From 75af3c34d35cb2202fdea2cbe69989b1c6af7d67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:37:56 +0000 Subject: [PATCH 01/14] Initial plan From 8de6f1678a6f0a51d8c765a40ba9211acd610e10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:42:03 +0000 Subject: [PATCH 02/14] Update .NET SDK version to match available version Co-authored-by: feO2x <7884158+feO2x@users.noreply.github.com> --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index 689dbbd..7ab46d1 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.411", + "version": "8.0.117", "rollForward": "disable" } } From 7763f81bda4565fc3b35008a1fcbb8684ccf0d4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:46:48 +0000 Subject: [PATCH 03/14] Implement comprehensive tests for CopyToTemporaryStreamAsync overloads and fix plugin bugs Co-authored-by: feO2x <7884158+feO2x@users.noreply.github.com> --- .../Hashing/HashingPlugin.cs | 9 +- .../TemporaryStreamServiceExtensions.cs | 2 +- .../TemporaryStreamServiceExtensionsTests.cs | 292 ++++++++++++++++++ 3 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceExtensionsTests.cs diff --git a/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs b/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs index 534165d..fe2009b 100644 --- a/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs +++ b/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs @@ -77,15 +77,14 @@ public async ValueTask DisposeAsync() public ValueTask SetUpAsync(Stream innerStream, CancellationToken cancellationToken = default) { innerStream.MustNotBeNull(); - CryptoStream outermostStream = null!; // In the constructor, we ensure that the immutable array is not empty + Stream currentStream = innerStream; for (var i = 0; i < HashCalculators.Length; i++) { - outermostStream = - HashCalculators[i].CreateWrappingCryptoStream(innerStream, leaveWrappedStreamOpen: true); + currentStream = HashCalculators[i].CreateWrappingCryptoStream(currentStream, leaveWrappedStreamOpen: true); } - _outermostCryptoStream = outermostStream; - return new ValueTask(outermostStream); + _outermostCryptoStream = (CryptoStream)currentStream; + return new ValueTask(currentStream); } /// diff --git a/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs b/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs index 8010220..68bee34 100644 --- a/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs +++ b/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs @@ -123,7 +123,7 @@ public static async Task CopyToTemporaryStreamAsync( { for (var i = 0; i < plugins.Length; i++) { - outermostStream = await plugins[i].SetUpAsync(source, cancellationToken).ConfigureAwait(false); + outermostStream = await plugins[i].SetUpAsync(outermostStream, cancellationToken).ConfigureAwait(false); } if (copyBufferSize.HasValue) diff --git a/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceExtensionsTests.cs b/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceExtensionsTests.cs new file mode 100644 index 0000000..495e511 --- /dev/null +++ b/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceExtensionsTests.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Immutable; +using System.IO; +using System.Security.Cryptography; +using System.Threading.Tasks; +using FluentAssertions; +using Light.TemporaryStreams.Hashing; +using Xunit; + +namespace Light.TemporaryStreams; + +public static class TemporaryStreamServiceExtensionsTests +{ + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldCreateMemoryStream_WhenSourceIsSmall() + { + // Arrange + var service = CreateDefaultService(); + var sourceData = CreateTestData(40_000); // Less than 80KB threshold + using var sourceStream = new MemoryStream(sourceData); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync(sourceStream); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeFalse(); + temporaryStream.Length.Should().Be(sourceData.Length); + + // Verify content was copied correctly + temporaryStream.Position = 0; + var copiedData = new byte[sourceData.Length]; + await temporaryStream.ReadExactlyAsync(copiedData); + copiedData.Should().BeEquivalentTo(sourceData); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldCreateFileStream_WhenSourceIsLarge() + { + // Arrange + var service = CreateDefaultService(); + var sourceData = CreateTestData(100_000); // More than 80KB threshold + using var sourceStream = new MemoryStream(sourceData); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync(sourceStream); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeTrue(); + temporaryStream.Length.Should().Be(sourceData.Length); + + // Verify content was copied correctly + temporaryStream.Position = 0; + var copiedData = new byte[sourceData.Length]; + await temporaryStream.ReadExactlyAsync(copiedData); + copiedData.Should().BeEquivalentTo(sourceData); + + // Clean up file + var filePath = temporaryStream.GetUnderlyingFilePath(); + await temporaryStream.DisposeAsync(); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldUseCopyBufferSize_WhenSpecified() + { + // Arrange + var service = CreateDefaultService(); + var sourceData = CreateTestData(50_000); + using var sourceStream = new MemoryStream(sourceData); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + copyBufferSize: 8192); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.Length.Should().Be(sourceData.Length); + + // Verify content was copied correctly + temporaryStream.Position = 0; + var copiedData = new byte[sourceData.Length]; + await temporaryStream.ReadExactlyAsync(copiedData); + copiedData.Should().BeEquivalentTo(sourceData); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldUseCustomOptions_WhenProvided() + { + // Arrange + var service = CreateDefaultService(); + var sourceData = CreateTestData(50_000); // Would normally use MemoryStream + using var sourceStream = new MemoryStream(sourceData); + + var customOptions = new TemporaryStreamServiceOptions + { + FileThresholdInBytes = 30_000 // Force FileStream usage + }; + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + options: customOptions); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeTrue(); // Should use file due to custom threshold + temporaryStream.Length.Should().Be(sourceData.Length); + + // Clean up file + var filePath = temporaryStream.GetUnderlyingFilePath(); + await temporaryStream.DisposeAsync(); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldCalculateCorrectHash_WhenSourceIsSmall() + { + // Arrange + var service = CreateDefaultService(); + var sourceData = CreateTestData(40_000); // Less than 80KB threshold + using var sourceStream = new MemoryStream(sourceData); + + using var sha1 = SHA1.Create(); + var hashCalculator = new CopyToHashCalculator(sha1, HashConversionMethod.UpperHexadecimal, "SHA1"); + var hashingPlugin = new HashingPlugin(ImmutableArray.Create(hashCalculator)); + + // Calculate expected hash for comparison + var expectedHash = Convert.ToHexString(SHA1.HashData(sourceData)); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + ImmutableArray.Create(hashingPlugin)); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeFalse(); + temporaryStream.Length.Should().Be(sourceData.Length); + + // Verify content was copied correctly + temporaryStream.Position = 0; + var copiedData = new byte[sourceData.Length]; + await temporaryStream.ReadExactlyAsync(copiedData); + copiedData.Should().BeEquivalentTo(sourceData); + + // Verify hash was calculated correctly + hashCalculator.Hash.Should().Be(expectedHash); + + await hashingPlugin.DisposeAsync(); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldCalculateCorrectHash_WhenSourceIsLarge() + { + // Arrange + var service = CreateDefaultService(); + var sourceData = CreateTestData(100_000); // More than 80KB threshold + using var sourceStream = new MemoryStream(sourceData); + + using var sha1 = SHA1.Create(); + var hashCalculator = new CopyToHashCalculator(sha1, HashConversionMethod.UpperHexadecimal, "SHA1"); + var hashingPlugin = new HashingPlugin(ImmutableArray.Create(hashCalculator)); + + // Calculate expected hash for comparison + var expectedHash = Convert.ToHexString(SHA1.HashData(sourceData)); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + ImmutableArray.Create(hashingPlugin)); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeTrue(); + temporaryStream.Length.Should().Be(sourceData.Length); + + // Verify content was copied correctly + temporaryStream.Position = 0; + var copiedData = new byte[sourceData.Length]; + await temporaryStream.ReadExactlyAsync(copiedData); + copiedData.Should().BeEquivalentTo(sourceData); + + // Verify hash was calculated correctly + hashCalculator.Hash.Should().Be(expectedHash); + + await hashingPlugin.DisposeAsync(); + + // Clean up file + var filePath = temporaryStream.GetUnderlyingFilePath(); + await temporaryStream.DisposeAsync(); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldSupportMultipleHashAlgorithms() + { + // Arrange + var service = CreateDefaultService(); + var sourceData = CreateTestData(50_000); + using var sourceStream = new MemoryStream(sourceData); + + using var sha1 = SHA1.Create(); + using var sha256 = SHA256.Create(); + var sha1Calculator = new CopyToHashCalculator(sha1, HashConversionMethod.UpperHexadecimal, "SHA1"); + var sha256Calculator = new CopyToHashCalculator(sha256, HashConversionMethod.UpperHexadecimal, "SHA256"); + var hashingPlugin = new HashingPlugin(ImmutableArray.Create(sha1Calculator, sha256Calculator)); + + // Calculate expected hashes for comparison + var expectedSha1Hash = Convert.ToHexString(SHA1.HashData(sourceData)); + var expectedSha256Hash = Convert.ToHexString(SHA256.HashData(sourceData)); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + ImmutableArray.Create(hashingPlugin)); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.Length.Should().Be(sourceData.Length); + + // Verify hashes were calculated correctly + sha1Calculator.Hash.Should().Be(expectedSha1Hash); + sha256Calculator.Hash.Should().Be(expectedSha256Hash); + + await hashingPlugin.DisposeAsync(); + + // Clean up file if needed + if (temporaryStream.IsFileBased) + { + var filePath = temporaryStream.GetUnderlyingFilePath(); + await temporaryStream.DisposeAsync(); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldUseCustomBufferSize() + { + // Arrange + var service = CreateDefaultService(); + var sourceData = CreateTestData(60_000); + using var sourceStream = new MemoryStream(sourceData); + + using var sha1 = SHA1.Create(); + var hashCalculator = new CopyToHashCalculator(sha1, HashConversionMethod.UpperHexadecimal, "SHA1"); + var hashingPlugin = new HashingPlugin(ImmutableArray.Create(hashCalculator)); + + var expectedHash = Convert.ToHexString(SHA1.HashData(sourceData)); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + ImmutableArray.Create(hashingPlugin), + copyBufferSize: 4096); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.Length.Should().Be(sourceData.Length); + hashCalculator.Hash.Should().Be(expectedHash); + + await hashingPlugin.DisposeAsync(); + } + + private static TemporaryStreamService CreateDefaultService() => + new( + new TemporaryStreamServiceOptions(), + new TemporaryStreamErrorHandlerProvider(null) + ); + + private static byte[] CreateTestData(int size) + { + var data = new byte[size]; + var random = new Random(42); // Fixed seed for reproducible tests + random.NextBytes(data); + return data; + } +} \ No newline at end of file From 220d8c8bce839e06a477e68dc7fec3653bdbaea0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:51:09 +0000 Subject: [PATCH 04/14] Complete CopyToTemporaryStreamAsync tests implementation with PDF files Co-authored-by: feO2x <7884158+feO2x@users.noreply.github.com> --- .../Light.TemporaryStreams.Core.Tests.csproj | 2 ++ .../less-than-80KB.pdf | Bin 0 -> 596 bytes .../more-than-80KB.pdf | Bin 0 -> 80602 bytes 3 files changed, 2 insertions(+) create mode 100644 tests/Light.TemporaryStreams.Core.Tests/less-than-80KB.pdf create mode 100644 tests/Light.TemporaryStreams.Core.Tests/more-than-80KB.pdf diff --git a/tests/Light.TemporaryStreams.Core.Tests/Light.TemporaryStreams.Core.Tests.csproj b/tests/Light.TemporaryStreams.Core.Tests/Light.TemporaryStreams.Core.Tests.csproj index fd61100..11d536a 100644 --- a/tests/Light.TemporaryStreams.Core.Tests/Light.TemporaryStreams.Core.Tests.csproj +++ b/tests/Light.TemporaryStreams.Core.Tests/Light.TemporaryStreams.Core.Tests.csproj @@ -22,6 +22,8 @@ + + diff --git a/tests/Light.TemporaryStreams.Core.Tests/less-than-80KB.pdf b/tests/Light.TemporaryStreams.Core.Tests/less-than-80KB.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e294b20eaca5b74baebb752b445e99f8f1292bf5 GIT binary patch literal 596 zcmZWn(N4lJ6n*bkoEH*((5{1mB!mZ)#YBw;_C|at>~4;X)wIQ+UvE2xfHYaMoqNu? z=iasR_K@6&+a42$pqFbl8iA+Zp9FrdoT_vI{-z4Dh&^G3@t9Ga1!DL<7wmcbsmv@E z-9Uf$E_z!#h!bI`Tna-QL!P?t!)Ijm)~tNvkSNi<(!2=t+92K%{DrboP4o_n$isaR zV=#=Hwzcza7P4g@Vh8nk_r{ygw@cTI9flX+6J=>%d8F!#T)9#wGX&Rz%QMx5`+$r> z-8w^R!zL-?iGYuj=1fEp2L4Gib~WFqs)AoSa&6$qy0R{=k*?V^4G~*?yW^dqocXa> z^movgvMZ6}hz-$HQ6DjRcYz7vLx*$1i=|)3Kdc-3O&&03R9TT}ZpFO(A@sp#T8AIn T-UjQGaZS!%ELf*AeNBD=9vqyD literal 0 HcmV?d00001 diff --git a/tests/Light.TemporaryStreams.Core.Tests/more-than-80KB.pdf b/tests/Light.TemporaryStreams.Core.Tests/more-than-80KB.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8df06e3b265a81004910a77bd61c4e04bd1b12a1 GIT binary patch literal 80602 zcmeIu(MlUp5CG8k{fc=J+J|u1j^hZlid1vCq7|*pI1GS9(7upQWa{tuMO4{YFPsm)U$2 zF?v{}YZ>|1v{;n~)m@woy6~hJ#?IbwGj`o}-I^FbR6nlW-+noL(>p)CJ#RP9?jK)7 zeo!{)KJz(U|4QxZqI~v-zUyO`r~!k2lFgHDx%wHewh_rYNz|n z7ulQRvR+~cyUqfJe_S})(m{3<(~XCE8_1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5Fqd$fh?^TO!V?cv}l*4E|fI@Q^98u{t!Pl`$8v#M&Nh(XL^e?M#5vTiqf?hZ!7 LBHP+J{C4ygEq(f< literal 0 HcmV?d00001 From 558af0f23ec20f3670b266c0dd495a13ce95c5cb Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 1 Jul 2025 21:28:08 +0200 Subject: [PATCH 05/14] test: cleanup tests for CopyToTemporaryStream Signed-off-by: Kenny Pflug --- Light.TemporaryStreams.sln.DotSettings | 1 + .../Hashing/CopyToHashCalculator.cs | 78 +++-- .../Hashing/HashConverter.cs | 31 ++ .../CopyToTemporaryStreamTests.cs | 319 ++++++++++++++++++ .../Light.TemporaryStreams.Core.Tests.csproj | 2 - .../TemporaryStreamServiceExtensionsTests.cs | 292 ---------------- .../TemporaryStreamServiceTests.cs | 13 +- .../less-than-80KB.pdf | Bin 596 -> 0 bytes .../more-than-80KB.pdf | Bin 80602 -> 0 bytes 9 files changed, 404 insertions(+), 332 deletions(-) create mode 100644 src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs create mode 100644 tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs delete mode 100644 tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceExtensionsTests.cs delete mode 100644 tests/Light.TemporaryStreams.Core.Tests/less-than-80KB.pdf delete mode 100644 tests/Light.TemporaryStreams.Core.Tests/more-than-80KB.pdf diff --git a/Light.TemporaryStreams.sln.DotSettings b/Light.TemporaryStreams.sln.DotSettings index bb2d7f1..0a92497 100644 --- a/Light.TemporaryStreams.sln.DotSettings +++ b/Light.TemporaryStreams.sln.DotSettings @@ -163,4 +163,5 @@ True True True + True diff --git a/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs b/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs index 5c9fd69..2f5e55e 100644 --- a/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs +++ b/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs @@ -22,15 +22,11 @@ public sealed class CopyToHashCalculator : IAsyncDisposable /// The hash algorithm to use. /// The enum value identifying how hash byte arrays are converted to strings. /// A name that uniquely identifies the hash algorithm. - public CopyToHashCalculator( - HashAlgorithm hashAlgorithm, - HashConversionMethod conversionMethod, - string? name = null - ) + public CopyToHashCalculator(HashAlgorithm hashAlgorithm, HashConversionMethod conversionMethod, string? name = null) { HashAlgorithm = hashAlgorithm.MustNotBeNull(); ConversionMethod = conversionMethod.MustBeValidEnumValue(); - Name = name ?? hashAlgorithm.GetType().Name; + Name = name ?? DetermineDefaultName(hashAlgorithm); } /// @@ -54,12 +50,21 @@ public CopyToHashCalculator( /// /// Thrown when has not been called yet. /// - public string Hash => - _hash.MustNotBeNull( - () => new InvalidOperationException( - $"ObtainHashFromAlgorithm must be called before accessing the {nameof(Hash)} property." - ) - ); + public string Hash + { + get + { + var hash = _hash; + if (hash is null) + { + throw new InvalidOperationException( + $"ObtainHashFromAlgorithm must be called before accessing the {nameof(Hash)} property." + ); + } + + return hash; + } + } /// /// The calculated hash in byte array representation. @@ -67,12 +72,21 @@ public CopyToHashCalculator( /// /// Thrown when has not been called yet. /// - public byte[] HashArray => - _hashArray.MustNotBeNull( - () => new InvalidOperationException( - $"ObtainHashFromAlgorithm must be called before accessing the {nameof(HashArray)} property." - ) - ); + public byte[] HashArray + { + get + { + var hashArray = _hashArray; + if (hashArray is null) + { + throw new InvalidOperationException( + $"ObtainHashFromAlgorithm must be called before accessing the {nameof(HashArray)} property." + ); + } + + return hashArray; + } + } /// /// Asynchronously disposes the resources used by the current instance, including the CryptoStream and the hash algorithm. @@ -90,6 +104,23 @@ public async ValueTask DisposeAsync() HashAlgorithm.Dispose(); } + private static string DetermineDefaultName(HashAlgorithm hashAlgorithm) + { + /* Some of the .NET hash algorithms (like .SHA1, MD5) are actually just abstract base classes. They have a + * nested type called implementation which can be instantiated, but this type is not publicly visible. GetType() + * will likely return the implementation type, which is not what we want as callers would want the name of the + * base type instead. This is why we check the name of the type for ".Implementation" and if found, we return + * the name of the base type instead. + * + * Other types like HMACSHA256 are public non-abstract types and can be used as expected. */ + var type = hashAlgorithm.GetType(); + var name = type.Name; + var baseTypeName = type.BaseType?.Name; + return name.Equals("Implementation", StringComparison.Ordinal) && !baseTypeName.IsNullOrWhiteSpace() ? + baseTypeName : + name; + } + /// /// Creates a CryptoStream wrapped around the specified stream. The CryptoStream /// is configured to calculate a hash using the hash algorithm provided by the @@ -131,18 +162,9 @@ public void ObtainHashFromAlgorithm() ) ); - _hash = ConvertHashToString(_hashArray); + _hash = HashConverter.ConvertHashToString(_hashArray, ConversionMethod); } - private string ConvertHashToString(byte[] hashArray) => - ConversionMethod switch - { - HashConversionMethod.Base64 => Convert.ToBase64String(hashArray), - HashConversionMethod.UpperHexadecimal => Convert.ToHexString(hashArray), - HashConversionMethod.None => "", - _ => throw new InvalidDataException($"{nameof(ConversionMethod)} has an invalid value") - }; - /// /// Converts a to a using the default settings /// (hash array is converted to a Base64 string, name is identical to the type name). diff --git a/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs b/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs new file mode 100644 index 0000000..1631b5d --- /dev/null +++ b/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; + +namespace Light.TemporaryStreams.Hashing; + +/// +/// Converts hash byte arrays to strings. +/// +public static class HashConverter +{ + /// + /// Converts the hash byte array to a string based on the specified . + /// + /// The hash byte array to convert. + /// The enum value specifying how the hash byte array should be converted to a string. + /// The hash as a string. + /// + /// Thrown if has an invalid value. + /// + public static string ConvertHashToString(byte[] hashArray, HashConversionMethod conversionMethod) => + conversionMethod switch + { + HashConversionMethod.Base64 => Convert.ToBase64String(hashArray), + HashConversionMethod.UpperHexadecimal => Convert.ToHexString(hashArray), + HashConversionMethod.None => "", + _ => throw new ArgumentOutOfRangeException( + nameof(conversionMethod), + $"{nameof(conversionMethod)} has an invalid value" + ) + }; +} diff --git a/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs new file mode 100644 index 0000000..c45a464 --- /dev/null +++ b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Immutable; +using System.IO; +using System.Security.Cryptography; +using System.Threading.Tasks; +using FluentAssertions; +using Light.TemporaryStreams.Hashing; +using Xunit; + +namespace Light.TemporaryStreams; + +public static class CopyToTemporaryStreamTests +{ + [Theory] + [InlineData(TemporaryStreamServiceOptions.DefaultFileThresholdInBytes - 1)] + [InlineData(12_000)] + public static async Task CopyToTemporaryStreamAsync_ShouldCreateMemoryStream_WhenSourceIsSmall(int bufferSize) + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var service = CreateDefaultService(); + var sourceData = CreateTestData(bufferSize); + using var sourceStream = new MemoryStream(sourceData); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + cancellationToken: cancellationToken + ); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeFalse(); + temporaryStream.Length.Should().Be(sourceData.Length); + temporaryStream.Position = 0; + var copiedData = new byte[sourceData.Length]; + await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); + copiedData.Should().Equal(sourceData); + } + + [Theory] + [InlineData(TemporaryStreamServiceOptions.DefaultFileThresholdInBytes)] + [InlineData(100_000)] + public static async Task CopyToTemporaryStreamAsync_ShouldCreateFileStream_WhenSourceIsLarge(int bufferSize) + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var service = CreateDefaultService(); + var sourceData = CreateTestData(bufferSize); // More than 80KB threshold + using var sourceStream = new MemoryStream(sourceData); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + cancellationToken: cancellationToken + ); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeTrue(); + temporaryStream.Length.Should().Be(sourceData.Length); + temporaryStream.Position = 0; + var copiedData = new byte[sourceData.Length]; + await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); + copiedData.Should().Equal(sourceData); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldUseCopyBufferSize_WhenSpecified() + { + var cancellationToken = TestContext.Current.CancellationToken; + // Arrange + var service = CreateDefaultService(); + var sourceData = CreateTestData(100_000); + using var sourceStream = new MemoryStream(sourceData); + var filePath = Path.GetFullPath("test.txt"); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + filePath: filePath, + cancellationToken: cancellationToken + ); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.Length.Should().Be(sourceData.Length); + temporaryStream.IsFileBased.Should().BeTrue(); + temporaryStream.GetUnderlyingFilePath().Should().Be(filePath); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldUseCustomOptions_WhenProvided() + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var service = CreateDefaultService(); + var sourceData = CreateTestData(50_000); // Would normally use MemoryStream + using var sourceStream = new MemoryStream(sourceData); + + var customOptions = new TemporaryStreamServiceOptions + { + FileThresholdInBytes = 30_000 // Force FileStream usage + }; + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + options: customOptions, + cancellationToken: cancellationToken + ); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeTrue(); // Should use file due to custom threshold + temporaryStream.Length.Should().Be(sourceData.Length); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldForwardFilePath() + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var service = CreateDefaultService(); + var sourceData = CreateTestData(100_000); + using var sourceStream = new MemoryStream(sourceData); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + filePath: "test.txt", + cancellationToken: cancellationToken + ); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeTrue(); + temporaryStream.Length.Should().Be(sourceData.Length); + temporaryStream.Position = 0; + var copiedData = new byte[sourceData.Length]; + await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); + copiedData.Should().Equal(sourceData); + } + + [Theory] + [InlineData(HashConversionMethod.Base64)] + [InlineData(HashConversionMethod.UpperHexadecimal)] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldCalculateCorrectHash_WhenSourceIsSmall( + HashConversionMethod hashConversionMethod + ) + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var service = CreateDefaultService(); + var sourceData = CreateTestData(40_000); // Less than 80KB threshold + using var sourceStream = new MemoryStream(sourceData); + await using var hashingPlugin = + new HashingPlugin([new CopyToHashCalculator(SHA1.Create(), hashConversionMethod)]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + ImmutableArray.Create(hashingPlugin), + cancellationToken: cancellationToken + ); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeFalse(); + temporaryStream.Length.Should().Be(sourceData.Length); + temporaryStream.Position = 0; + var expectedHash = HashConverter.ConvertHashToString(SHA1.HashData(sourceData), hashConversionMethod); + var copiedData = new byte[sourceData.Length]; + await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); + copiedData.Should().Equal(sourceData); + hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedHash); + } + + [Theory] + [InlineData(HashConversionMethod.Base64)] + [InlineData(HashConversionMethod.UpperHexadecimal)] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldCalculateCorrectHash_WhenSourceIsLarge( + HashConversionMethod hashConversionMethod + ) + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var service = CreateDefaultService(); + var sourceData = CreateTestData(100_000); // More than 80KB threshold + using var sourceStream = new MemoryStream(sourceData); + await using var hashingPlugin = + new HashingPlugin([new CopyToHashCalculator(MD5.Create(), hashConversionMethod)]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + ImmutableArray.Create(hashingPlugin), + cancellationToken: cancellationToken + ); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeTrue(); + temporaryStream.Length.Should().Be(sourceData.Length); + temporaryStream.Position = 0; + var copiedData = new byte[sourceData.Length]; + await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); + copiedData.Should().Equal(sourceData); + var expectedHash = HashConverter.ConvertHashToString(MD5.HashData(sourceData), hashConversionMethod); + hashingPlugin.GetHash(nameof(MD5)).Should().Be(expectedHash); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldSupportMultipleHashAlgorithms() + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var service = CreateDefaultService(); + var sourceData = CreateTestData(50_000); + using var sourceStream = new MemoryStream(sourceData); + + await using var hashingPlugin = new HashingPlugin( + [ + new CopyToHashCalculator(SHA1.Create(), HashConversionMethod.UpperHexadecimal), + new CopyToHashCalculator(SHA256.Create(), HashConversionMethod.Base64) + ] + ); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashingPlugin], + cancellationToken: cancellationToken + ); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.Length.Should().Be(sourceData.Length); + var expectedSha1Hash = HashConverter.ConvertHashToString( + SHA1.HashData(sourceData), + HashConversionMethod.UpperHexadecimal + ); + var expectedSha256Hash = HashConverter.ConvertHashToString( + SHA256.HashData(sourceData), + HashConversionMethod.Base64 + ); + hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedSha1Hash); + hashingPlugin.GetHash(nameof(SHA256)).Should().Be(expectedSha256Hash); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldUseCustomBufferSize() + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var service = CreateDefaultService(); + var sourceData = CreateTestData(60_000); + using var sourceStream = new MemoryStream(sourceData); + await using var hashingPlugin = new HashingPlugin([SHA1.Create()]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + ImmutableArray.Create(hashingPlugin), + copyBufferSize: 4096, + cancellationToken: cancellationToken + ); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.Length.Should().Be(sourceData.Length); + var expectedHash = HashConverter.ConvertHashToString(SHA1.HashData(sourceData), HashConversionMethod.Base64); + hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedHash); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldForwardFilePath() + { + // Arrange + var cancellationToken = TestContext.Current.CancellationToken; + var service = CreateDefaultService(); + var sourceData = CreateTestData(100_000); + using var sourceStream = new MemoryStream(sourceData); + await using var hashingPlugin = new HashingPlugin([SHA1.Create()]); + var filePath = Path.GetFullPath("test.txt"); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashingPlugin], + filePath: filePath, + cancellationToken: cancellationToken + ); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeTrue(); + temporaryStream.Length.Should().Be(sourceData.Length); + temporaryStream.GetUnderlyingFilePath().Should().Be(filePath); + var expectedHash = HashConverter.ConvertHashToString(SHA1.HashData(sourceData), HashConversionMethod.Base64); + hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedHash); + } + + private static TemporaryStreamService CreateDefaultService() => + new ( + new TemporaryStreamServiceOptions(), + new TemporaryStreamErrorHandlerProvider( + (_, exception) => { TestContext.Current.TestOutputHelper?.WriteLine(exception.ToString()); } + ) + ); + + private static byte[] CreateTestData(int size) + { + var data = new byte[size]; + var random = new Random(42); // Fixed seed for reproducible tests + random.NextBytes(data); + return data; + } +} diff --git a/tests/Light.TemporaryStreams.Core.Tests/Light.TemporaryStreams.Core.Tests.csproj b/tests/Light.TemporaryStreams.Core.Tests/Light.TemporaryStreams.Core.Tests.csproj index 11d536a..fd61100 100644 --- a/tests/Light.TemporaryStreams.Core.Tests/Light.TemporaryStreams.Core.Tests.csproj +++ b/tests/Light.TemporaryStreams.Core.Tests/Light.TemporaryStreams.Core.Tests.csproj @@ -22,8 +22,6 @@ - - diff --git a/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceExtensionsTests.cs b/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceExtensionsTests.cs deleted file mode 100644 index 495e511..0000000 --- a/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceExtensionsTests.cs +++ /dev/null @@ -1,292 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.IO; -using System.Security.Cryptography; -using System.Threading.Tasks; -using FluentAssertions; -using Light.TemporaryStreams.Hashing; -using Xunit; - -namespace Light.TemporaryStreams; - -public static class TemporaryStreamServiceExtensionsTests -{ - [Fact] - public static async Task CopyToTemporaryStreamAsync_ShouldCreateMemoryStream_WhenSourceIsSmall() - { - // Arrange - var service = CreateDefaultService(); - var sourceData = CreateTestData(40_000); // Less than 80KB threshold - using var sourceStream = new MemoryStream(sourceData); - - // Act - await using var temporaryStream = await service.CopyToTemporaryStreamAsync(sourceStream); - - // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeFalse(); - temporaryStream.Length.Should().Be(sourceData.Length); - - // Verify content was copied correctly - temporaryStream.Position = 0; - var copiedData = new byte[sourceData.Length]; - await temporaryStream.ReadExactlyAsync(copiedData); - copiedData.Should().BeEquivalentTo(sourceData); - } - - [Fact] - public static async Task CopyToTemporaryStreamAsync_ShouldCreateFileStream_WhenSourceIsLarge() - { - // Arrange - var service = CreateDefaultService(); - var sourceData = CreateTestData(100_000); // More than 80KB threshold - using var sourceStream = new MemoryStream(sourceData); - - // Act - await using var temporaryStream = await service.CopyToTemporaryStreamAsync(sourceStream); - - // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeTrue(); - temporaryStream.Length.Should().Be(sourceData.Length); - - // Verify content was copied correctly - temporaryStream.Position = 0; - var copiedData = new byte[sourceData.Length]; - await temporaryStream.ReadExactlyAsync(copiedData); - copiedData.Should().BeEquivalentTo(sourceData); - - // Clean up file - var filePath = temporaryStream.GetUnderlyingFilePath(); - await temporaryStream.DisposeAsync(); - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - } - - [Fact] - public static async Task CopyToTemporaryStreamAsync_ShouldUseCopyBufferSize_WhenSpecified() - { - // Arrange - var service = CreateDefaultService(); - var sourceData = CreateTestData(50_000); - using var sourceStream = new MemoryStream(sourceData); - - // Act - await using var temporaryStream = await service.CopyToTemporaryStreamAsync( - sourceStream, - copyBufferSize: 8192); - - // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.Length.Should().Be(sourceData.Length); - - // Verify content was copied correctly - temporaryStream.Position = 0; - var copiedData = new byte[sourceData.Length]; - await temporaryStream.ReadExactlyAsync(copiedData); - copiedData.Should().BeEquivalentTo(sourceData); - } - - [Fact] - public static async Task CopyToTemporaryStreamAsync_ShouldUseCustomOptions_WhenProvided() - { - // Arrange - var service = CreateDefaultService(); - var sourceData = CreateTestData(50_000); // Would normally use MemoryStream - using var sourceStream = new MemoryStream(sourceData); - - var customOptions = new TemporaryStreamServiceOptions - { - FileThresholdInBytes = 30_000 // Force FileStream usage - }; - - // Act - await using var temporaryStream = await service.CopyToTemporaryStreamAsync( - sourceStream, - options: customOptions); - - // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeTrue(); // Should use file due to custom threshold - temporaryStream.Length.Should().Be(sourceData.Length); - - // Clean up file - var filePath = temporaryStream.GetUnderlyingFilePath(); - await temporaryStream.DisposeAsync(); - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - } - - [Fact] - public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldCalculateCorrectHash_WhenSourceIsSmall() - { - // Arrange - var service = CreateDefaultService(); - var sourceData = CreateTestData(40_000); // Less than 80KB threshold - using var sourceStream = new MemoryStream(sourceData); - - using var sha1 = SHA1.Create(); - var hashCalculator = new CopyToHashCalculator(sha1, HashConversionMethod.UpperHexadecimal, "SHA1"); - var hashingPlugin = new HashingPlugin(ImmutableArray.Create(hashCalculator)); - - // Calculate expected hash for comparison - var expectedHash = Convert.ToHexString(SHA1.HashData(sourceData)); - - // Act - await using var temporaryStream = await service.CopyToTemporaryStreamAsync( - sourceStream, - ImmutableArray.Create(hashingPlugin)); - - // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeFalse(); - temporaryStream.Length.Should().Be(sourceData.Length); - - // Verify content was copied correctly - temporaryStream.Position = 0; - var copiedData = new byte[sourceData.Length]; - await temporaryStream.ReadExactlyAsync(copiedData); - copiedData.Should().BeEquivalentTo(sourceData); - - // Verify hash was calculated correctly - hashCalculator.Hash.Should().Be(expectedHash); - - await hashingPlugin.DisposeAsync(); - } - - [Fact] - public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldCalculateCorrectHash_WhenSourceIsLarge() - { - // Arrange - var service = CreateDefaultService(); - var sourceData = CreateTestData(100_000); // More than 80KB threshold - using var sourceStream = new MemoryStream(sourceData); - - using var sha1 = SHA1.Create(); - var hashCalculator = new CopyToHashCalculator(sha1, HashConversionMethod.UpperHexadecimal, "SHA1"); - var hashingPlugin = new HashingPlugin(ImmutableArray.Create(hashCalculator)); - - // Calculate expected hash for comparison - var expectedHash = Convert.ToHexString(SHA1.HashData(sourceData)); - - // Act - await using var temporaryStream = await service.CopyToTemporaryStreamAsync( - sourceStream, - ImmutableArray.Create(hashingPlugin)); - - // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeTrue(); - temporaryStream.Length.Should().Be(sourceData.Length); - - // Verify content was copied correctly - temporaryStream.Position = 0; - var copiedData = new byte[sourceData.Length]; - await temporaryStream.ReadExactlyAsync(copiedData); - copiedData.Should().BeEquivalentTo(sourceData); - - // Verify hash was calculated correctly - hashCalculator.Hash.Should().Be(expectedHash); - - await hashingPlugin.DisposeAsync(); - - // Clean up file - var filePath = temporaryStream.GetUnderlyingFilePath(); - await temporaryStream.DisposeAsync(); - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - } - - [Fact] - public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldSupportMultipleHashAlgorithms() - { - // Arrange - var service = CreateDefaultService(); - var sourceData = CreateTestData(50_000); - using var sourceStream = new MemoryStream(sourceData); - - using var sha1 = SHA1.Create(); - using var sha256 = SHA256.Create(); - var sha1Calculator = new CopyToHashCalculator(sha1, HashConversionMethod.UpperHexadecimal, "SHA1"); - var sha256Calculator = new CopyToHashCalculator(sha256, HashConversionMethod.UpperHexadecimal, "SHA256"); - var hashingPlugin = new HashingPlugin(ImmutableArray.Create(sha1Calculator, sha256Calculator)); - - // Calculate expected hashes for comparison - var expectedSha1Hash = Convert.ToHexString(SHA1.HashData(sourceData)); - var expectedSha256Hash = Convert.ToHexString(SHA256.HashData(sourceData)); - - // Act - await using var temporaryStream = await service.CopyToTemporaryStreamAsync( - sourceStream, - ImmutableArray.Create(hashingPlugin)); - - // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.Length.Should().Be(sourceData.Length); - - // Verify hashes were calculated correctly - sha1Calculator.Hash.Should().Be(expectedSha1Hash); - sha256Calculator.Hash.Should().Be(expectedSha256Hash); - - await hashingPlugin.DisposeAsync(); - - // Clean up file if needed - if (temporaryStream.IsFileBased) - { - var filePath = temporaryStream.GetUnderlyingFilePath(); - await temporaryStream.DisposeAsync(); - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - } - } - - [Fact] - public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldUseCustomBufferSize() - { - // Arrange - var service = CreateDefaultService(); - var sourceData = CreateTestData(60_000); - using var sourceStream = new MemoryStream(sourceData); - - using var sha1 = SHA1.Create(); - var hashCalculator = new CopyToHashCalculator(sha1, HashConversionMethod.UpperHexadecimal, "SHA1"); - var hashingPlugin = new HashingPlugin(ImmutableArray.Create(hashCalculator)); - - var expectedHash = Convert.ToHexString(SHA1.HashData(sourceData)); - - // Act - await using var temporaryStream = await service.CopyToTemporaryStreamAsync( - sourceStream, - ImmutableArray.Create(hashingPlugin), - copyBufferSize: 4096); - - // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.Length.Should().Be(sourceData.Length); - hashCalculator.Hash.Should().Be(expectedHash); - - await hashingPlugin.DisposeAsync(); - } - - private static TemporaryStreamService CreateDefaultService() => - new( - new TemporaryStreamServiceOptions(), - new TemporaryStreamErrorHandlerProvider(null) - ); - - private static byte[] CreateTestData(int size) - { - var data = new byte[size]; - var random = new Random(42); // Fixed seed for reproducible tests - random.NextBytes(data); - return data; - } -} \ No newline at end of file diff --git a/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceTests.cs b/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceTests.cs index f16af90..aa10afe 100644 --- a/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceTests.cs +++ b/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceTests.cs @@ -127,16 +127,9 @@ public static void CreateTemporaryStream_ShouldUseOverrideOptions_WhenOptionsAre using var temporaryStream = service.CreateTemporaryStream(750, options: overrideOptions); - try - { - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeTrue(); // 750 > 500 (override threshold) - temporaryStream.DisposeBehavior.Should().Be(TemporaryStreamDisposeBehavior.CloseUnderlyingStreamOnly); - } - finally - { - File.Delete(temporaryStream.GetUnderlyingFilePath()); - } + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeTrue(); // 750 > 500 (override threshold) + temporaryStream.DisposeBehavior.Should().Be(TemporaryStreamDisposeBehavior.CloseUnderlyingStreamOnly); } [Fact] diff --git a/tests/Light.TemporaryStreams.Core.Tests/less-than-80KB.pdf b/tests/Light.TemporaryStreams.Core.Tests/less-than-80KB.pdf deleted file mode 100644 index e294b20eaca5b74baebb752b445e99f8f1292bf5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 596 zcmZWn(N4lJ6n*bkoEH*((5{1mB!mZ)#YBw;_C|at>~4;X)wIQ+UvE2xfHYaMoqNu? z=iasR_K@6&+a42$pqFbl8iA+Zp9FrdoT_vI{-z4Dh&^G3@t9Ga1!DL<7wmcbsmv@E z-9Uf$E_z!#h!bI`Tna-QL!P?t!)Ijm)~tNvkSNi<(!2=t+92K%{DrboP4o_n$isaR zV=#=Hwzcza7P4g@Vh8nk_r{ygw@cTI9flX+6J=>%d8F!#T)9#wGX&Rz%QMx5`+$r> z-8w^R!zL-?iGYuj=1fEp2L4Gib~WFqs)AoSa&6$qy0R{=k*?V^4G~*?yW^dqocXa> z^movgvMZ6}hz-$HQ6DjRcYz7vLx*$1i=|)3Kdc-3O&&03R9TT}ZpFO(A@sp#T8AIn T-UjQGaZS!%ELf*AeNBD=9vqyD diff --git a/tests/Light.TemporaryStreams.Core.Tests/more-than-80KB.pdf b/tests/Light.TemporaryStreams.Core.Tests/more-than-80KB.pdf deleted file mode 100644 index 8df06e3b265a81004910a77bd61c4e04bd1b12a1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80602 zcmeIu(MlUp5CG8k{fc=J+J|u1j^hZlid1vCq7|*pI1GS9(7upQWa{tuMO4{YFPsm)U$2 zF?v{}YZ>|1v{;n~)m@woy6~hJ#?IbwGj`o}-I^FbR6nlW-+noL(>p)CJ#RP9?jK)7 zeo!{)KJz(U|4QxZqI~v-zUyO`r~!k2lFgHDx%wHewh_rYNz|n z7ulQRvR+~cyUqfJe_S})(m{3<(~XCE8_1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5Fqd$fh?^TO!V?cv}l*4E|fI@Q^98u{t!Pl`$8v#M&Nh(XL^e?M#5vTiqf?hZ!7 LBHP+J{C4ygEq(f< From b7b2c2ae1ea8caeec626d8088476a5ce8ba37892 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 5 Jul 2025 23:02:28 +0200 Subject: [PATCH 06/14] test: refactor CopyToTemporaryStreamTests Signed-off-by: Kenny Pflug --- .../CopyToTemporaryStreamTests.cs | 226 ++++++++++-------- 1 file changed, 126 insertions(+), 100 deletions(-) diff --git a/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs index c45a464..e263797 100644 --- a/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs +++ b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs @@ -2,6 +2,7 @@ using System.Collections.Immutable; using System.IO; using System.Security.Cryptography; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Light.TemporaryStreams.Hashing; @@ -17,10 +18,8 @@ public static class CopyToTemporaryStreamTests public static async Task CopyToTemporaryStreamAsync_ShouldCreateMemoryStream_WhenSourceIsSmall(int bufferSize) { // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(bufferSize); var cancellationToken = TestContext.Current.CancellationToken; - var service = CreateDefaultService(); - var sourceData = CreateTestData(bufferSize); - using var sourceStream = new MemoryStream(sourceData); // Act await using var temporaryStream = await service.CopyToTemporaryStreamAsync( @@ -29,13 +28,12 @@ public static async Task CopyToTemporaryStreamAsync_ShouldCreateMemoryStream_Whe ); // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeFalse(); - temporaryStream.Length.Should().Be(sourceData.Length); - temporaryStream.Position = 0; - var copiedData = new byte[sourceData.Length]; - await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); - copiedData.Should().Equal(sourceData); + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: false, + cancellationToken + ); } [Theory] @@ -44,10 +42,8 @@ public static async Task CopyToTemporaryStreamAsync_ShouldCreateMemoryStream_Whe public static async Task CopyToTemporaryStreamAsync_ShouldCreateFileStream_WhenSourceIsLarge(int bufferSize) { // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(bufferSize); var cancellationToken = TestContext.Current.CancellationToken; - var service = CreateDefaultService(); - var sourceData = CreateTestData(bufferSize); // More than 80KB threshold - using var sourceStream = new MemoryStream(sourceData); // Act await using var temporaryStream = await service.CopyToTemporaryStreamAsync( @@ -56,23 +52,20 @@ public static async Task CopyToTemporaryStreamAsync_ShouldCreateFileStream_WhenS ); // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeTrue(); - temporaryStream.Length.Should().Be(sourceData.Length); - temporaryStream.Position = 0; - var copiedData = new byte[sourceData.Length]; - await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); - copiedData.Should().Equal(sourceData); + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: true, + cancellationToken + ); } [Fact] public static async Task CopyToTemporaryStreamAsync_ShouldUseCopyBufferSize_WhenSpecified() { - var cancellationToken = TestContext.Current.CancellationToken; // Arrange - var service = CreateDefaultService(); - var sourceData = CreateTestData(100_000); - using var sourceStream = new MemoryStream(sourceData); + var (service, sourceData, sourceStream) = CreateTestSetup(100_000); + var cancellationToken = TestContext.Current.CancellationToken; var filePath = Path.GetFullPath("test.txt"); // Act @@ -83,21 +76,20 @@ public static async Task CopyToTemporaryStreamAsync_ShouldUseCopyBufferSize_When ); // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.Length.Should().Be(sourceData.Length); - temporaryStream.IsFileBased.Should().BeTrue(); - temporaryStream.GetUnderlyingFilePath().Should().Be(filePath); + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: true, + cancellationToken + ); } [Fact] public static async Task CopyToTemporaryStreamAsync_ShouldUseCustomOptions_WhenProvided() { // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(50_000); // Would normally use MemoryStream var cancellationToken = TestContext.Current.CancellationToken; - var service = CreateDefaultService(); - var sourceData = CreateTestData(50_000); // Would normally use MemoryStream - using var sourceStream = new MemoryStream(sourceData); - var customOptions = new TemporaryStreamServiceOptions { FileThresholdInBytes = 30_000 // Force FileStream usage @@ -111,19 +103,20 @@ public static async Task CopyToTemporaryStreamAsync_ShouldUseCustomOptions_WhenP ); // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeTrue(); // Should use file due to custom threshold - temporaryStream.Length.Should().Be(sourceData.Length); + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: true, + cancellationToken + ); } [Fact] public static async Task CopyToTemporaryStreamAsync_ShouldForwardFilePath() { // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(100_000); var cancellationToken = TestContext.Current.CancellationToken; - var service = CreateDefaultService(); - var sourceData = CreateTestData(100_000); - using var sourceStream = new MemoryStream(sourceData); // Act await using var temporaryStream = await service.CopyToTemporaryStreamAsync( @@ -133,13 +126,12 @@ public static async Task CopyToTemporaryStreamAsync_ShouldForwardFilePath() ); // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeTrue(); - temporaryStream.Length.Should().Be(sourceData.Length); - temporaryStream.Position = 0; - var copiedData = new byte[sourceData.Length]; - await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); - copiedData.Should().Equal(sourceData); + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: true, + cancellationToken + ); } [Theory] @@ -150,12 +142,12 @@ HashConversionMethod hashConversionMethod ) { // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(40_000); // Less than 80KB threshold var cancellationToken = TestContext.Current.CancellationToken; - var service = CreateDefaultService(); - var sourceData = CreateTestData(40_000); // Less than 80KB threshold - using var sourceStream = new MemoryStream(sourceData); - await using var hashingPlugin = - new HashingPlugin([new CopyToHashCalculator(SHA1.Create(), hashConversionMethod)]); + var (hashingPlugin, expectedHash) = CreateHashingPluginWithExpectedHash( + sourceData, + new CopyToHashCalculator(SHA1.Create(), hashConversionMethod) + ); // Act await using var temporaryStream = await service.CopyToTemporaryStreamAsync( @@ -165,14 +157,12 @@ HashConversionMethod hashConversionMethod ); // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeFalse(); - temporaryStream.Length.Should().Be(sourceData.Length); - temporaryStream.Position = 0; - var expectedHash = HashConverter.ConvertHashToString(SHA1.HashData(sourceData), hashConversionMethod); - var copiedData = new byte[sourceData.Length]; - await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); - copiedData.Should().Equal(sourceData); + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: false, + cancellationToken + ); hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedHash); } @@ -184,12 +174,12 @@ HashConversionMethod hashConversionMethod ) { // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(100_000); // More than 80KB threshold var cancellationToken = TestContext.Current.CancellationToken; - var service = CreateDefaultService(); - var sourceData = CreateTestData(100_000); // More than 80KB threshold - using var sourceStream = new MemoryStream(sourceData); - await using var hashingPlugin = - new HashingPlugin([new CopyToHashCalculator(MD5.Create(), hashConversionMethod)]); + var (hashingPlugin, expectedHash) = CreateHashingPluginWithExpectedHash( + sourceData, + new CopyToHashCalculator(MD5.Create(), hashConversionMethod) + ); // Act await using var temporaryStream = await service.CopyToTemporaryStreamAsync( @@ -199,14 +189,12 @@ HashConversionMethod hashConversionMethod ); // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeTrue(); - temporaryStream.Length.Should().Be(sourceData.Length); - temporaryStream.Position = 0; - var copiedData = new byte[sourceData.Length]; - await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); - copiedData.Should().Equal(sourceData); - var expectedHash = HashConverter.ConvertHashToString(MD5.HashData(sourceData), hashConversionMethod); + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: true, + cancellationToken + ); hashingPlugin.GetHash(nameof(MD5)).Should().Be(expectedHash); } @@ -214,28 +202,12 @@ HashConversionMethod hashConversionMethod public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldSupportMultipleHashAlgorithms() { // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(50_000); var cancellationToken = TestContext.Current.CancellationToken; - var service = CreateDefaultService(); - var sourceData = CreateTestData(50_000); - using var sourceStream = new MemoryStream(sourceData); - - await using var hashingPlugin = new HashingPlugin( - [ - new CopyToHashCalculator(SHA1.Create(), HashConversionMethod.UpperHexadecimal), - new CopyToHashCalculator(SHA256.Create(), HashConversionMethod.Base64) - ] - ); - // Act - await using var temporaryStream = await service.CopyToTemporaryStreamAsync( - sourceStream, - [hashingPlugin], - cancellationToken: cancellationToken - ); + var sha1Calculator = new CopyToHashCalculator(SHA1.Create(), HashConversionMethod.UpperHexadecimal); + var sha256Calculator = new CopyToHashCalculator(SHA256.Create(), HashConversionMethod.Base64); - // Assert - temporaryStream.Should().NotBeNull(); - temporaryStream.Length.Should().Be(sourceData.Length); var expectedSha1Hash = HashConverter.ConvertHashToString( SHA1.HashData(sourceData), HashConversionMethod.UpperHexadecimal @@ -244,6 +216,17 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldSupp SHA256.HashData(sourceData), HashConversionMethod.Base64 ); + + await using var hashingPlugin = new HashingPlugin([sha1Calculator, sha256Calculator]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashingPlugin], + cancellationToken: cancellationToken + ); + + // Assert hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedSha1Hash); hashingPlugin.GetHash(nameof(SHA256)).Should().Be(expectedSha256Hash); } @@ -252,11 +235,12 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldSupp public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldUseCustomBufferSize() { // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(60_000); var cancellationToken = TestContext.Current.CancellationToken; - var service = CreateDefaultService(); - var sourceData = CreateTestData(60_000); - using var sourceStream = new MemoryStream(sourceData); - await using var hashingPlugin = new HashingPlugin([SHA1.Create()]); + var (hashingPlugin, expectedHash) = CreateHashingPluginWithExpectedHash( + sourceData, + new CopyToHashCalculator(SHA1.Create(), HashConversionMethod.Base64) + ); // Act await using var temporaryStream = await service.CopyToTemporaryStreamAsync( @@ -269,7 +253,6 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldUseC // Assert temporaryStream.Should().NotBeNull(); temporaryStream.Length.Should().Be(sourceData.Length); - var expectedHash = HashConverter.ConvertHashToString(SHA1.HashData(sourceData), HashConversionMethod.Base64); hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedHash); } @@ -277,11 +260,12 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldUseC public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldForwardFilePath() { // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(100_000); var cancellationToken = TestContext.Current.CancellationToken; - var service = CreateDefaultService(); - var sourceData = CreateTestData(100_000); - using var sourceStream = new MemoryStream(sourceData); - await using var hashingPlugin = new HashingPlugin([SHA1.Create()]); + var (hashingPlugin, expectedHash) = CreateHashingPluginWithExpectedHash( + sourceData, + new CopyToHashCalculator(SHA1.Create(), HashConversionMethod.Base64) + ); var filePath = Path.GetFullPath("test.txt"); // Act @@ -297,10 +281,52 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldForw temporaryStream.IsFileBased.Should().BeTrue(); temporaryStream.Length.Should().Be(sourceData.Length); temporaryStream.GetUnderlyingFilePath().Should().Be(filePath); - var expectedHash = HashConverter.ConvertHashToString(SHA1.HashData(sourceData), HashConversionMethod.Base64); hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedHash); } + private static (TemporaryStreamService service, byte[] sourceData, MemoryStream sourceStream) CreateTestSetup( + int dataSize + ) + { + var service = CreateDefaultService(); + var sourceData = CreateTestData(dataSize); + var sourceStream = new MemoryStream(sourceData); + + return (service, sourceData, sourceStream); + } + + private static async Task AssertTemporaryStreamContentsMatchAsync( + TemporaryStream temporaryStream, + byte[] expectedData, + bool expectFileBased, + CancellationToken cancellationToken + ) + { + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().Be(expectFileBased); + temporaryStream.Length.Should().Be(expectedData.Length); + + temporaryStream.Position = 0; + var copiedData = new byte[expectedData.Length]; + await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); + copiedData.Should().Equal(expectedData); + } + + private static (HashingPlugin plugin, string expectedHash) CreateHashingPluginWithExpectedHash( + byte[] sourceData, + CopyToHashCalculator hashCalculator + ) + { + var plugin = new HashingPlugin([hashCalculator]); + + var expectedHash = HashConverter.ConvertHashToString( + hashCalculator.HashAlgorithm.ComputeHash(sourceData), + hashCalculator.ConversionMethod + ); + + return (plugin, expectedHash); + } + private static TemporaryStreamService CreateDefaultService() => new ( new TemporaryStreamServiceOptions(), From 1f347be30f7dc7340873547036e5fbdbfa197428 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 5 Jul 2025 23:04:08 +0200 Subject: [PATCH 07/14] ci: move SDK version up to 8.0.411 Signed-off-by: Kenny Pflug --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index 7ab46d1..689dbbd 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.117", + "version": "8.0.411", "rollForward": "disable" } } From 20816b459667c048df205fe7f0c3bd333525cde7 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 5 Jul 2025 23:28:07 +0200 Subject: [PATCH 08/14] test: add tests for HashConverter Signed-off-by: Kenny Pflug --- .../Hashing/HashConverter.cs | 2 +- .../Hashing/HashConverterTests.cs | 72 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tests/Light.TemporaryStreams.Core.Tests/Hashing/HashConverterTests.cs diff --git a/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs b/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs index 1631b5d..b2d665e 100644 --- a/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs +++ b/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs @@ -25,7 +25,7 @@ public static string ConvertHashToString(byte[] hashArray, HashConversionMethod HashConversionMethod.None => "", _ => throw new ArgumentOutOfRangeException( nameof(conversionMethod), - $"{nameof(conversionMethod)} has an invalid value" + $"{nameof(conversionMethod)} has an invalid value '{conversionMethod}'" ) }; } diff --git a/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashConverterTests.cs b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashConverterTests.cs new file mode 100644 index 0000000..804dc94 --- /dev/null +++ b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashConverterTests.cs @@ -0,0 +1,72 @@ +using System; +using FluentAssertions; +using Xunit; + +namespace Light.TemporaryStreams.Hashing; + +public static class HashConverterTests +{ + [Fact] + public static void ConvertHashToString_Base64() + { + byte[] hash = [0x01, 0x02, 0x03, 0x04, 0x05]; + + var result = HashConverter.ConvertHashToString(hash, HashConversionMethod.Base64); + + result.Should().Be("AQIDBAU="); + } + + [Fact] + public static void ConvertHashToString_UpperHexadecimal() + { + byte[] hash = [0x01, 0x02, 0x03, 0x04, 0x05]; + + var result = HashConverter.ConvertHashToString(hash, HashConversionMethod.UpperHexadecimal); + + result.Should().Be("0102030405"); + } + + [Fact] + public static void ConvertHashToString_None_ReturnsEmptyString() + { + byte[] hash = [0x01, 0x02, 0x03, 0x04, 0x05]; + + var result = HashConverter.ConvertHashToString(hash, HashConversionMethod.None); + + result.Should().BeEmpty(); + } + + [Fact] + public static void ConvertHashToString_InvalidMethod_ThrowsArgumentOutOfRangeException() + { + byte[] hash = [0x01, 0x02, 0x03, 0x04, 0x05]; + const HashConversionMethod method = (HashConversionMethod) 999; + + Action act = () => HashConverter.ConvertHashToString(hash, method); + + act.Should() + .Throw() + .Where( + x => x.ParamName == "conversionMethod" && + x.Message == $"conversionMethod has an invalid value '{method}' (Parameter 'conversionMethod')" + ); + } + + [Fact] + public static void ConvertHashToString_EmptyArray_ReturnsCorrectResult() + { + var emptyHash = Array.Empty(); + + HashConverter.ConvertHashToString(emptyHash, HashConversionMethod.Base64).Should().BeEmpty(); + HashConverter.ConvertHashToString(emptyHash, HashConversionMethod.UpperHexadecimal).Should().BeEmpty(); + HashConverter.ConvertHashToString(emptyHash, HashConversionMethod.None).Should().BeEmpty(); + } + + [Fact] + public static void ConvertHashToString_NullArray_ThrowsArgumentNullException() + { + Action act = () => HashConverter.ConvertHashToString(null!, HashConversionMethod.Base64); + + act.Should().Throw(); + } +} From 95c3ac3fb31d3b03c8f642f980e90f4c325ea642 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 5 Jul 2025 23:35:21 +0200 Subject: [PATCH 09/14] test: add tests for HashingPlugin Signed-off-by: Kenny Pflug --- .../Hashing/HashingPluginTests.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs diff --git a/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs new file mode 100644 index 0000000..794284a --- /dev/null +++ b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Threading.Tasks; +using FluentAssertions; +using Light.GuardClauses.Exceptions; +using Xunit; + +namespace Light.TemporaryStreams.Hashing; + +public static class HashingPluginTests +{ + [Fact] + public static void Constructor_ThrowsEmptyCollectionException_WhenImmutableArrayIsEmpty() + { + var hashCalculators = ImmutableArray.Empty; + + var act = () => new HashingPlugin(hashCalculators); + + act.Should().Throw().Which.ParamName.Should().Be("hashCalculators"); + } + + [Fact] + public static void Constructor_ThrowsEmptyCollectionException_WhenImmutableArrayIsDefault() + { + var hashCalculators = default(ImmutableArray); + + var act = () => new HashingPlugin(hashCalculators); + + act.Should().Throw().Which.ParamName.Should().Be("hashCalculators"); + } + + [Fact] + public static async Task DisposeAsync_DoesNotDisposeCalculators_WhenDisposeCalculatorsIsFalse() + { + using var sha1 = SHA1.Create(); + await using var hashingPlugin = new HashingPlugin([sha1], disposeCalculators: false); + } +} From f0938810f625ac22e17fbf4a986b3c843d48f376 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 6 Jul 2025 00:09:32 +0200 Subject: [PATCH 10/14] test: add additional tests for CopyToTemporaryStream Signed-off-by: Kenny Pflug --- .../Hashing/HashingPlugin.cs | 1 + .../CopyToTemporaryStreamTests.cs | 123 ++++++++++++------ 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs b/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs index fe2009b..6e4257f 100644 --- a/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs +++ b/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs @@ -74,6 +74,7 @@ public async ValueTask DisposeAsync() /// The inner stream to be wrapped by the hash calculators. /// The optional token to cancel the asynchronous operation. /// The outermost CryptoStream. + /// Thrown when is null. public ValueTask SetUpAsync(Stream innerStream, CancellationToken cancellationToken = default) { innerStream.MustNotBeNull(); diff --git a/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs index e263797..6d775e0 100644 --- a/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs +++ b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Security.Cryptography; @@ -144,15 +145,13 @@ HashConversionMethod hashConversionMethod // Arrange var (service, sourceData, sourceStream) = CreateTestSetup(40_000); // Less than 80KB threshold var cancellationToken = TestContext.Current.CancellationToken; - var (hashingPlugin, expectedHash) = CreateHashingPluginWithExpectedHash( - sourceData, - new CopyToHashCalculator(SHA1.Create(), hashConversionMethod) - ); + await using var hashingPlugin = + new HashingPlugin([new CopyToHashCalculator(SHA1.Create(), hashConversionMethod)]); // Act await using var temporaryStream = await service.CopyToTemporaryStreamAsync( sourceStream, - ImmutableArray.Create(hashingPlugin), + [hashingPlugin], cancellationToken: cancellationToken ); @@ -163,7 +162,7 @@ await AssertTemporaryStreamContentsMatchAsync( expectFileBased: false, cancellationToken ); - hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedHash); + hashingPlugin.GetHashArray(nameof(SHA1)).Should().Equal(SHA1.HashData(sourceData)); } [Theory] @@ -176,10 +175,8 @@ HashConversionMethod hashConversionMethod // Arrange var (service, sourceData, sourceStream) = CreateTestSetup(100_000); // More than 80KB threshold var cancellationToken = TestContext.Current.CancellationToken; - var (hashingPlugin, expectedHash) = CreateHashingPluginWithExpectedHash( - sourceData, - new CopyToHashCalculator(MD5.Create(), hashConversionMethod) - ); + await using var hashingPlugin = + new HashingPlugin([new CopyToHashCalculator(MD5.Create(), hashConversionMethod)]); // Act await using var temporaryStream = await service.CopyToTemporaryStreamAsync( @@ -195,7 +192,9 @@ await AssertTemporaryStreamContentsMatchAsync( expectFileBased: true, cancellationToken ); - hashingPlugin.GetHash(nameof(MD5)).Should().Be(expectedHash); + hashingPlugin.GetHash(nameof(MD5)).Should().Be( + HashConverter.ConvertHashToString(MD5.HashData(sourceData), hashConversionMethod) + ); } [Fact] @@ -237,15 +236,13 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldUseC // Arrange var (service, sourceData, sourceStream) = CreateTestSetup(60_000); var cancellationToken = TestContext.Current.CancellationToken; - var (hashingPlugin, expectedHash) = CreateHashingPluginWithExpectedHash( - sourceData, - new CopyToHashCalculator(SHA1.Create(), HashConversionMethod.Base64) - ); + await using var hashingPlugin = new HashingPlugin([SHA1.Create()]); + // Act await using var temporaryStream = await service.CopyToTemporaryStreamAsync( sourceStream, - ImmutableArray.Create(hashingPlugin), + [hashingPlugin], copyBufferSize: 4096, cancellationToken: cancellationToken ); @@ -253,7 +250,7 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldUseC // Assert temporaryStream.Should().NotBeNull(); temporaryStream.Length.Should().Be(sourceData.Length); - hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedHash); + hashingPlugin.GetHash(nameof(SHA1)).Should().Be(Convert.ToBase64String(SHA1.HashData(sourceData))); } [Fact] @@ -262,10 +259,7 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldForw // Arrange var (service, sourceData, sourceStream) = CreateTestSetup(100_000); var cancellationToken = TestContext.Current.CancellationToken; - var (hashingPlugin, expectedHash) = CreateHashingPluginWithExpectedHash( - sourceData, - new CopyToHashCalculator(SHA1.Create(), HashConversionMethod.Base64) - ); + await using var hashingPlugin = new HashingPlugin([SHA1.Create()]); var filePath = Path.GetFullPath("test.txt"); // Act @@ -281,7 +275,77 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldForw temporaryStream.IsFileBased.Should().BeTrue(); temporaryStream.Length.Should().Be(sourceData.Length); temporaryStream.GetUnderlyingFilePath().Should().Be(filePath); - hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedHash); + hashingPlugin.GetHash(nameof(SHA1)).Should().Be(Convert.ToBase64String(SHA1.HashData(sourceData))); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldThrowWhenHashNotFound() + { + // Arrange + var (service, _, sourceStream) = CreateTestSetup(20_000); + var cancellationToken = TestContext.Current.CancellationToken; + await using var hashPlugin = new HashingPlugin([SHA1.Create()]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashPlugin], + cancellationToken: cancellationToken + ); + // ReSharper disable once AccessToDisposedClosure -- delegate called before hashPlugin is disposed of + var act = () => hashPlugin.GetHash(nameof(SHA256)); + + // Arrange + act.Should() + .Throw() + .Where(x => x.Message.StartsWith("There is no hash calculator with the name 'SHA256'")); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldThrowWhenHashArrayNotFound() + { + // Arrange + var (service, _, sourceStream) = CreateTestSetup(20_000); + var cancellationToken = TestContext.Current.CancellationToken; + await using var hashPlugin = + new HashingPlugin([new CopyToHashCalculator(SHA256.Create(), HashConversionMethod.None)]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashPlugin], + cancellationToken: cancellationToken + ); + // ReSharper disable once AccessToDisposedClosure -- delegate called before hashPlugin is disposed of + var act = () => hashPlugin.GetHashArray(nameof(SHA3_512)); + + // Arrange + act.Should() + .Throw() + .Where(x => x.Message.StartsWith("There is no hash calculator with the name 'SHA3_512'")); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldReturnHashArray() + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(20_000); + var cancellationToken = TestContext.Current.CancellationToken; + await using var hashPlugin = + new HashingPlugin([new CopyToHashCalculator(SHA256.Create(), HashConversionMethod.None)]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashPlugin], + cancellationToken: cancellationToken + ); + + + // Assert + var hashArray = hashPlugin.GetHashArray(nameof(SHA256)); + var expectedHash = SHA256.HashData(sourceData); + hashArray.Should().Equal(expectedHash); } private static (TemporaryStreamService service, byte[] sourceData, MemoryStream sourceStream) CreateTestSetup( @@ -312,21 +376,6 @@ CancellationToken cancellationToken copiedData.Should().Equal(expectedData); } - private static (HashingPlugin plugin, string expectedHash) CreateHashingPluginWithExpectedHash( - byte[] sourceData, - CopyToHashCalculator hashCalculator - ) - { - var plugin = new HashingPlugin([hashCalculator]); - - var expectedHash = HashConverter.ConvertHashToString( - hashCalculator.HashAlgorithm.ComputeHash(sourceData), - hashCalculator.ConversionMethod - ); - - return (plugin, expectedHash); - } - private static TemporaryStreamService CreateDefaultService() => new ( new TemporaryStreamServiceOptions(), From ccfc727dd905674278d9226d9264e283c5bbc8ef Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 6 Jul 2025 00:15:24 +0200 Subject: [PATCH 11/14] test: add additional test for HashingPlugin.AfterCopyAsync Signed-off-by: Kenny Pflug --- .../Hashing/HashingPluginTests.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs index 794284a..b7d620c 100644 --- a/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs +++ b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Immutable; +using System; +using System.Collections.Immutable; using System.Security.Cryptography; using System.Threading.Tasks; using FluentAssertions; @@ -35,4 +36,16 @@ public static async Task DisposeAsync_DoesNotDisposeCalculators_WhenDisposeCalcu using var sha1 = SHA1.Create(); await using var hashingPlugin = new HashingPlugin([sha1], disposeCalculators: false); } + + [Fact] + public static async Task AfterCopyAsync_ShouldThrow_WhenSetUpAsyncWasNotCalledBeforehand() + { + await using var hashingPlugin = new HashingPlugin([SHA1.Create()]); + + // ReSharper disable once AccessToDisposedClosure -- delegate called before hashingPlugin is disposed of + var act = () => hashingPlugin.AfterCopyAsync().AsTask(); + + (await act.Should().ThrowAsync()) + .Which.Message.Should().Be("SetUpAsync must be called before AfterCopyAsync"); + } } From 5ce5122887680461c4605a375c91a096a3afd2ab Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 6 Jul 2025 00:27:10 +0200 Subject: [PATCH 12/14] test: add exception tests for CopyToHashCalculator Signed-off-by: Kenny Pflug --- .../Hashing/CopyToHashCalculator.cs | 6 +-- .../Hashing/CopyToHashCalculatorTests.cs | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 tests/Light.TemporaryStreams.Core.Tests/Hashing/CopyToHashCalculatorTests.cs diff --git a/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs b/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs index 2f5e55e..c4a03d7 100644 --- a/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs +++ b/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs @@ -58,7 +58,7 @@ public string Hash if (hash is null) { throw new InvalidOperationException( - $"ObtainHashFromAlgorithm must be called before accessing the {nameof(Hash)} property." + $"ObtainHashFromAlgorithm must be called before accessing the {nameof(Hash)} property" ); } @@ -80,7 +80,7 @@ public byte[] HashArray if (hashArray is null) { throw new InvalidOperationException( - $"ObtainHashFromAlgorithm must be called before accessing the {nameof(HashArray)} property." + $"ObtainHashFromAlgorithm must be called before accessing the {nameof(HashArray)} property" ); } @@ -158,7 +158,7 @@ public void ObtainHashFromAlgorithm() .Hash .MustNotBeNull( () => new InvalidOperationException( - "The crypto stream was not written to - no hash was calculated." + "The crypto stream was not written to - no hash was calculated" ) ); diff --git a/tests/Light.TemporaryStreams.Core.Tests/Hashing/CopyToHashCalculatorTests.cs b/tests/Light.TemporaryStreams.Core.Tests/Hashing/CopyToHashCalculatorTests.cs new file mode 100644 index 0000000..9593db8 --- /dev/null +++ b/tests/Light.TemporaryStreams.Core.Tests/Hashing/CopyToHashCalculatorTests.cs @@ -0,0 +1,46 @@ +using System; +using System.Security.Cryptography; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace Light.TemporaryStreams.Hashing; + +public static class CopyToHashCalculatorTests +{ + [Fact] + public static async Task Hash_ShouldThrow_WhenObtainHashFromAlgorithmWasNotCalledBeforehand() + { + await using CopyToHashCalculator calculator = SHA1.Create(); + + // ReSharper disable once AccessToDisposedClosure -- delegate called before calculator is disposed of + var act = () => calculator.Hash; + + act.Should().Throw() + .Which.Message.Should().Be("ObtainHashFromAlgorithm must be called before accessing the Hash property"); + } + + [Fact] + public static async Task HashArray_ShouldThrow_WhenObtainHashFromAlgorithmWasNotCalledBeforehand() + { + await using CopyToHashCalculator calculator = SHA256.Create(); + + // ReSharper disable once AccessToDisposedClosure -- delegate called before calculator is disposed of + var act = () => calculator.HashArray; + + act.Should().Throw() + .Which.Message.Should().Be("ObtainHashFromAlgorithm must be called before accessing the HashArray property"); + } + + [Fact] + public static async Task ObtainHashFromAlgorithm_ShouldThrow_WhenNothingWasWrittenToUnderlyingCryptoStream() + { + await using CopyToHashCalculator calculator = SHA3_512.Create(); + + // ReSharper disable once AccessToDisposedClosure -- delegate called before calculator is disposed of + var act = () => calculator.ObtainHashFromAlgorithm(); + + act.Should().Throw() + .Which.Message.Should().Be("The crypto stream was not written to - no hash was calculated"); + } +} From 249860440496cc94e07c6cbd3230aa5205b9ac4c Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 6 Jul 2025 01:11:12 +0200 Subject: [PATCH 13/14] fix: TemporaryStreamServiceExtensions now operate on the ITemporaryStreamService interface Signed-off-by: Kenny Pflug --- .../TemporaryStreamServiceExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs b/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs index 68bee34..e600e79 100644 --- a/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs +++ b/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs @@ -38,7 +38,7 @@ public static class TemporaryStreamServiceExtensions /// Thrown when or are null. /// Thrown when has a value that is less than 0. public static async Task CopyToTemporaryStreamAsync( - this TemporaryStreamService temporaryStreamService, + this ITemporaryStreamService temporaryStreamService, Stream source, string? filePath = null, TemporaryStreamServiceOptions? options = null, @@ -100,7 +100,7 @@ await source /// Thrown when has a value that is less than 0. /// Thrown when is empty or the default instance. public static async Task CopyToTemporaryStreamAsync( - this TemporaryStreamService temporaryStreamService, + this ITemporaryStreamService temporaryStreamService, Stream source, ImmutableArray plugins, string? filePath = null, From 814ecb0a48ca3485d1d7322eb67a69d11e883615 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 6 Jul 2025 01:13:53 +0200 Subject: [PATCH 14/14] test: add additional tests for TemporaryStreamService Signed-off-by: Kenny Pflug --- .../CopyToTemporaryStreamTests.cs | 141 +++++++++++++++++- 1 file changed, 137 insertions(+), 4 deletions(-) diff --git a/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs index 6d775e0..65da05d 100644 --- a/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs +++ b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs @@ -6,8 +6,10 @@ using System.Threading; using System.Threading.Tasks; using FluentAssertions; +using Light.GuardClauses.Exceptions; using Light.TemporaryStreams.Hashing; using Xunit; +using XunitException = Xunit.Sdk.XunitException; namespace Light.TemporaryStreams; @@ -61,6 +63,29 @@ await AssertTemporaryStreamContentsMatchAsync( ); } + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldForwardBufferSize() + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(40_000); + var cancellationToken = TestContext.Current.CancellationToken; + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + copyBufferSize: 24 * 1024, + cancellationToken: cancellationToken + ); + + // Assert + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: false, + cancellationToken + ); + } + [Fact] public static async Task CopyToTemporaryStreamAsync_ShouldUseCopyBufferSize_WhenSpecified() { @@ -135,6 +160,23 @@ await AssertTemporaryStreamContentsMatchAsync( ); } + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldDisposeTemporaryStream_WhenAnExceptionIsThrown() + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(100_000, useErroneousSourceStream: true); + var cancellationToken = TestContext.Current.CancellationToken; + var serviceSpy = new TemporaryStreamServiceSpy(service); + + // Act + var act = () => serviceSpy.CopyToTemporaryStreamAsync(sourceStream, cancellationToken: cancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .Which.Message.Should().Be("Intentional exception to test error handling"); + serviceSpy.CapturedTemporaryStream.IsDisposed.Should().BeTrue(); + } + [Theory] [InlineData(HashConversionMethod.Base64)] [InlineData(HashConversionMethod.UpperHexadecimal)] @@ -238,7 +280,6 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldUseC var cancellationToken = TestContext.Current.CancellationToken; await using var hashingPlugin = new HashingPlugin([SHA1.Create()]); - // Act await using var temporaryStream = await service.CopyToTemporaryStreamAsync( sourceStream, @@ -278,6 +319,34 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldForw hashingPlugin.GetHash(nameof(SHA1)).Should().Be(Convert.ToBase64String(SHA1.HashData(sourceData))); } + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldThrow_WhenPluginArrayIsEmpty() + { + var (service, _, sourceStream) = CreateTestSetup(10); + + var act = () => service.CopyToTemporaryStreamAsync( + sourceStream, + ImmutableArray.Empty + ); + + (await act.Should().ThrowAsync()) + .Which.ParamName.Should().Be("plugins"); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldThrow_WhenPluginArrayIsDefaultInstance() + { + var (service, _, sourceStream) = CreateTestSetup(10); + + var act = () => service.CopyToTemporaryStreamAsync( + sourceStream, + plugins: default + ); + + (await act.Should().ThrowAsync()) + .Which.ParamName.Should().Be("plugins"); + } + [Fact] public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldThrowWhenHashNotFound() { @@ -341,20 +410,41 @@ public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldRetu cancellationToken: cancellationToken ); - // Assert var hashArray = hashPlugin.GetHashArray(nameof(SHA256)); var expectedHash = SHA256.HashData(sourceData); hashArray.Should().Equal(expectedHash); } + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldDisposeTemporaryStreamDuringException() + { + // Arrange + var (service, _, sourceStream) = CreateTestSetup(20_000); + var serviceSpy = new TemporaryStreamServiceSpy(service); + var cancellationToken = TestContext.Current.CancellationToken; + + // Act + var act = () => serviceSpy.CopyToTemporaryStreamAsync( + sourceStream, + [new ErroneousPlugin()], + cancellationToken: cancellationToken + ); + + // Assert + (await act.Should().ThrowAsync()) + .Which.Message.Should().Be("Intentional exception to test error handling"); + serviceSpy.CapturedTemporaryStream.IsDisposed.Should().BeTrue(); + } + private static (TemporaryStreamService service, byte[] sourceData, MemoryStream sourceStream) CreateTestSetup( - int dataSize + int dataSize, + bool useErroneousSourceStream = false ) { var service = CreateDefaultService(); var sourceData = CreateTestData(dataSize); - var sourceStream = new MemoryStream(sourceData); + var sourceStream = useErroneousSourceStream ? new ErroneousMemoryStream() : new MemoryStream(sourceData); return (service, sourceData, sourceStream); } @@ -391,4 +481,47 @@ private static byte[] CreateTestData(int size) random.NextBytes(data); return data; } + + private sealed class ErroneousPlugin : ICopyToTemporaryStreamPlugin + { + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + public ValueTask SetUpAsync(Stream innerStream, CancellationToken cancellationToken = default) => + new (innerStream); + + public ValueTask AfterCopyAsync(CancellationToken cancellationToken = default) + { + throw new XunitException("Intentional exception to test error handling"); + } + } + + private sealed class TemporaryStreamServiceSpy : ITemporaryStreamService + { + private readonly ITemporaryStreamService _wrappedService; + private TemporaryStream? _capturedTemporaryStream; + + public TemporaryStreamServiceSpy(ITemporaryStreamService wrappedService) => _wrappedService = wrappedService; + + public TemporaryStream CapturedTemporaryStream => + _capturedTemporaryStream ?? throw new InvalidOperationException("No temporary stream captured"); + + public TemporaryStream CreateTemporaryStream( + long expectedLengthInBytes, + string? filePath = null, + TemporaryStreamServiceOptions? options = null + ) + { + var temporaryStream = _wrappedService.CreateTemporaryStream(expectedLengthInBytes, filePath, options); + _capturedTemporaryStream = temporaryStream; + return temporaryStream; + } + } + + private sealed class ErroneousMemoryStream : MemoryStream + { + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + throw new XunitException("Intentional exception to test error handling"); + } + } }