diff --git a/src/SyncTrayzor.Tests/ChecksumFileUtilitiesTests.cs b/src/SyncTrayzor.Tests/ChecksumFileUtilitiesTests.cs new file mode 100644 index 00000000..3fc40b59 --- /dev/null +++ b/src/SyncTrayzor.Tests/ChecksumFileUtilitiesTests.cs @@ -0,0 +1,133 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using SyncTrayzor.Utils; +using Xunit; + +namespace SyncTrayzor.Tests +{ + public class ChecksumFileUtilitiesTests + { + private static MemoryStream ToStream(string text) => + new(Encoding.UTF8.GetBytes(text)); + + private static MemoryStream ToStream(byte[] bytes) => new(bytes); + + // ── WriteChecksumToFile ────────────────────────────────────────────── + + [Fact] + public void WriteChecksumToFile_ProducesValidChecksumLine() + { + var content = Encoding.UTF8.GetBytes("hello world"); + using var fileStream = ToStream(content); + using var checksumStream = new MemoryStream(); + + using var sha256 = SHA256.Create(); + ChecksumFileUtilities.WriteChecksumToFile(sha256, checksumStream, "test.txt", fileStream); + + checksumStream.Position = 0; + var line = new StreamReader(checksumStream, Encoding.ASCII).ReadLine(); + + // Expected: " test.txt" + Assert.NotNull(line); + var parts = line!.Split(" ", 2); + Assert.Equal(2, parts.Length); + Assert.Equal("test.txt", parts[1].Trim()); + Assert.Equal(64, parts[0].Length); // SHA-256 hex is 64 chars + } + + // ── ValidateChecksum – positive case ──────────────────────────────── + + [Fact] + public void ValidateChecksum_ReturnsTrueForMatchingContent() + { + var content = Encoding.UTF8.GetBytes("hello world"); + var checksumLine = ComputeChecksumLine(content, "test.txt"); + + using var checksumStream = ToStream(checksumLine); + using var fileStream = ToStream(content); + + using var sha256 = SHA256.Create(); + Assert.True(ChecksumFileUtilities.ValidateChecksum(sha256, checksumStream, "test.txt", fileStream)); + } + + // ── ValidateChecksum – negative case ──────────────────────────────── + + [Fact] + public void ValidateChecksum_ReturnsFalseForModifiedContent() + { + var originalContent = Encoding.UTF8.GetBytes("hello world"); + var modifiedContent = Encoding.UTF8.GetBytes("hello WORLD"); + var checksumLine = ComputeChecksumLine(originalContent, "test.txt"); + + using var checksumStream = ToStream(checksumLine); + using var fileStream = ToStream(modifiedContent); + + using var sha256 = SHA256.Create(); + Assert.False(ChecksumFileUtilities.ValidateChecksum(sha256, checksumStream, "test.txt", fileStream)); + } + + // ── ValidateChecksum – multi-entry checksum file ───────────────────── + + [Fact] + public void ValidateChecksum_FindsCorrectEntryInMultiLineFile() + { + var content = Encoding.UTF8.GetBytes("data"); + var otherContent = Encoding.UTF8.GetBytes("other"); + + var lines = ComputeChecksumLine(otherContent, "other.txt") + + ComputeChecksumLine(content, "data.txt"); + + using var checksumStream = ToStream(lines); + using var fileStream = ToStream(content); + + using var sha256 = SHA256.Create(); + Assert.True(ChecksumFileUtilities.ValidateChecksum(sha256, checksumStream, "data.txt", fileStream)); + } + + // ── ValidateChecksum – missing filename ────────────────────────────── + + [Fact] + public void ValidateChecksum_ThrowsWhenFilenameNotFound() + { + var content = Encoding.UTF8.GetBytes("hello"); + var checksumLine = ComputeChecksumLine(content, "other.txt"); + + using var checksumStream = ToStream(checksumLine); + using var fileStream = ToStream(content); + + using var sha256 = SHA256.Create(); + Assert.Throws(() => + ChecksumFileUtilities.ValidateChecksum(sha256, checksumStream, "missing.txt", fileStream)); + } + + // ── Round-trip: WriteChecksumToFile then ValidateChecksum ──────────── + + [Fact] + public void RoundTrip_WriteAndValidate() + { + var content = Encoding.UTF8.GetBytes("round-trip test content"); + using var fileStreamWrite = ToStream(content); + using var checksumStream = new MemoryStream(); + + using var sha256Write = SHA256.Create(); + ChecksumFileUtilities.WriteChecksumToFile(sha256Write, checksumStream, "file.bin", fileStreamWrite); + + checksumStream.Position = 0; + using var fileStreamValidate = ToStream(content); + using var sha256Validate = SHA256.Create(); + Assert.True(ChecksumFileUtilities.ValidateChecksum(sha256Validate, checksumStream, "file.bin", fileStreamValidate)); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private static string ComputeChecksumLine(byte[] content, string filename) + { + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(content); + var hex = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + return $"{hex} {filename}\n"; + } + } +} diff --git a/src/SyncTrayzor.Tests/FormatUtilsTests.cs b/src/SyncTrayzor.Tests/FormatUtilsTests.cs new file mode 100644 index 00000000..6502bc9a --- /dev/null +++ b/src/SyncTrayzor.Tests/FormatUtilsTests.cs @@ -0,0 +1,48 @@ +using SyncTrayzor.Utils; +using Xunit; + +namespace SyncTrayzor.Tests +{ + public class FormatUtilsTests + { + // ── BytesToHuman ──────────────────────────────────────────────────── + + [Theory] + [InlineData(0, 0, "0B")] + [InlineData(1, 0, "1B")] + [InlineData(1023, 0, "1023B")] + [InlineData(1024, 0, "1KiB")] + [InlineData(1536, 0, "2KiB")] // 1.5 KiB rounds to 2 with 0 decimal places + [InlineData(1048576, 0, "1MiB")] // 1 MiB + [InlineData(1073741824, 0, "1GiB")] // 1 GiB + public void BytesToHuman_DefaultDecimalPlaces(double bytes, int decimalPlaces, string expected) + { + Assert.Equal(expected, FormatUtils.BytesToHuman(bytes, decimalPlaces)); + } + + [Theory] + [InlineData(1536, 1, "1.5KiB")] // exactly 1.5 KiB + [InlineData(1024, 2, "1.00KiB")] + [InlineData(1048576, 1, "1.0MiB")] + public void BytesToHuman_WithDecimalPlaces(double bytes, int decimalPlaces, string expected) + { + Assert.Equal(expected, FormatUtils.BytesToHuman(bytes, decimalPlaces)); + } + + [Fact] + public void BytesToHuman_ByteRangeNeverShowsDecimalPlaces() + { + // Bytes (order == 0) should never have decimal places regardless of the argument + var result = FormatUtils.BytesToHuman(512, decimalPlaces: 3); + Assert.Equal("512B", result); + } + + [Fact] + public void BytesToHuman_CapsAtGiB() + { + // Even a very large value should be expressed in GiB, not some higher unit + var result = FormatUtils.BytesToHuman(1024.0 * 1024 * 1024 * 10, decimalPlaces: 0); + Assert.EndsWith("GiB", result); + } + } +} diff --git a/src/SyncTrayzor.Tests/StringExtensionsTests.cs b/src/SyncTrayzor.Tests/StringExtensionsTests.cs new file mode 100644 index 00000000..bb158d39 --- /dev/null +++ b/src/SyncTrayzor.Tests/StringExtensionsTests.cs @@ -0,0 +1,144 @@ +using System.Linq; +using SyncTrayzor.Utils; +using Xunit; + +namespace SyncTrayzor.Tests +{ + public class StringExtensionsTests + { + // ── TrimStart(string prefix) ──────────────────────────────────────── + + [Fact] + public void TrimStart_RemovesPrefixWhenPresent() + { + Assert.Equal("world", "helloworld".TrimStart("hello")); + } + + [Fact] + public void TrimStart_ReturnsOriginalWhenPrefixAbsent() + { + Assert.Equal("world", "world".TrimStart("hello")); + } + + [Fact] + public void TrimStart_EmptyPrefixReturnsOriginal() + { + Assert.Equal("hello", "hello".TrimStart("")); + } + + [Fact] + public void TrimStart_OnlyRemovesLeadingOccurrence() + { + // "abab" starts with "ab", so the result should be "ab" (second occurrence) + Assert.Equal("ab", "abab".TrimStart("ab")); + } + + // ── TrimMatchingQuotes(char quote) ───────────────────────────────── + + [Fact] + public void TrimMatchingQuotes_RemovesWrappingDoubleQuotes() + { + Assert.Equal("hello world", "\"hello world\"".TrimMatchingQuotes('"')); + } + + [Fact] + public void TrimMatchingQuotes_ReturnsOriginalWhenNotWrapped() + { + Assert.Equal("hello", "hello".TrimMatchingQuotes('"')); + } + + [Fact] + public void TrimMatchingQuotes_ReturnsOriginalWhenOnlyLeadingQuote() + { + Assert.Equal("\"hello", "\"hello".TrimMatchingQuotes('"')); + } + + [Fact] + public void TrimMatchingQuotes_ReturnsOriginalWhenOnlyTrailingQuote() + { + Assert.Equal("hello\"", "hello\"".TrimMatchingQuotes('"')); + } + + [Fact] + public void TrimMatchingQuotes_ReturnsEmptyStringForTwoQuotes() + { + Assert.Equal("", "\"\"".TrimMatchingQuotes('"')); + } + + // ── SplitCommandLine ──────────────────────────────────────────────── + + [Fact] + public void SplitCommandLine_SplitsSimpleArgs() + { + var result = StringExtensions.SplitCommandLine("foo bar baz").ToList(); + Assert.Equal(new[] { "foo", "bar", "baz" }, result); + } + + [Fact] + public void SplitCommandLine_KeepsQuotedSpaces() + { + var result = StringExtensions.SplitCommandLine("\"hello world\" foo").ToList(); + Assert.Equal(new[] { "hello world", "foo" }, result); + } + + [Fact] + public void SplitCommandLine_EmptyStringReturnsNoElements() + { + var result = StringExtensions.SplitCommandLine("").ToList(); + Assert.Empty(result); + } + + [Fact] + public void SplitCommandLine_SingleArgReturnsOneElement() + { + var result = StringExtensions.SplitCommandLine("only").ToList(); + Assert.Equal(new[] { "only" }, result); + } + + [Fact] + public void SplitCommandLine_SkipsExtraSpaces() + { + var result = StringExtensions.SplitCommandLine("foo bar").ToList(); + // extra space creates an empty token which is filtered out + Assert.Equal(new[] { "foo", "bar" }, result); + } + + // ── JoinCommandLine ───────────────────────────────────────────────── + + [Fact] + public void JoinCommandLine_SimpleArgsNoSpaces() + { + Assert.Equal("foo bar", StringExtensions.JoinCommandLine(new[] { "foo", "bar" })); + } + + [Fact] + public void JoinCommandLine_QuotesArgWithSpace() + { + Assert.Equal("\"hello world\" foo", StringExtensions.JoinCommandLine(new[] { "hello world", "foo" })); + } + + [Fact] + public void JoinCommandLine_EscapesEmbeddedQuotes() + { + // An arg that itself contains a double-quote should be quoted and the inner quote escaped + Assert.Equal("\"say \\\"hi\\\"\"", StringExtensions.JoinCommandLine(new[] { "say \"hi\"" })); + } + + [Fact] + public void JoinCommandLine_EmptyInputReturnsEmptyString() + { + Assert.Equal("", StringExtensions.JoinCommandLine(new string[] { })); + } + + // ── Round-trip: SplitCommandLine ∘ JoinCommandLine ───────────────── + + [Fact] + public void RoundTrip_SplitThenJoin() + { + var original = new[] { "path to/file", "simple", "--flag=value" }; + var joined = StringExtensions.JoinCommandLine(original); + var split = StringExtensions.SplitCommandLine(joined).ToArray(); + Assert.Equal(original, split); + } + } +} diff --git a/src/SyncTrayzor.Tests/SyncthingCapabilitiesTests.cs b/src/SyncTrayzor.Tests/SyncthingCapabilitiesTests.cs new file mode 100644 index 00000000..25c3f93b --- /dev/null +++ b/src/SyncTrayzor.Tests/SyncthingCapabilitiesTests.cs @@ -0,0 +1,65 @@ +using System; +using SyncTrayzor.Syncthing; +using Xunit; + +namespace SyncTrayzor.Tests +{ + public class SyncthingCapabilitiesTests + { + // ── SupportsDebugFacilities ────────────────────────────────────────── + + [Fact] + public void SupportsDebugFacilities_FalseBeforeIntroducedVersion() + { + var caps = new SyncthingCapabilities { SyncthingVersion = new Version(0, 11, 99) }; + Assert.False(caps.SupportsDebugFacilities); + } + + [Fact] + public void SupportsDebugFacilities_TrueAtIntroducedVersion() + { + var caps = new SyncthingCapabilities { SyncthingVersion = new Version(0, 12, 0) }; + Assert.True(caps.SupportsDebugFacilities); + } + + [Fact] + public void SupportsDebugFacilities_TrueAfterIntroducedVersion() + { + var caps = new SyncthingCapabilities { SyncthingVersion = new Version(1, 0, 0) }; + Assert.True(caps.SupportsDebugFacilities); + } + + // ── SupportsDevicePauseResume ──────────────────────────────────────── + + [Fact] + public void SupportsDevicePauseResume_FalseBeforeIntroducedVersion() + { + var caps = new SyncthingCapabilities { SyncthingVersion = new Version(0, 11, 0) }; + Assert.False(caps.SupportsDevicePauseResume); + } + + [Fact] + public void SupportsDevicePauseResume_TrueAtIntroducedVersion() + { + var caps = new SyncthingCapabilities { SyncthingVersion = new Version(0, 12, 0) }; + Assert.True(caps.SupportsDevicePauseResume); + } + + [Fact] + public void SupportsDevicePauseResume_TrueAfterIntroducedVersion() + { + var caps = new SyncthingCapabilities { SyncthingVersion = new Version(2, 0, 0) }; + Assert.True(caps.SupportsDevicePauseResume); + } + + // ── Default version (0.0.0) ────────────────────────────────────────── + + [Fact] + public void DefaultVersion_NoCapabilitiesSupported() + { + var caps = new SyncthingCapabilities(); // default version is 0.0.0 + Assert.False(caps.SupportsDebugFacilities); + Assert.False(caps.SupportsDevicePauseResume); + } + } +} diff --git a/src/SyncTrayzor.Tests/SyncthingVersionInformationTests.cs b/src/SyncTrayzor.Tests/SyncthingVersionInformationTests.cs new file mode 100644 index 00000000..77654c56 --- /dev/null +++ b/src/SyncTrayzor.Tests/SyncthingVersionInformationTests.cs @@ -0,0 +1,55 @@ +using System; +using SyncTrayzor.Syncthing; +using Xunit; + +namespace SyncTrayzor.Tests +{ + public class SyncthingVersionInformationTests + { + [Theory] + [InlineData("v1.23.4", "1.23.4")] + [InlineData("v0.14.28", "0.14.28")] + [InlineData("v0.12.0", "0.12.0")] + public void ParsedVersion_ExtractsVersionFromShortVersionString(string shortVersion, string expectedVersion) + { + var info = new SyncthingVersionInformation(shortVersion, ""); + Assert.Equal(Version.Parse(expectedVersion), info.ParsedVersion); + } + + [Fact] + public void ParsedVersion_HandlesLongVersionString() + { + // syncthing sometimes reports "syncthing v1.2.3 (go1.21 linux-amd64) ..." + var info = new SyncthingVersionInformation("syncthing v1.2.3", "syncthing v1.2.3 (go1.21)"); + Assert.Equal(new Version(1, 2, 3), info.ParsedVersion); + } + + [Fact] + public void ParsedVersion_DefaultsToZeroWhenNoVersionFound() + { + var info = new SyncthingVersionInformation("no-version-here", ""); + Assert.Equal(new Version(0, 0, 0), info.ParsedVersion); + } + + [Fact] + public void ParsedVersion_DefaultsToZeroForEmptyString() + { + var info = new SyncthingVersionInformation("", ""); + Assert.Equal(new Version(0, 0, 0), info.ParsedVersion); + } + + [Fact] + public void ShortVersion_StoredAsProvided() + { + var info = new SyncthingVersionInformation("v1.0.0", "long version"); + Assert.Equal("v1.0.0", info.ShortVersion); + } + + [Fact] + public void LongVersion_StoredAsProvided() + { + var info = new SyncthingVersionInformation("v1.0.0", "long version"); + Assert.Equal("long version", info.LongVersion); + } + } +} diff --git a/src/SyncTrayzor.Tests/UriExtensionsTests.cs b/src/SyncTrayzor.Tests/UriExtensionsTests.cs new file mode 100644 index 00000000..1eceda21 --- /dev/null +++ b/src/SyncTrayzor.Tests/UriExtensionsTests.cs @@ -0,0 +1,49 @@ +using System; +using SyncTrayzor.Utils; +using Xunit; + +namespace SyncTrayzor.Tests +{ + public class UriExtensionsTests + { + [Fact] + public void NormalizeZeroHost_ReplacesZeroHostWith127() + { + var input = new Uri("http://0.0.0.0:8384/"); + var result = input.NormalizeZeroHost(); + Assert.Equal("127.0.0.1", result.Host); + } + + [Fact] + public void NormalizeZeroHost_PreservesPort() + { + var input = new Uri("http://0.0.0.0:8384/"); + var result = input.NormalizeZeroHost(); + Assert.Equal(8384, result.Port); + } + + [Fact] + public void NormalizeZeroHost_PreservesPath() + { + var input = new Uri("http://0.0.0.0:8384/some/path"); + var result = input.NormalizeZeroHost(); + Assert.Equal("/some/path", result.AbsolutePath); + } + + [Fact] + public void NormalizeZeroHost_LeavesNonZeroHostUnchanged() + { + var input = new Uri("http://192.168.1.1:8384/"); + var result = input.NormalizeZeroHost(); + Assert.Equal("192.168.1.1", result.Host); + } + + [Fact] + public void NormalizeZeroHost_LeavesLocalhostUnchanged() + { + var input = new Uri("http://localhost:8384/"); + var result = input.NormalizeZeroHost(); + Assert.Equal("localhost", result.Host); + } + } +}