diff --git a/tests/CoreTest/Configuration/BaseConfigInfoTests.cs b/tests/CoreTest/Configuration/BaseConfigInfoTests.cs new file mode 100644 index 00000000..201e8fb2 --- /dev/null +++ b/tests/CoreTest/Configuration/BaseConfigInfoTests.cs @@ -0,0 +1,148 @@ +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +/// +/// AAAT unit tests for — default property values. +/// Covers: all default property values defined in the abstract base class. +/// +public class BaseConfigInfoTests +{ + private class TestableConfig : BaseConfigInfo { } + + [Fact] + public void Ctor_AppName_DefaultsToUpdateExe() + { + var config = new TestableConfig(); + Assert.Equal("Update.exe", config.AppName); + } + + [Fact] + public void Ctor_MainAppName_DefaultsToNull() + { + var config = new TestableConfig(); + Assert.Null(config.MainAppName); + } + + [Fact] + public void Ctor_InstallPath_DefaultsToBaseDirectory() + { + var config = new TestableConfig(); + var expected = AppDomain.CurrentDomain.BaseDirectory; + Assert.Equal(expected, config.InstallPath); + } + + [Fact] + public void Ctor_UpdateLogUrl_DefaultsToNull() + { + var config = new TestableConfig(); + Assert.Null(config.UpdateLogUrl); + } + + [Fact] + public void Ctor_AppSecretKey_DefaultsToNull() + { + var config = new TestableConfig(); + Assert.Null(config.AppSecretKey); + } + + [Fact] + public void Ctor_ClientVersion_DefaultsToNull() + { + var config = new TestableConfig(); + Assert.Null(config.ClientVersion); + } + + [Fact] + public void Ctor_BlackFiles_DefaultsToNull() + { + var config = new TestableConfig(); + Assert.Null(config.BlackFiles); + } + + [Fact] + public void Ctor_BlackFormats_DefaultsToNull() + { + var config = new TestableConfig(); + Assert.Null(config.BlackFormats); + } + + [Fact] + public void Ctor_SkipDirectorys_DefaultsToNull() + { + var config = new TestableConfig(); + Assert.Null(config.SkipDirectorys); + } + + [Fact] + public void Ctor_ReportUrl_DefaultsToNull() + { + var config = new TestableConfig(); + Assert.Null(config.ReportUrl); + } + + [Fact] + public void Ctor_Bowl_DefaultsToNull() + { + var config = new TestableConfig(); + Assert.Null(config.Bowl); + } + + [Fact] + public void Ctor_Scheme_DefaultsToNull() + { + var config = new TestableConfig(); + Assert.Null(config.Scheme); + } + + [Fact] + public void Ctor_Token_DefaultsToNull() + { + var config = new TestableConfig(); + Assert.Null(config.Token); + } + + [Fact] + public void Ctor_DriverDirectory_DefaultsToNull() + { + var config = new TestableConfig(); + Assert.Null(config.DriverDirectory); + } + + [Fact] + public void AllProperties_CanBeSetAndGet() + { + var config = new TestableConfig + { + AppName = "MyApp.exe", + MainAppName = "MainApp", + InstallPath = "C:\\MyApp", + UpdateLogUrl = "https://logs.example.com", + AppSecretKey = "secret-key", + ClientVersion = "1.2.3", + BlackFiles = new List { "a.dll" }, + BlackFormats = new List { ".log" }, + SkipDirectorys = new List { "temp" }, + ReportUrl = "https://report.example.com", + Bowl = "Bowl.exe", + Scheme = "https", + Token = "bearer-token", + DriverDirectory = "C:\\Drivers" + }; + + Assert.Equal("MyApp.exe", config.AppName); + Assert.Equal("MainApp", config.MainAppName); + Assert.Equal("C:\\MyApp", config.InstallPath); + Assert.Equal("https://logs.example.com", config.UpdateLogUrl); + Assert.Equal("secret-key", config.AppSecretKey); + Assert.Equal("1.2.3", config.ClientVersion); + Assert.Single(config.BlackFiles); + Assert.Single(config.BlackFormats); + Assert.Single(config.SkipDirectorys); + Assert.Equal("https://report.example.com", config.ReportUrl); + Assert.Equal("Bowl.exe", config.Bowl); + Assert.Equal("https", config.Scheme); + Assert.Equal("bearer-token", config.Token); + Assert.Equal("C:\\Drivers", config.DriverDirectory); + } +} diff --git a/tests/CoreTest/Configuration/BlackListConfigBuilderTests.cs b/tests/CoreTest/Configuration/BlackListConfigBuilderTests.cs new file mode 100644 index 00000000..a260c694 --- /dev/null +++ b/tests/CoreTest/Configuration/BlackListConfigBuilderTests.cs @@ -0,0 +1,240 @@ +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.FileSystem; + +namespace CoreTest.Configuration; + +/// +/// AAAT unit tests for and . +/// Covers: fluent chaining, empty builds, null-vs-empty discrimination, HasRules logic, Builder reuse. +/// +public class BlackListConfigBuilderTests +{ + #region BlackListConfigBuilder fluent API + + [Fact] + public void Build_NoMethodsCalled_AllPropertiesNull() + { + // Arrange + var builder = new BlackListConfigBuilder(); + + // Act + var config = builder.Build(); + + // Assert + Assert.Null(config.BlackFiles); + Assert.Null(config.BlackFormats); + Assert.Null(config.SkipDirectorys); + } + + [Fact] + public void Build_OnlyAddBlackFiles_OthersNull() + { + // Arrange + var builder = new BlackListConfigBuilder(); + builder.AddBlackFiles("a.dll", "b.dll"); + + // Act + var config = builder.Build(); + + // Assert + Assert.NotNull(config.BlackFiles); + Assert.Equal(2, config.BlackFiles!.Count); + Assert.Contains("a.dll", config.BlackFiles); + Assert.Contains("b.dll", config.BlackFiles); + Assert.Null(config.BlackFormats); + Assert.Null(config.SkipDirectorys); + } + + [Fact] + public void Build_OnlyAddBlackFormats_OthersNull() + { + var builder = new BlackListConfigBuilder(); + builder.AddBlackFormats(".patch", ".pdb", ".json"); + + var config = builder.Build(); + + Assert.NotNull(config.BlackFormats); + Assert.Equal(3, config.BlackFormats!.Count); + Assert.Null(config.BlackFiles); + Assert.Null(config.SkipDirectorys); + } + + [Fact] + public void Build_OnlyAddSkipDirectories_OthersNull() + { + var builder = new BlackListConfigBuilder(); + builder.AddSkipDirectories("app-", "fail"); + + var config = builder.Build(); + + Assert.NotNull(config.SkipDirectorys); + Assert.Equal(2, config.SkipDirectorys!.Count); + Assert.Null(config.BlackFiles); + Assert.Null(config.BlackFormats); + } + + [Fact] + public void Build_AllThreeSectionsFilled_AllReturned() + { + var builder = new BlackListConfigBuilder(); + builder.AddBlackFiles("f1.dll"); + builder.AddBlackFormats(".log"); + builder.AddSkipDirectories("tmp"); + + var config = builder.Build(); + + Assert.Single(config.BlackFiles!); + Assert.Single(config.BlackFormats!); + Assert.Single(config.SkipDirectorys!); + } + + [Fact] + public void AddBlackFiles_EmptyParams_NoItemsAdded() + { + var builder = new BlackListConfigBuilder(); + builder.AddBlackFiles(); + + var config = builder.Build(); + + Assert.Null(config.BlackFiles); + } + + [Fact] + public void AddBlackFormats_EmptyParams_NoItemsAdded() + { + var builder = new BlackListConfigBuilder(); + builder.AddBlackFormats(); + + var config = builder.Build(); + + Assert.Null(config.BlackFormats); + } + + [Fact] + public void AddSkipDirectories_EmptyParams_NoItemsAdded() + { + var builder = new BlackListConfigBuilder(); + builder.AddSkipDirectories(); + + var config = builder.Build(); + + Assert.Null(config.SkipDirectorys); + } + + [Fact] + public void AddBlackFiles_MultipleCalls_Accumulates() + { + var builder = new BlackListConfigBuilder(); + builder.AddBlackFiles("a.dll"); + builder.AddBlackFiles("b.dll", "c.dll"); + + var config = builder.Build(); + + Assert.Equal(3, config.BlackFiles!.Count); + } + + [Fact] + public void AddBlackFormats_MultipleCalls_Accumulates() + { + var builder = new BlackListConfigBuilder(); + builder.AddBlackFormats(".dll"); + builder.AddBlackFormats(".exe", ".log"); + + var config = builder.Build(); + + Assert.Equal(3, config.BlackFormats!.Count); + } + + [Fact] + public void AddSkipDirectories_MultipleCalls_Accumulates() + { + var builder = new BlackListConfigBuilder(); + builder.AddSkipDirectories("app-"); + builder.AddSkipDirectories("temp", "cache"); + + var config = builder.Build(); + + Assert.Equal(3, config.SkipDirectorys!.Count); + } + + [Fact] + public void Builder_FluentChaining_ReturnsBuilder() + { + var builder = new BlackListConfigBuilder(); + var result = builder.AddBlackFiles("a").AddBlackFormats(".z").AddSkipDirectories("d"); + + Assert.Same(builder, result); + } + + [Fact] + public void Build_ProducesNonNullLists() + { + var builder = new BlackListConfigBuilder(); + builder.AddBlackFiles("f.dll"); + + var config = builder.Build(); + + Assert.NotNull(config.BlackFiles); + Assert.Single(config.BlackFiles); + } + + #endregion + + #region BlackListConfig + + [Fact] + public void Empty_HasNoRules() + { + var empty = BlackListConfig.Empty; + + Assert.False(empty.HasRules); + Assert.Null(empty.BlackFiles); + Assert.Null(empty.BlackFormats); + Assert.Null(empty.SkipDirectorys); + } + + [Theory] + // All 8 combinations of (hasFiles, hasFormats, hasDirs) + [InlineData(false, false, false, false)] // none → false + [InlineData(true, false, false, true)] // files only → true + [InlineData(false, true, false, true)] // formats only → true + [InlineData(false, false, true, true)] // dirs only → true + [InlineData(true, true, false, true)] // files + formats → true + [InlineData(true, false, true, true)] // files + dirs → true + [InlineData(false, true, true, true)] // formats + dirs → true + [InlineData(true, true, true, true)] // all three → true + public void HasRules_AllCombinations(bool hasFiles, bool hasFormats, bool hasDirs, bool expected) + { + var builder = new BlackListConfigBuilder(); + if (hasFiles) builder.AddBlackFiles("f.dll"); + if (hasFormats) builder.AddBlackFormats(".log"); + if (hasDirs) builder.AddSkipDirectories("tmp"); + + var config = builder.Build(); + + Assert.Equal(expected, config.HasRules); + } + + [Fact] + public void HasRules_AllNull_ReturnsFalse() + { + var config = new BlackListConfig(null, null, null); + + Assert.False(config.HasRules); + } + + [Fact] + public void HasRules_AllEmptyLists_ReturnsFalse() + { + // Empty lists differ from null — the builder produces null for empty + // Direct record construction with empty lists + var config = new BlackListConfig( + new List().AsReadOnly(), + new List().AsReadOnly(), + new List().AsReadOnly()); + + Assert.False(config.HasRules); + } + + #endregion +} diff --git a/tests/CoreTest/Configuration/ComparisonResultTests.cs b/tests/CoreTest/Configuration/ComparisonResultTests.cs new file mode 100644 index 00000000..680069b7 --- /dev/null +++ b/tests/CoreTest/Configuration/ComparisonResultTests.cs @@ -0,0 +1,205 @@ +using GeneralUpdate.Core.FileSystem; + +namespace CoreTest.Configuration; + +/// +/// AAAT unit tests for . +/// Covers: default constructor state, AddToLeft/Right/Different, multiple adds, empty adds, immutability via AsReadOnly. +/// +public class ComparisonResultTests +{ + private static FileNode Node(string name) => new() { Name = name, Path = "/root/" + name, Hash = "abc" }; + + #region Constructor / Default State + + [Fact] + public void Ctor_Default_AllListsEmpty() + { + var cr = new ComparisonResult(); + + Assert.Empty(cr.LeftNodes); + Assert.Empty(cr.RightNodes); + Assert.Empty(cr.DifferentNodes); + } + + [Fact] + public void Ctor_Default_AllListsNotNull() + { + var cr = new ComparisonResult(); + + Assert.NotNull(cr.LeftNodes); + Assert.NotNull(cr.RightNodes); + Assert.NotNull(cr.DifferentNodes); + } + + #endregion + + #region AddToLeft + + [Fact] + public void AddToLeft_SingleNode_AddedToLeft() + { + var cr = new ComparisonResult(); + var node = Node("a.txt"); + + cr.AddToLeft(new[] { node }); + + Assert.Single(cr.LeftNodes); + Assert.Equal("a.txt", cr.LeftNodes[0].Name); + } + + [Fact] + public void AddToLeft_MultipleCalls_Accumulates() + { + var cr = new ComparisonResult(); + + cr.AddToLeft(new[] { Node("a.txt") }); + cr.AddToLeft(new[] { Node("b.txt"), Node("c.txt") }); + + Assert.Equal(3, cr.LeftNodes.Count); + } + + [Fact] + public void AddToLeft_EmptyEnumerable_CountUnchanged() + { + var cr = new ComparisonResult(); + cr.AddToLeft(Enumerable.Empty()); + + Assert.Empty(cr.LeftNodes); + } + + [Fact] + public void AddToLeft_DoesNotAffectRightOrDifferent() + { + var cr = new ComparisonResult(); + cr.AddToLeft(new[] { Node("a.txt") }); + + Assert.Empty(cr.RightNodes); + Assert.Empty(cr.DifferentNodes); + } + + #endregion + + #region AddToRight + + [Fact] + public void AddToRight_SingleNode_AddedToRight() + { + var cr = new ComparisonResult(); + var node = Node("b.txt"); + + cr.AddToRight(new[] { node }); + + Assert.Single(cr.RightNodes); + Assert.Equal("b.txt", cr.RightNodes[0].Name); + } + + [Fact] + public void AddToRight_MultipleCalls_Accumulates() + { + var cr = new ComparisonResult(); + + cr.AddToRight(new[] { Node("x.txt") }); + cr.AddToRight(new[] { Node("y.txt") }); + + Assert.Equal(2, cr.RightNodes.Count); + } + + [Fact] + public void AddToRight_DoesNotAffectLeftOrDifferent() + { + var cr = new ComparisonResult(); + cr.AddToRight(new[] { Node("b.txt") }); + + Assert.Empty(cr.LeftNodes); + Assert.Empty(cr.DifferentNodes); + } + + #endregion + + #region AddDifferent + + [Fact] + public void AddDifferent_SingleNode_AddedToDifferent() + { + var cr = new ComparisonResult(); + var node = Node("c.txt"); + + cr.AddDifferent(new[] { node }); + + Assert.Single(cr.DifferentNodes); + Assert.Equal("c.txt", cr.DifferentNodes[0].Name); + } + + [Fact] + public void AddDifferent_MultipleCalls_Accumulates() + { + var cr = new ComparisonResult(); + + cr.AddDifferent(new[] { Node("m.txt"), Node("n.txt") }); + cr.AddDifferent(new[] { Node("o.txt") }); + + Assert.Equal(3, cr.DifferentNodes.Count); + } + + [Fact] + public void AddDifferent_DoesNotAffectLeftOrRight() + { + var cr = new ComparisonResult(); + cr.AddDifferent(new[] { Node("d.txt") }); + + Assert.Empty(cr.LeftNodes); + Assert.Empty(cr.RightNodes); + } + + #endregion + + #region Combined usage + + [Fact] + public void AllThreeSections_CanBePopulatedSimultaneously() + { + var cr = new ComparisonResult(); + + cr.AddToLeft(new[] { Node("only-left.txt") }); + cr.AddToRight(new[] { Node("only-right.txt") }); + cr.AddDifferent(new[] { Node("changed.txt"), Node("also-changed.txt") }); + + Assert.Single(cr.LeftNodes); + Assert.Single(cr.RightNodes); + Assert.Equal(2, cr.DifferentNodes.Count); + } + + #endregion + + #region ReadOnly properties + + [Fact] + public void LeftNodes_IsReadOnly() + { + var cr = new ComparisonResult(); + cr.AddToLeft(new[] { Node("a.txt") }); + + Assert.True(((System.Collections.IList)cr.LeftNodes).IsReadOnly); + } + + [Fact] + public void RightNodes_IsReadOnly() + { + var cr = new ComparisonResult(); + cr.AddToRight(new[] { Node("b.txt") }); + + Assert.True(((System.Collections.IList)cr.RightNodes).IsReadOnly); + } + + [Fact] + public void DifferentNodes_IsReadOnly() + { + var cr = new ComparisonResult(); + cr.AddDifferent(new[] { Node("c.txt") }); + + Assert.True(((System.Collections.IList)cr.DifferentNodes).IsReadOnly); + } + + #endregion +} diff --git a/tests/CoreTest/Configuration/ConfigurationMapperExtendedTests.cs b/tests/CoreTest/Configuration/ConfigurationMapperExtendedTests.cs new file mode 100644 index 00000000..de52d9c2 --- /dev/null +++ b/tests/CoreTest/Configuration/ConfigurationMapperExtendedTests.cs @@ -0,0 +1,233 @@ +namespace CoreTest.Configuration; + +using GeneralUpdate.Core.Configuration; + +/// +/// AAAT unit tests for — additional edge case coverage beyond existing tests. +/// Covers: MapToProcessInfo required fields, MapToGlobalConfigInfo edge fields, CopyBaseFields cross-type. +/// +public class ConfigurationMapperExtendedTests : IDisposable +{ + private readonly string _tempInstallDir; + + public ConfigurationMapperExtendedTests() + { + _tempInstallDir = Path.Combine(Path.GetTempPath(), "MapToProcessInfo_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempInstallDir); + } + + public void Dispose() + { + try { if (Directory.Exists(_tempInstallDir)) Directory.Delete(_tempInstallDir, true); } catch { } + } + + /// Creates a valid GlobalConfigInfo with all required fields for MapToProcessInfo. + private GlobalConfigInfo CreateValidSource() + { + return new GlobalConfigInfo + { + MainAppName = "MainApp", + InstallPath = _tempInstallDir, + ClientVersion = "1.0.0", + LastVersion = "2.0.0", + Encoding = System.Text.Encoding.UTF8, + Format = ".zip", + AppSecretKey = "secret", + ReportUrl = "https://report.example.com", + BackupDirectory = Path.Combine(_tempInstallDir, "backup") + }; + } + + private static List OneVersion(string ver = "2.0.0") + => new() { new VersionInfo { Version = ver, Hash = "abc", Name = $"v{ver}.zip" } }; + + #region MapToGlobalConfigInfo — additional edge cases + + [Fact] + public void MapToGlobalConfigInfo_SourceWithBlackLists_CopiesCorrectly() + { + var source = new Configinfo + { + BlackFiles = new List { "a.dll", "b.dll" }, + BlackFormats = new List { ".log", ".tmp" }, + SkipDirectorys = new List { "app-1.0", "cache" } + }; + + var result = ConfigurationMapper.MapToGlobalConfigInfo(source); + + Assert.Equal(2, result.BlackFiles?.Count); + Assert.Equal(2, result.BlackFormats?.Count); + Assert.Equal(2, result.SkipDirectorys?.Count); + } + + [Fact] + public void MapToGlobalConfigInfo_SourceWithNullLists_ReturnsNullLists() + { + var source = new Configinfo { BlackFiles = null, BlackFormats = null, SkipDirectorys = null }; + + var result = ConfigurationMapper.MapToGlobalConfigInfo(source); + + Assert.Null(result.BlackFiles); + Assert.Null(result.BlackFormats); + Assert.Null(result.SkipDirectorys); + } + + [Fact] + public void MapToGlobalConfigInfo_PreservesExistingTargetFieldsNotInSource() + { + var target = new GlobalConfigInfo + { + TempPath = "/custom/temp", + BackupDirectory = "/custom/backup", + MaxConcurrency = 8 + }; + var source = new Configinfo { AppName = "NewApp.exe" }; + + var result = ConfigurationMapper.MapToGlobalConfigInfo(source, target); + + Assert.Equal("/custom/temp", result.TempPath); + Assert.Equal("/custom/backup", result.BackupDirectory); + Assert.Equal(8, result.MaxConcurrency); + Assert.Equal("NewApp.exe", result.AppName); + } + + #endregion + + #region MapToProcessInfo — valid path scenarios + + [Fact] + public void MapToProcessInfo_WithVersionList_CopiesVersions() + { + var source = CreateValidSource(); + var versions = new List + { + new() { Version = "2.0.0", Hash = "abc", Name = "v2.zip" }, + new() { Version = "3.0.0", Hash = "def", Name = "v3.zip" } + }; + + var result = ConfigurationMapper.MapToProcessInfo(source, versions, + new List(), new List(), new List()); + + Assert.Equal(2, result.UpdateVersions.Count); + Assert.Equal("2.0.0", result.UpdateVersions[0].Version); + Assert.Equal("3.0.0", result.UpdateVersions[1].Version); + } + + [Fact] + public void MapToProcessInfo_WithBlackAndSkipPaths_ListsSet() + { + var source = CreateValidSource(); + var blackFiles = new List { "b1.dll" }; + var blackFormats = new List { ".log" }; + var skipDirs = new List { "temp" }; + + var result = ConfigurationMapper.MapToProcessInfo(source, + OneVersion(), blackFiles, blackFormats, skipDirs); + + // All blacklist lists should be set and non-null + Assert.NotNull(result.BlackFiles); + Assert.NotNull(result.BlackFileFormats); + Assert.NotNull(result.SkipDirectorys); + } + + [Fact] + public void MapToProcessInfo_WithDriverDirectory_Works() + { + var source = CreateValidSource(); + source.DriverDirectory = "C:\\Drivers\\Special"; + + var result = ConfigurationMapper.MapToProcessInfo(source, + OneVersion(), new List(), new List(), new List()); + + Assert.Equal("C:\\Drivers\\Special", result.DriverDirectory); + } + + [Fact] + public void MapToProcessInfo_StandardConfig_CheckAllMappedProperties() + { + var source = CreateValidSource(); + source.UpdateLogUrl = "https://logs.test.com"; + source.Encoding = System.Text.Encoding.ASCII; + source.Format = ".tar"; + source.DownloadTimeOut = 120; + + var result = ConfigurationMapper.MapToProcessInfo(source, + OneVersion(), new List(), new List(), new List()); + + Assert.Equal("MainApp", result.AppName); // MainAppName -> AppName + Assert.Equal(source.InstallPath, result.InstallPath); + Assert.Equal("1.0.0", result.CurrentVersion); + Assert.Equal("2.0.0", result.LastVersion); + Assert.Equal("secret", result.AppSecretKey); + Assert.Equal("us-ascii", result.CompressEncoding); + Assert.Equal(".tar", result.CompressFormat); + Assert.Equal(120, result.DownloadTimeOut); + Assert.Equal("https://logs.test.com", result.UpdateLogUrl); + Assert.Equal("https://report.example.com", result.ReportUrl); + Assert.NotNull(result.BackupDirectory); + } + + #endregion + + #region CopyBaseFields — cross-type between BaseConfigInfo subtypes + + [Fact] + public void CopyBaseFields_ConfiginfoToGlobalConfigInfo_Works() + { + var source = new Configinfo + { + AppName = "App.exe", + MainAppName = "Main", + InstallPath = "C:\\app", + ClientVersion = "v1", + AppSecretKey = "key1", + Token = "tok", + Bowl = "bowl.exe", + DriverDirectory = "C:\\drv" + }; + var target = new GlobalConfigInfo(); + + ConfigurationMapper.CopyBaseFields(source, target); + + Assert.Equal("App.exe", target.AppName); + Assert.Equal("Main", target.MainAppName); + Assert.Equal("C:\\app", target.InstallPath); + Assert.Equal("v1", target.ClientVersion); + Assert.Equal("key1", target.AppSecretKey); + Assert.Equal("tok", target.Token); + Assert.Equal("bowl.exe", target.Bowl); + Assert.Equal("C:\\drv", target.DriverDirectory); + } + + [Fact] + public void CopyBaseFields_GraphCopy_ConfiginfoToNewConfiginfo_Works() + { + var source = new Configinfo + { + AppName = "Source.exe", + ClientVersion = "5.0.0", + Scheme = "https" + }; + var target = new Configinfo(); + + ConfigurationMapper.CopyBaseFields(source, target); + + Assert.Equal("Source.exe", target.AppName); + Assert.Equal("5.0.0", target.ClientVersion); + Assert.Equal("https", target.Scheme); + } + + [Fact] + public void CopyBaseFields_NullSource_DoesNotThrow() + { + var target = new GlobalConfigInfo { AppName = "keep" }; + + var ex = Record.Exception(() => + ConfigurationMapper.CopyBaseFields(null!, target)); + + Assert.Null(ex); + Assert.Equal("keep", target.AppName); + } + + #endregion +} diff --git a/tests/CoreTest/Configuration/HubConfigTests.cs b/tests/CoreTest/Configuration/HubConfigTests.cs new file mode 100644 index 00000000..41304990 --- /dev/null +++ b/tests/CoreTest/Configuration/HubConfigTests.cs @@ -0,0 +1,70 @@ +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +/// +/// AAAT unit tests for — default values and property mutation. +/// Covers: default construction, property get/set, boundary time spans, negative reconnects. +/// +public class HubConfigTests +{ + [Fact] + public void Ctor_Default_UrlIsEmpty() + { + var config = new HubConfig(); + Assert.Equal(string.Empty, config.Url); + } + + [Fact] + public void Ctor_Default_ReconnectDelayIs5Seconds() + { + var config = new HubConfig(); + Assert.Equal(TimeSpan.FromSeconds(5), config.ReconnectDelay); + } + + [Fact] + public void Ctor_Default_MaxReconnectAttemptsIs10() + { + var config = new HubConfig(); + Assert.Equal(10, config.MaxReconnectAttempts); + } + + [Fact] + public void Url_SetAndGet_Works() + { + var config = new HubConfig { Url = "https://hub.example.com/update" }; + Assert.Equal("https://hub.example.com/update", config.Url); + } + + [Fact] + public void ReconnectDelay_SetToZero_Works() + { + var config = new HubConfig { ReconnectDelay = TimeSpan.Zero }; + Assert.Equal(TimeSpan.Zero, config.ReconnectDelay); + } + + [Fact] + public void ReconnectDelay_SetToMaxValue_Works() + { + var config = new HubConfig { ReconnectDelay = TimeSpan.MaxValue }; + Assert.Equal(TimeSpan.MaxValue, config.ReconnectDelay); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(100)] + [InlineData(-1)] + public void MaxReconnectAttempts_SetVarious_Works(int attempts) + { + var config = new HubConfig { MaxReconnectAttempts = attempts }; + Assert.Equal(attempts, config.MaxReconnectAttempts); + } + + [Fact] + public void MaxReconnectAttempts_IntMinValue_Works() + { + var config = new HubConfig { MaxReconnectAttempts = int.MinValue }; + Assert.Equal(int.MinValue, config.MaxReconnectAttempts); + } +} diff --git a/tests/CoreTest/Configuration/PacketTests.cs b/tests/CoreTest/Configuration/PacketTests.cs new file mode 100644 index 00000000..015d0662 --- /dev/null +++ b/tests/CoreTest/Configuration/PacketTests.cs @@ -0,0 +1,118 @@ +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +/// +/// AAAT unit tests for — property defaults, set/get, JSON serialization attrs. +/// Covers: all nullable bool?/int?/DateTime?/string? properties, null vs non-null, IsForcibly/IsFreeze tri-state. +/// +public class PacketTests +{ + [Fact] + public void Ctor_Default_AllPropertiesAreNullOrDefault() + { + var packet = new Packet(); + + Assert.Null(packet.Name); + Assert.Null(packet.Hash); + Assert.Null(packet.ReleaseDate); + Assert.Null(packet.Url); + Assert.Null(packet.Version); + Assert.Null(packet.AppType); + Assert.Null(packet.Platform); + Assert.Null(packet.ProductId); + Assert.Null(packet.IsForcibly); + Assert.Null(packet.IsFreeze); + } + + [Fact] + public void FullAssignment_AllPropertiesSet() + { + var releaseDate = new DateTime(2024, 6, 15, 0, 0, 0, DateTimeKind.Utc); + + var packet = new Packet + { + Name = "UpdatePack", + Hash = "abc123", + ReleaseDate = releaseDate, + Url = "https://cdn.example.com/pack.zip", + Version = "2.0.0", + AppType = 1, + Platform = 0, + ProductId = "prod-001", + IsForcibly = true, + IsFreeze = false + }; + + Assert.Equal("UpdatePack", packet.Name); + Assert.Equal("abc123", packet.Hash); + Assert.Equal(releaseDate, packet.ReleaseDate); + Assert.Equal("https://cdn.example.com/pack.zip", packet.Url); + Assert.Equal("2.0.0", packet.Version); + Assert.Equal(1, packet.AppType); + Assert.Equal(0, packet.Platform); + Assert.Equal("prod-001", packet.ProductId); + Assert.True(packet.IsForcibly); + Assert.False(packet.IsFreeze); + } + + [Fact] + public void IsForcibly_SetToNull_ReturnsNull() + { + var packet = new Packet(); + Assert.Null(packet.IsForcibly); + + packet.IsForcibly = true; + Assert.True(packet.IsForcibly); + + packet.IsForcibly = null; + Assert.Null(packet.IsForcibly); + } + + [Fact] + public void IsFreeze_SetToNull_ReturnsNull() + { + var packet = new Packet(); + Assert.Null(packet.IsFreeze); + + packet.IsFreeze = false; + Assert.False(packet.IsFreeze); + + packet.IsFreeze = null; + Assert.Null(packet.IsFreeze); + } + + [Fact] + public void AppType_SetToNull_ReturnsNull() + { + var packet = new Packet { AppType = 2 }; + Assert.Equal(2, packet.AppType); + + packet.AppType = null; + Assert.Null(packet.AppType); + } + + [Fact] + public void Platform_SetToNull_ReturnsNull() + { + var packet = new Packet { Platform = 1 }; + Assert.Equal(1, packet.Platform); + + packet.Platform = null; + Assert.Null(packet.Platform); + } + + [Fact] + public void ReleaseDate_Nullable_Works() + { + var packet = new Packet(); + Assert.Null(packet.ReleaseDate); + + var dt = DateTime.UtcNow; + packet.ReleaseDate = dt; + Assert.Equal(dt, packet.ReleaseDate); + + packet.ReleaseDate = null; + Assert.Null(packet.ReleaseDate); + } +} diff --git a/tests/CoreTest/Configuration/UpdateOptionValueTests.cs b/tests/CoreTest/Configuration/UpdateOptionValueTests.cs new file mode 100644 index 00000000..880e8661 --- /dev/null +++ b/tests/CoreTest/Configuration/UpdateOptionValueTests.cs @@ -0,0 +1,202 @@ +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +/// +/// AAAT unit tests for — the strongly-typed option value wrapper. +/// Covers: construction, Option property, GetValue, ToString (normal/edge/null), value semantics. +/// +public class UpdateOptionValueTests +{ + #region Construction & Basic Properties + + [Fact] + public void Ctor_IntValue_StoresCorrectly() + { + var option = UpdateOption.ValueOf("INT_KEY_UV", 0); + var value = new UpdateOptionValue(option, 42); + + Assert.Same(option, value.Option); + Assert.Equal(42, value.GetValue()); + } + + [Fact] + public void Ctor_StringValue_StoresCorrectly() + { + var option = UpdateOption.ValueOf("STR_KEY"); + var value = new UpdateOptionValue(option, "hello"); + + Assert.Same(option, value.Option); + Assert.Equal("hello", value.GetValue()); + } + + [Fact] + public void Ctor_NullableBool_StoresCorrectly() + { + var option = UpdateOption.ValueOf("BOOL_KEY"); + var value = new UpdateOptionValue(option, true); + + Assert.Equal(true, value.GetValue()); + } + + [Fact] + public void Ctor_NullReferenceType_StoresCorrectly() + { + var option = UpdateOption.ValueOf("NULL_KEY"); + var value = new UpdateOptionValue(option, null!); + + Assert.Null(value.GetValue()); + } + + [Fact] + public void Ctor_DefaultValueType_StoresCorrectly() + { + var option = UpdateOption.ValueOf("DEFAULT_INT"); + var value = new UpdateOptionValue(option, default); + + Assert.Equal(0, value.GetValue()); + } + + #endregion + + #region GetValue returns correct type + + [Fact] + public void GetValue_Int_ReturnsBoxedInt() + { + var option = UpdateOption.ValueOf("BOXED_INT", 0); + var value = new UpdateOptionValue(option, 99); + + var result = value.GetValue(); + + Assert.IsType(result); + Assert.Equal(99, result); + } + + [Fact] + public void GetValue_BaseClass_ReturnsBoxedValue() + { + var option = UpdateOption.ValueOf("BASE_CLASS", 0); + var value = new UpdateOptionValue(option, 7); + UpdateOptionValue baseRef = value; + + var result = baseRef.GetValue(); + + Assert.IsType(result); + Assert.Equal(7, result); + } + + #endregion + + #region ToString + + [Fact] + public void ToString_NonNullValue_ReturnsValueToString() + { + var option = UpdateOption.ValueOf("TOSTR1", 0); + var value = new UpdateOptionValue(option, 42); + + Assert.Equal("42", value.ToString()); + } + + [Fact] + public void ToString_NullValue_ReturnsEmptyString() + { + var option = UpdateOption.ValueOf("TOSTR2"); + var value = new UpdateOptionValue(option, null!); + + Assert.Equal(string.Empty, value.ToString()); + } + + [Fact] + public void ToString_EmptyStringValue_ReturnsEmptyString() + { + var option = UpdateOption.ValueOf("TOSTR3"); + var value = new UpdateOptionValue(option, string.Empty); + + Assert.Equal(string.Empty, value.ToString()); + } + + [Fact] + public void ToString_DateTimeValue_ReturnsFormattedDateTime() + { + var option = UpdateOption.ValueOf("TOSTR_DT"); + var dt = new DateTime(2024, 6, 15, 0, 0, 0, DateTimeKind.Utc); + var value = new UpdateOptionValue(option, dt); + + var result = value.ToString(); + + Assert.Contains("2024", result); + } + + [Fact] + public void ToString_BooleanValue_ReturnsTrueOrFalse() + { + var option = UpdateOption.ValueOf("TOSTR_BOOL"); + var trueVal = new UpdateOptionValue(option, true); + var falseVal = new UpdateOptionValue(option, false); + + Assert.Equal("True", trueVal.ToString()); + Assert.Equal("False", falseVal.ToString()); + } + + #endregion + + #region Option property validation + + [Fact] + public void Option_ReturnsTheSameOptionInstance() + { + var option1 = UpdateOption.ValueOf("OPT_CHECK_1", 1); + var option2 = UpdateOption.ValueOf("OPT_CHECK_2", 2); + var value1 = new UpdateOptionValue(option1, 100); + var value2 = new UpdateOptionValue(option2, 200); + + Assert.Same(option1, value1.Option); + Assert.Same(option2, value2.Option); + Assert.NotSame(value1.Option, value2.Option); + } + + #endregion + + #region Edge cases + + [Fact] + public void Ctor_MaxInt_StoresCorrectly() + { + var option = UpdateOption.ValueOf("MAX_INT", 0); + var value = new UpdateOptionValue(option, int.MaxValue); + + Assert.Equal(int.MaxValue, value.GetValue()); + } + + [Fact] + public void Ctor_MinInt_StoresCorrectly() + { + var option = UpdateOption.ValueOf("MIN_INT", 0); + var value = new UpdateOptionValue(option, int.MinValue); + + Assert.Equal(int.MinValue, value.GetValue()); + } + + [Fact] + public void Ctor_LongString_StoresCorrectly() + { + var option = UpdateOption.ValueOf("LONG_STR"); + var longStr = new string('x', 10000); + var value = new UpdateOptionValue(option, longStr); + + Assert.Equal(longStr, value.GetValue()); + } + + [Fact] + public void Ctor_DoubleNaN_StoresCorrectly() + { + var option = UpdateOption.ValueOf("NAN_KEY"); + var value = new UpdateOptionValue(option, double.NaN); + + Assert.True(double.IsNaN((double)value.GetValue())); + } + + #endregion +} diff --git a/tests/CoreTest/Configuration/UpdateOptionsStaticTests.cs b/tests/CoreTest/Configuration/UpdateOptionsStaticTests.cs new file mode 100644 index 00000000..c3fd3105 --- /dev/null +++ b/tests/CoreTest/Configuration/UpdateOptionsStaticTests.cs @@ -0,0 +1,135 @@ +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +/// +/// AAAT unit tests for — static option key definitions. +/// Validates the default values as defined in the source. +/// +public class UpdateOptionsStaticTests +{ + #region Static option key existence & defaults + + [Fact] + public void AppType_HasCorrectDefault() + { + Assert.Equal(GeneralUpdate.Core.Configuration.AppType.Client, UpdateOptions.AppType.DefaultValue); + } + + [Fact] + public void DiffMode_HasCorrectDefault() + { + Assert.Equal(DiffMode.Serial, UpdateOptions.DiffMode.DefaultValue); + } + + [Fact] + public void Encoding_HasCorrectDefault() + { + Assert.Equal(System.Text.Encoding.UTF8, UpdateOptions.Encoding.DefaultValue); + } + + [Fact] + public void Format_HasCorrectDefault() + { + Assert.Equal("ZIP", UpdateOptions.Format.DefaultValue); + } + + [Fact] + public void DownloadTimeout_HasCorrectDefault() + { + Assert.Equal(30, UpdateOptions.DownloadTimeout.DefaultValue); + } + + [Fact] + public void PatchEnabled_HasCorrectDefault() + { + Assert.True(UpdateOptions.PatchEnabled.DefaultValue); + } + + [Fact] + public void BackupEnabled_HasCorrectDefault() + { + Assert.True(UpdateOptions.BackupEnabled.DefaultValue); + } + + [Fact] + public void Silent_HasCorrectDefault() + { + Assert.False(UpdateOptions.Silent.DefaultValue); + } + + [Fact] + public void SilentAutoInstall_HasCorrectDefault() + { + Assert.False(UpdateOptions.SilentAutoInstall.DefaultValue); + } + + [Fact] + public void SilentPollIntervalMinutes_HasCorrectDefault() + { + Assert.Equal(60, UpdateOptions.SilentPollIntervalMinutes.DefaultValue); + } + + [Fact] + public void MaxConcurrency_HasCorrectDefault() + { + Assert.Equal(3, UpdateOptions.MaxConcurrency.DefaultValue); + } + + [Fact] + public void EnableResume_HasCorrectDefault() + { + Assert.True(UpdateOptions.EnableResume.DefaultValue); + } + + [Fact] + public void RetryCount_HasCorrectDefault() + { + Assert.Equal(3, UpdateOptions.RetryCount.DefaultValue); + } + + [Fact] + public void VerifyChecksum_HasCorrectDefault() + { + Assert.True(UpdateOptions.VerifyChecksum.DefaultValue); + } + + [Fact] + public void RetryInterval_HasCorrectDefault() + { + Assert.Equal(TimeSpan.FromSeconds(1), UpdateOptions.RetryInterval.DefaultValue); + } + + #endregion + + #region Singleton identity + + [Fact] + public void AppType_RepeatedAccess_ReturnsSameInstance() + { + var a = UpdateOptions.AppType; + var b = UpdateOptions.AppType; + Assert.Same(a, b); + } + + [Fact] + public void AllOptions_RepeatedAccess_ReturnsSameInstance() + { + Assert.Same(UpdateOptions.DiffMode, UpdateOptions.DiffMode); + Assert.Same(UpdateOptions.Format, UpdateOptions.Format); + Assert.Same(UpdateOptions.MaxConcurrency, UpdateOptions.MaxConcurrency); + Assert.Same(UpdateOptions.RetryCount, UpdateOptions.RetryCount); + } + + #endregion + + #region Hub option + + [Fact] + public void Hub_NotSet_HasNullDefault() + { + Assert.Null(UpdateOptions.Hub.DefaultValue); + } + + #endregion +} diff --git a/tests/CoreTest/Configuration/VersionOSSTests.cs b/tests/CoreTest/Configuration/VersionOSSTests.cs new file mode 100644 index 00000000..1f60e295 --- /dev/null +++ b/tests/CoreTest/Configuration/VersionOSSTests.cs @@ -0,0 +1,100 @@ +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +/// +/// AAAT unit tests for — property defaults and set/get. +/// Covers: all properties, DateTime precision, null/empty strings. +/// +public class VersionOSSTests +{ + [Fact] + public void Ctor_Default_AllPropertiesAreDefault() + { + var version = new VersionOSS(); + + Assert.Equal(default(DateTime), version.PubTime); + Assert.Null(version.PacketName); + Assert.Null(version.Hash); + Assert.Null(version.Version); + Assert.Null(version.Url); + } + + [Fact] + public void FullAssignment_AllPropertiesSet() + { + var pubTime = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc); + + var version = new VersionOSS + { + PubTime = pubTime, + PacketName = "Release-2.0.0.zip", + Hash = "def456abc", + Version = "2.0.0", + Url = "https://oss.example.com/bucket/release.zip" + }; + + Assert.Equal(pubTime, version.PubTime); + Assert.Equal("Release-2.0.0.zip", version.PacketName); + Assert.Equal("def456abc", version.Hash); + Assert.Equal("2.0.0", version.Version); + Assert.Equal("https://oss.example.com/bucket/release.zip", version.Url); + } + + [Fact] + public void PacketName_SetToNull_Works() + { + var version = new VersionOSS { PacketName = "something" }; + Assert.Equal("something", version.PacketName); + + version.PacketName = null; + Assert.Null(version.PacketName); + } + + [Fact] + public void Hash_SetToNull_Works() + { + var version = new VersionOSS { Hash = "hash" }; + version.Hash = null; + Assert.Null(version.Hash); + } + + [Fact] + public void Version_SetToNull_Works() + { + var version = new VersionOSS { Version = "1.0" }; + version.Version = null; + Assert.Null(version.Version); + } + + [Fact] + public void Url_SetToNull_Works() + { + var version = new VersionOSS { Url = "https://a" }; + version.Url = null; + Assert.Null(version.Url); + } + + [Fact] + public void PubTime_UtcNow_StoredCorrectly() + { + var now = DateTime.UtcNow; + var version = new VersionOSS { PubTime = now }; + + Assert.Equal(now, version.PubTime); + } + + [Fact] + public void PubTime_MinValue_StoredCorrectly() + { + var version = new VersionOSS { PubTime = DateTime.MinValue }; + Assert.Equal(DateTime.MinValue, version.PubTime); + } + + [Fact] + public void PubTime_MaxValue_StoredCorrectly() + { + var version = new VersionOSS { PubTime = DateTime.MaxValue }; + Assert.Equal(DateTime.MaxValue, version.PubTime); + } +} diff --git a/tests/CoreTest/Configuration/VersionRespDTOTests.cs b/tests/CoreTest/Configuration/VersionRespDTOTests.cs new file mode 100644 index 00000000..8eb06a79 --- /dev/null +++ b/tests/CoreTest/Configuration/VersionRespDTOTests.cs @@ -0,0 +1,138 @@ +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +/// +/// AAAT unit tests for and . +/// Covers: default construction, property set/get, generic type resolution, null body. +/// +public class VersionRespDTOTests +{ + [Fact] + public void Ctor_Default_CodeIsZero() + { + var resp = new VersionRespDTO(); + Assert.Equal(0, resp.Code); + } + + [Fact] + public void Ctor_Default_BodyIsNull() + { + var resp = new VersionRespDTO(); + Assert.Null(resp.Body); + } + + [Fact] + public void Ctor_Default_MessageIsNull() + { + var resp = new VersionRespDTO(); + Assert.Null(resp.Message); + } + + [Fact] + public void FullAssignment_AllPropertiesSet() + { + var versions = new List + { + new() { Version = "2.0.0", Hash = "abc", Name = "update.zip" } + }; + + var resp = new VersionRespDTO + { + Code = 200, + Body = versions, + Message = "success" + }; + + Assert.Equal(200, resp.Code); + Assert.Same(versions, resp.Body); + Assert.Equal("success", resp.Message); + Assert.Single(resp.Body); + Assert.Equal("2.0.0", resp.Body[0].Version); + } + + [Fact] + public void BaseResponseDTO_WithStringBody_Works() + { + var resp = new BaseResponseDTO + { + Code = 404, + Body = "not found", + Message = "error" + }; + + Assert.Equal(404, resp.Code); + Assert.Equal("not found", resp.Body); + Assert.Equal("error", resp.Message); + } + + [Fact] + public void BaseResponseDTO_WithIntBody_Works() + { + var resp = new BaseResponseDTO + { + Code = 200, + Body = 42, + Message = "ok" + }; + + Assert.Equal(200, resp.Code); + Assert.Equal(42, resp.Body); + Assert.Equal("ok", resp.Message); + } + + [Theory] + [InlineData(0)] + [InlineData(200)] + [InlineData(400)] + [InlineData(500)] + public void Code_SetVariousValues_Works(int code) + { + var resp = new VersionRespDTO { Code = code }; + Assert.Equal(code, resp.Code); + } + + [Fact] + public void Code_NegativeValue_Works() + { + var resp = new VersionRespDTO { Code = -1 }; + Assert.Equal(-1, resp.Code); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("ok")] + [InlineData("error message with special chars: !@#$%^&*()")] + public void Message_SetVariousValues_Works(string message) + { + var resp = new VersionRespDTO { Message = message }; + Assert.Equal(message, resp.Message); + } + + [Fact] + public void Body_EmptyList_Works() + { + var resp = new VersionRespDTO + { + Code = 200, + Body = new List(), + Message = "no updates" + }; + + Assert.NotNull(resp.Body); + Assert.Empty(resp.Body); + } + + [Fact] + public void Body_WithMultipleVersions_Works() + { + var versions = Enumerable.Range(1, 5).Select(i => + new VersionInfo { Version = $"{i}.0.0", Hash = $"hash{i}" }).ToList(); + + var resp = new VersionRespDTO { Body = versions }; + + Assert.Equal(5, resp.Body.Count); + Assert.Equal("3.0.0", resp.Body[2].Version); + } +} diff --git a/tests/CoreTest/Download/DefaultDownloadPipelineTests.cs b/tests/CoreTest/Download/DefaultDownloadPipelineTests.cs new file mode 100644 index 00000000..ca8f724a --- /dev/null +++ b/tests/CoreTest/Download/DefaultDownloadPipelineTests.cs @@ -0,0 +1,199 @@ +using GeneralUpdate.Core.Download.Pipeline; + +namespace CoreTest.Download; + +/// +/// AAAT unit tests for — SHA256 hash verification pipeline. +/// Covers: no-hash passthrough, matching hash, mismatched hash, null hash, empty hash, file not found, cancelled token, whitespace hash. +/// +public class DefaultDownloadPipelineTests +{ + private static string TempFile(string content = "test content") + { + var path = Path.GetTempFileName(); + File.WriteAllText(path, content); + return path; + } + + #region No hash — passthrough + + [Fact] + public async Task ProcessAsync_NullHash_ReturnsPath() + { + var pipeline = new DefaultDownloadPipeline(null); + var filePath = TempFile(); + + try + { + var result = await pipeline.ProcessAsync(filePath); + Assert.Equal(filePath, result); + } + finally { TryDelete(filePath); } + } + + [Fact] + public async Task ProcessAsync_EmptyHash_ReturnsPath() + { + var pipeline = new DefaultDownloadPipeline(string.Empty); + var filePath = TempFile(); + + try + { + var result = await pipeline.ProcessAsync(filePath); + Assert.Equal(filePath, result); + } + finally { TryDelete(filePath); } + } + + [Fact] + public async Task ProcessAsync_WhitespaceHash_TriggersVerification() + { + // Whitespace is NOT null or empty, so it triggers hash verification which will fail + var pipeline = new DefaultDownloadPipeline(" "); + var filePath = TempFile(); + + try + { + await Assert.ThrowsAsync(() => pipeline.ProcessAsync(filePath)); + } + finally { TryDelete(filePath); } + } + + [Fact] + public async Task ProcessAsync_DefaultCtor_NoHash_ReturnsPath() + { + var pipeline = new DefaultDownloadPipeline(); + var filePath = TempFile(); + + try + { + var result = await pipeline.ProcessAsync(filePath); + Assert.Equal(filePath, result); + } + finally { TryDelete(filePath); } + } + + #endregion + + #region Hash verification + + [Fact] + public async Task ProcessAsync_MatchingHash_Succeeds() + { + var filePath = TempFile("Hello World"); + var expectedHash = ComputeSha256(filePath); + + var pipeline = new DefaultDownloadPipeline(expectedHash); + + try + { + var result = await pipeline.ProcessAsync(filePath); + Assert.Equal(filePath, result); + } + finally { TryDelete(filePath); } + } + + [Fact] + public async Task ProcessAsync_MismatchedHash_ThrowsInvalidDataException() + { + var filePath = TempFile("Hello World"); + + // Deliberately wrong hash + var pipeline = new DefaultDownloadPipeline("0000000000000000000000000000000000000000000000000000000000000000"); + try + { + await Assert.ThrowsAsync(() => pipeline.ProcessAsync(filePath)); + } + finally { TryDelete(filePath); } + } + + [Fact] + public async Task ProcessAsync_HashCaseInsensitive_MatchSucceeds() + { + var filePath = TempFile("case test"); + var lowerHash = ComputeSha256(filePath); + var upperHash = lowerHash.ToUpperInvariant(); + + Assert.NotEqual(lowerHash, upperHash); // confirm case differs + + var pipeline = new DefaultDownloadPipeline(upperHash); + + try + { + var result = await pipeline.ProcessAsync(filePath); + Assert.Equal(filePath, result); + } + finally { TryDelete(filePath); } + } + + #endregion + + #region Error handling + + [Fact] + public async Task ProcessAsync_FileNotFound_Throws() + { + var pipeline = new DefaultDownloadPipeline("abc"); + var nonExistentPath = Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid().ToString("N") + ".bin"); + await Assert.ThrowsAnyAsync(() => + pipeline.ProcessAsync(nonExistentPath)); + } + + [Fact] + public async Task ProcessAsync_CancelledTokenWithHash_ThrowsOperationCanceledException() + { + // When a hash IS provided, the pipeline actually performs async I/O (SHA256), + // which can observe cancellation + var filePath = TempFile("test content for cancellation"); + var expectedHash = ComputeSha256(filePath); + var pipeline = new DefaultDownloadPipeline(expectedHash); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + try + { + await Assert.ThrowsAnyAsync(() => + pipeline.ProcessAsync(filePath, cts.Token)); + } + finally { TryDelete(filePath); } + } + + #endregion + + #region Empty file + + [Fact] + public async Task ProcessAsync_EmptyFileHashMatch_Succeeds() + { + var filePath = TempFile(string.Empty); + var expectedHash = ComputeSha256(filePath); + + var pipeline = new DefaultDownloadPipeline(expectedHash); + + try + { + var result = await pipeline.ProcessAsync(filePath); + Assert.Equal(filePath, result); + } + finally { TryDelete(filePath); } + } + + #endregion + + #region Helpers + + private static string ComputeSha256(string path) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + using var fs = File.OpenRead(path); + var hash = sha.ComputeHash(fs); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + private static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } + + #endregion +} diff --git a/tests/CoreTest/Download/DownloadModelsTests.cs b/tests/CoreTest/Download/DownloadModelsTests.cs new file mode 100644 index 00000000..76e6969b --- /dev/null +++ b/tests/CoreTest/Download/DownloadModelsTests.cs @@ -0,0 +1,203 @@ +using GeneralUpdate.Core.Download.Models; + +namespace CoreTest.Download; + +/// +/// AAAT unit tests for , , +/// , record types. +/// Covers: default construction, value equality, Empty plan, HasAssets, all statuses/priorities. +/// +public class DownloadModelsTests +{ + #region DownloadAsset + + [Fact] + public void DownloadAsset_Defaults_AreSensible() + { + var asset = new DownloadAsset("test", "http://url", 100, null, "1.0.0"); + + Assert.Equal("test", asset.Name); + Assert.Equal("http://url", asset.Url); + Assert.Equal(100, asset.Size); + Assert.Null(asset.SHA256); + Assert.Equal("1.0.0", asset.Version); + Assert.Equal(DownloadPriority.Normal, asset.Priority); + Assert.False(asset.IsCrossVersion); + Assert.Null(asset.FromVersion); + Assert.Null(asset.MinClientVersion); + Assert.False(asset.IsForcibly); + Assert.False(asset.IsFreeze); + } + + [Fact] + public void DownloadAsset_FullySpecified_AllPropertiesSet() + { + var asset = new DownloadAsset( + "package.zip", "https://cdn/pkg.zip", 1024, "abc123", "3.0.0", + DownloadPriority.High, true, "1.0.0", "2.0.0", + "srcHash", "tgtHash", true, false + ); + + Assert.Equal("package.zip", asset.Name); + Assert.Equal("https://cdn/pkg.zip", asset.Url); + Assert.Equal(1024, asset.Size); + Assert.Equal("abc123", asset.SHA256); + Assert.Equal("3.0.0", asset.Version); + Assert.Equal(DownloadPriority.High, asset.Priority); + Assert.True(asset.IsCrossVersion); + Assert.Equal("1.0.0", asset.FromVersion); + Assert.Equal("2.0.0", asset.MinClientVersion); + Assert.Equal("srcHash", asset.SourceArchiveHash); + Assert.Equal("tgtHash", asset.TargetArchiveHash); + Assert.True(asset.IsForcibly); + Assert.False(asset.IsFreeze); + } + + [Fact] + public void DownloadAsset_ValueEquality_SamePropsEqual() + { + var a = new DownloadAsset("a", "url", 100, "hash", "1.0", IsForcibly: true); + var b = new DownloadAsset("a", "url", 100, "hash", "1.0", IsForcibly: true); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void DownloadAsset_ValueEquality_DifferentForciblyNotEqual() + { + var a = new DownloadAsset("a", "url", 100, "hash", "1.0", IsForcibly: true); + var b = new DownloadAsset("a", "url", 100, "hash", "1.0", IsForcibly: false); + + Assert.NotEqual(a, b); + } + + #endregion + + #region DownloadPlan + + [Fact] + public void DownloadPlan_Empty_HasNoAssets() + { + var plan = DownloadPlan.Empty; + Assert.False(plan.HasAssets); + Assert.Empty(plan.Assets); + Assert.False(plan.IsForcibly); + } + + [Fact] + public void DownloadPlan_Empty_IsSingleton() + { + var a = DownloadPlan.Empty; + var b = DownloadPlan.Empty; + Assert.Same(a, b); + } + + [Fact] + public void DownloadPlan_WithAssets_HasAssetsTrue() + { + var assets = new List { new("a", "u", 100, null, "2.0") }; + var plan = new DownloadPlan(assets, false); + + Assert.True(plan.HasAssets); + Assert.Single(plan.Assets); + } + + [Fact] + public void DownloadPlan_Empty_IsForciblyFalse() + { + var plan = DownloadPlan.Empty; + Assert.False(plan.IsForcibly); + } + + [Fact] + public void DownloadPlan_ForciblyTrue_Stored() + { + var plan = new DownloadPlan(new List(), true); + Assert.True(plan.IsForcibly); + } + + #endregion + + #region DownloadProgress + + [Fact] + public void DownloadProgress_AllPropertiesAssigned() + { + var dp = new DownloadProgress("asset.zip", 512, 1024, 50.0, DownloadStatus.Downloading); + + Assert.Equal("asset.zip", dp.AssetName); + Assert.Equal(512, dp.BytesDownloaded); + Assert.Equal(1024, dp.TotalBytes); + Assert.Equal(50.0, dp.Percentage); + Assert.Equal(DownloadStatus.Downloading, dp.Status); + } + + [Fact] + public void DownloadProgress_NullAssetName_Works() + { + var dp = new DownloadProgress(null, 100, 200, 50.0, DownloadStatus.Pending); + Assert.Null(dp.AssetName); + } + + [Fact] + public void DownloadProgress_NullTotalBytes_Works() + { + var dp = new DownloadProgress("a.zip", 500, null, 0, DownloadStatus.Downloading); + Assert.Null(dp.TotalBytes); + } + + [Fact] + public void DownloadProgress_AllStatuses_Supported() + { + foreach (DownloadStatus status in Enum.GetValues()) + { + var dp = new DownloadProgress("a", 0, 0, 0, status); + Assert.Equal(status, dp.Status); + } + } + + #endregion + + #region DownloadResult + + [Fact] + public void DownloadResult_Success_AllFieldsSet() + { + var asset = new DownloadAsset("pkg.zip", "http://u", 500, "hash", "2.0"); + var result = new DownloadResult(asset, "/local/pkg.zip", 500, + TimeSpan.FromSeconds(5), 1, true, null); + + Assert.Same(asset, result.Asset); + Assert.Equal("/local/pkg.zip", result.LocalPath); + Assert.Equal(500, result.DownloadedBytes); + Assert.Equal(TimeSpan.FromSeconds(5), result.Duration); + Assert.Equal(1, result.RetryCount); + Assert.True(result.Success); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public void DownloadResult_Failure_HasErrorMessage() + { + var asset = new DownloadAsset("fail.zip", "http://u", 100, null, "1.0"); + var result = new DownloadResult(asset, "", 0, TimeSpan.Zero, 3, false, "Network timeout"); + + Assert.False(result.Success); + Assert.Equal(3, result.RetryCount); + Assert.Equal("Network timeout", result.ErrorMessage); + } + + #endregion + + #region DownloadPriority + + [Fact] + public void DownloadPriority_Ordering_LowLessThanNormal() + { + Assert.True(DownloadPriority.Low < DownloadPriority.Normal); + Assert.True(DownloadPriority.Normal < DownloadPriority.High); + } + + #endregion +} diff --git a/tests/CoreTest/Download/DownloadProgressReporterTests.cs b/tests/CoreTest/Download/DownloadProgressReporterTests.cs new file mode 100644 index 00000000..0c44cda8 --- /dev/null +++ b/tests/CoreTest/Download/DownloadProgressReporterTests.cs @@ -0,0 +1,273 @@ +using GeneralUpdate.Core.Download; +using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.Download.Progress; +using GeneralUpdate.Core.Event; + +namespace CoreTest.Download; + +[Collection("NonParallel_EventManager")] +public class DownloadProgressReporterTests : IDisposable +{ + public void Dispose() + { + EventManager.Instance.Clear(); + } + + #region Progress callback + + [Fact] + public void Report_InvokesOnProgressCallback() + { + DownloadProgress? captured = null; + var reporter = new DownloadProgressReporter(onProgress: p => captured = p); + + var progress = new DownloadProgress("asset.zip", 500, 1000, 50.0, DownloadStatus.Downloading); + reporter.Report(progress); + + Assert.NotNull(captured); + Assert.Equal("asset.zip", captured!.AssetName); + Assert.Equal(500, captured.BytesDownloaded); + Assert.Equal(1000, captured.TotalBytes); + Assert.Equal(50.0, captured.Percentage); + Assert.Equal(DownloadStatus.Downloading, captured.Status); + } + + [Fact] + public void Report_NullProgressCallback_DoesNotThrow() + { + var reporter = new DownloadProgressReporter(onProgress: null); + + var progress = new DownloadProgress("a.zip", 0, 100, 0, DownloadStatus.Pending); + var ex = Record.Exception(() => reporter.Report(progress)); + + Assert.Null(ex); + } + + #endregion + + #region Completed callback + + [Fact] + public void Report_CompletedStatus_InvokesOnCompleted() + { + var completedInvoked = false; + var reporter = new DownloadProgressReporter(onProgress: null, onCompleted: () => completedInvoked = true); + + var progress = new DownloadProgress("done.zip", 1000, 1000, 100.0, DownloadStatus.Completed); + reporter.Report(progress); + + Assert.True(completedInvoked); + } + + [Fact] + public void Report_NonCompletedStatus_DoesNotInvokeOnCompleted() + { + var completedInvoked = false; + var reporter = new DownloadProgressReporter(onProgress: null, onCompleted: () => completedInvoked = true); + + var progress = new DownloadProgress("notdone.zip", 500, 1000, 50.0, DownloadStatus.Downloading); + reporter.Report(progress); + + Assert.False(completedInvoked); + } + + [Fact] + public void Report_CompletedStatus_NullOnCompleted_DoesNotThrow() + { + var reporter = new DownloadProgressReporter(onProgress: null, onCompleted: null); + + var progress = new DownloadProgress("done.zip", 1000, 1000, 100.0, DownloadStatus.Completed); + var ex = Record.Exception(() => reporter.Report(progress)); + + Assert.Null(ex); + } + + [Fact] + public void Report_FailedStatus_DoesNotInvokeOnCompleted() + { + var completedInvoked = false; + var reporter = new DownloadProgressReporter(onProgress: null, onCompleted: () => completedInvoked = true); + + var progress = new DownloadProgress("fail.zip", 0, 100, 0, DownloadStatus.Failed); + reporter.Report(progress); + + Assert.False(completedInvoked); + } + + #endregion + + #region Event dispatch on Completed + + [Fact] + public void Report_CompletedStatus_DispatchesCompletedEvent() + { + MultiDownloadCompletedEventArgs? captured = null; + Action handler = (_, args) => captured = args; + EventManager.Instance.AddListener(handler); + + try + { + var reporter = new DownloadProgressReporter(); + reporter.Report(new DownloadProgress("asset.zip", 1000, 1000, 100.0, DownloadStatus.Completed)); + + Assert.NotNull(captured); + Assert.Equal("asset.zip", captured!.Version); // Version is set to the asset name + Assert.True(captured.IsCompleted); + } + finally + { + EventManager.Instance.RemoveListener(handler); + } + } + + #endregion + + #region Event dispatch on Failed + + [Fact] + public void Report_FailedStatus_DispatchesErrorEvent() + { + MultiDownloadErrorEventArgs? captured = null; + Action handler = (_, args) => captured = args; + EventManager.Instance.AddListener(handler); + + try + { + var reporter = new DownloadProgressReporter(); + reporter.Report(new DownloadProgress("fail.zip", 0, 100, 0, DownloadStatus.Failed)); + + Assert.NotNull(captured); + Assert.Equal("fail.zip", captured!.Version); + Assert.NotNull(captured.Exception); + } + finally + { + EventManager.Instance.RemoveListener(handler); + } + } + + #endregion + + #region DispatchAllCompleted + + [Fact] + public void DispatchAllCompleted_Success_DispatchesEvent() + { + MultiAllDownloadCompletedEventArgs? captured = null; + Action handler = (_, args) => captured = args; + EventManager.Instance.AddListener(handler); + + try + { + DownloadProgressReporter.DispatchAllCompleted(this, true, null!); + + Assert.NotNull(captured); + Assert.True(captured!.IsAllDownloadCompleted); + } + finally + { + EventManager.Instance.RemoveListener(handler); + } + } + + [Fact] + public void DispatchAllCompleted_Failure_EventHasFalseFlag() + { + MultiAllDownloadCompletedEventArgs? captured = null; + Action handler = (_, args) => captured = args; + EventManager.Instance.AddListener(handler); + + try + { + DownloadProgressReporter.DispatchAllCompleted(this, false, new List<(object, string)>()); + + Assert.NotNull(captured); + Assert.False(captured!.IsAllDownloadCompleted); + } + finally + { + EventManager.Instance.RemoveListener(handler); + } + } + + #endregion + + #region CreateEventBridge + + [Fact] + public void CreateEventBridge_ReturnsNonNull() + { + var progress = DownloadProgressReporter.CreateEventBridge(); + Assert.NotNull(progress); + Assert.IsAssignableFrom>(progress); + } + + [Fact] + public void CreateEventBridge_Reporting_DoesNotThrow() + { + var progress = DownloadProgressReporter.CreateEventBridge(); + + var dp = new DownloadProgress("bridge.zip", 500, 1000, 50.0, DownloadStatus.Downloading); + var ex = Record.Exception(() => progress.Report(dp)); + + Assert.Null(ex); + } + + #endregion + + #region All download statuses + + [Theory] + [InlineData(DownloadStatus.Pending)] + [InlineData(DownloadStatus.Downloading)] + [InlineData(DownloadStatus.Retrying)] + public void Report_NonTerminalStatuses_ReportProgress(DownloadStatus status) + { + DownloadProgress? captured = null; + var reporter = new DownloadProgressReporter(onProgress: p => captured = p); + + var progress = new DownloadProgress("a.zip", 100, 1000, 10.0, status); + reporter.Report(progress); + + Assert.NotNull(captured); + Assert.Equal(status, captured!.Status); + } + + #endregion + + #region AssetName null/empty cases + + [Fact] + public void Report_AssetNameNull_DoesNotThrow() + { + var reporter = new DownloadProgressReporter(); + var progress = new DownloadProgress(null, 0, null, 0, DownloadStatus.Pending); + + var ex = Record.Exception(() => reporter.Report(progress)); + + Assert.Null(ex); + } + + [Fact] + public void Report_AssetNameNull_Completed_UsesUnknown() + { + MultiDownloadCompletedEventArgs? captured = null; + Action handler = (_, args) => captured = args; + EventManager.Instance.AddListener(handler); + + try + { + var reporter = new DownloadProgressReporter(); + reporter.Report(new DownloadProgress(null, 100, 100, 100.0, DownloadStatus.Completed)); + + Assert.NotNull(captured); + Assert.Equal("unknown", captured!.Version); + } + finally + { + EventManager.Instance.RemoveListener(handler); + } + } + + #endregion +} diff --git a/tests/CoreTest/Download/PacketDTOTests.cs b/tests/CoreTest/Download/PacketDTOTests.cs new file mode 100644 index 00000000..7b1f6781 --- /dev/null +++ b/tests/CoreTest/Download/PacketDTOTests.cs @@ -0,0 +1,158 @@ +namespace CoreTest.Download; + +using GeneralUpdate.Core.Download.Abstractions; + +/// +/// AAAT unit tests for and related DTO records. +/// Covers: default values, full assignment, nullable properties, VersionRequest, VersionResponse. +/// +public class PacketDTOTests +{ + #region PacketDTO + + [Fact] + public void PacketDTO_Default_AllNullablePropsAreNull() + { + var dto = new PacketDTO(); + + Assert.Null(dto.Name); + Assert.Null(dto.Hash); + Assert.Null(dto.ReleaseDate); + Assert.Null(dto.Url); + Assert.Null(dto.Version); + Assert.Null(dto.AppType); + Assert.Null(dto.Platform); + Assert.Null(dto.ProductId); + Assert.Null(dto.IsForcibly); + Assert.Null(dto.IsFreeze); + Assert.Null(dto.Format); + Assert.Null(dto.Size); + Assert.Null(dto.FromVersion); + Assert.Null(dto.IsCrossVersion); + Assert.Null(dto.MinClientVersion); + Assert.Null(dto.SourceArchiveHash); + Assert.Null(dto.TargetArchiveHash); + } + + [Fact] + public void PacketDTO_FullAssignment_AllPropsSet() + { + var dto = new PacketDTO + { + Name = "UpdatePack", + Hash = "hash123", + ReleaseDate = new DateTime(2025, 3, 15), + Url = "https://cdn.example.com/pack.zip", + Version = "2.0.0", + AppType = 1, + Platform = 0, + ProductId = "prod-1", + IsForcibly = true, + IsFreeze = false, + Format = ".zip", + Size = 2048, + FromVersion = "1.0.0", + IsCrossVersion = true, + MinClientVersion = "1.5.0", + SourceArchiveHash = "srcHash", + TargetArchiveHash = "tgtHash" + }; + + Assert.Equal("UpdatePack", dto.Name); + Assert.Equal("hash123", dto.Hash); + Assert.Equal(new DateTime(2025, 3, 15), dto.ReleaseDate); + Assert.Equal(".zip", dto.Format); + Assert.Equal(2048, dto.Size); + Assert.Equal("1.0.0", dto.FromVersion); + Assert.True(dto.IsCrossVersion); + Assert.Equal("1.5.0", dto.MinClientVersion); + Assert.Equal("srcHash", dto.SourceArchiveHash); + Assert.Equal("tgtHash", dto.TargetArchiveHash); + } + + [Fact] + public void PacketDTO_IsForcibly_NullableTriState() + { + var dto = new PacketDTO(); + Assert.Null(dto.IsForcibly); + + dto.IsForcibly = true; + Assert.True(dto.IsForcibly); + + dto.IsForcibly = null; + Assert.Null(dto.IsForcibly); + } + + [Fact] + public void PacketDTO_IsFreeze_NullableTriState() + { + var dto = new PacketDTO(); + Assert.Null(dto.IsFreeze); + + dto.IsFreeze = false; + Assert.False(dto.IsFreeze); + + dto.IsFreeze = null; + Assert.Null(dto.IsFreeze); + } + + #endregion + + #region VersionRequest + + [Fact] + public void VersionRequest_AllFieldsAssigned() + { + var req = new VersionRequest("MyApp", "1.0.0", "2.0.0", 1, "prod-001"); + + Assert.Equal("MyApp", req.AppName); + Assert.Equal("1.0.0", req.ClientVersion); + Assert.Equal("2.0.0", req.UpgradeClientVersion); + Assert.Equal(1, req.Platform); + Assert.Equal("prod-001", req.ProductId); + } + + [Fact] + public void VersionRequest_NullableFields_CanBeNull() + { + var req = new VersionRequest("App", "1.0", null, null, null); + + Assert.Null(req.UpgradeClientVersion); + Assert.Null(req.Platform); + Assert.Null(req.ProductId); + } + + #endregion + + #region VersionResponse + + [Fact] + public void VersionResponse_NoUpdate_EmptyPackets() + { + var resp = new VersionResponse(false, null); + + Assert.False(resp.HasUpdate); + Assert.Null(resp.Packets); + } + + [Fact] + public void VersionResponse_HasUpdate_WithPackets() + { + var packets = new List { new() { Name = "p1" }, new() { Name = "p2" } }; + var resp = new VersionResponse(true, packets); + + Assert.True(resp.HasUpdate); + Assert.Equal(2, resp.Packets!.Count); + } + + [Fact] + public void VersionResponse_HasUpdateTrue_ButNullPackets_Works() + { + var resp = new VersionResponse(true, null); + + Assert.True(resp.HasUpdate); + Assert.Null(resp.Packets); + } + + #endregion +} diff --git a/tests/CoreTest/FileSystem/BlackListDefaultsTests.cs b/tests/CoreTest/FileSystem/BlackListDefaultsTests.cs new file mode 100644 index 00000000..033e866e --- /dev/null +++ b/tests/CoreTest/FileSystem/BlackListDefaultsTests.cs @@ -0,0 +1,91 @@ +namespace CoreTest.FileSystem; + +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.FileSystem; + +/// +/// AAAT unit tests for — static default values. +/// Covers: DefaultBlackFiles content, DefaultBlackFormats content (incl. Format.ZIP), +/// DefaultSkipDirectories content, list mutability. +/// +public class BlackListDefaultsTests +{ + [Fact] + public void DefaultBlackFiles_ContainsRequiredRuntimeDlls() + { + var files = BlackListDefaults.DefaultBlackFiles; + + Assert.Contains("Microsoft.Bcl.AsyncInterfaces.dll", files); + Assert.Contains("System.Collections.Immutable.dll", files); + Assert.Contains("System.IO.Pipelines.dll", files); + Assert.Contains("System.Text.Encodings.Web.dll", files); + Assert.Contains("System.Text.Json.dll", files); + } + + [Fact] + public void DefaultBlackFiles_HasFiveEntries() + { + Assert.Equal(5, BlackListDefaults.DefaultBlackFiles.Count); + } + + [Fact] + public void DefaultBlackFormats_ContainsPatchPdbRarTarJsonZip() + { + var formats = BlackListDefaults.DefaultBlackFormats; + + Assert.Contains(".patch", formats); + Assert.Contains(".pdb", formats); + Assert.Contains(".rar", formats); + Assert.Contains(".tar", formats); + Assert.Contains(".json", formats); + Assert.Contains(Format.ZIP, formats); + } + + [Fact] + public void DefaultBlackFormats_HasSixEntries() + { + Assert.Equal(6, BlackListDefaults.DefaultBlackFormats.Count); + } + + [Fact] + public void DefaultSkipDirectories_ContainsAppPrefixAndFail() + { + var dirs = BlackListDefaults.DefaultSkipDirectories; + + Assert.Contains("app-", dirs); + Assert.Contains("fail", dirs); + } + + [Fact] + public void DefaultSkipDirectories_HasTwoEntries() + { + Assert.Equal(2, BlackListDefaults.DefaultSkipDirectories.Count); + } + + [Fact] + public void DefaultBlackFiles_IsSameInstance_OnRepeatedAccess() + { + var a = BlackListDefaults.DefaultBlackFiles; + var b = BlackListDefaults.DefaultBlackFiles; + + Assert.Same(a, b); + } + + [Fact] + public void DefaultBlackFormats_IsSameInstance_OnRepeatedAccess() + { + var a = BlackListDefaults.DefaultBlackFormats; + var b = BlackListDefaults.DefaultBlackFormats; + + Assert.Same(a, b); + } + + [Fact] + public void DefaultSkipDirectories_IsSameInstance_OnRepeatedAccess() + { + var a = BlackListDefaults.DefaultSkipDirectories; + var b = BlackListDefaults.DefaultSkipDirectories; + + Assert.Same(a, b); + } +} diff --git a/tests/CoreTest/FileSystem/FileTreeDifferExtendedTests.cs b/tests/CoreTest/FileSystem/FileTreeDifferExtendedTests.cs new file mode 100644 index 00000000..06a96d4a --- /dev/null +++ b/tests/CoreTest/FileSystem/FileTreeDifferExtendedTests.cs @@ -0,0 +1,304 @@ +namespace CoreTest.FileSystem; + +using GeneralUpdate.Core.FileSystem; + +/// +/// AAAT unit tests for — exhaustive branch coverage. +/// Covers: ProduceDeltaPaths (added, modified, deleted, non-existent, mixed), ProduceDeletes, +/// ShouldUseDeltaPatching (zero total, threshold boundary, exact threshold, large diff, empty diff). +/// +public class FileTreeDifferExtendedTests +{ + private static FileEntry Entry(string path, long size = 100) + => new(path, size, DateTime.UtcNow); + + private static FileTreeDiff Diff( + FileEntry[]? added = null, + FileEntry[]? modified = null, + string[]? deleted = null) + => new( + added ?? Array.Empty(), + modified ?? Array.Empty(), + deleted ?? Array.Empty()); + + #region ProduceDeltaPaths — Added Files + + [Fact] + public void ProduceDeltaPaths_AddedFileExists_ReturnsIt() + { + var filePath = Path.GetTempFileName(); + try + { + var fileName = Path.GetFileName(filePath); + var root = Path.GetDirectoryName(filePath)!; + var diff = Diff(added: new[] { Entry(fileName) }); + + var paths = FileTreeDiffer.ProduceDeltaPaths(diff, root); + + Assert.Single(paths); + Assert.Equal(fileName, paths[0].RelativePath); + } + finally { TryDelete(filePath); } + } + + [Fact] + public void ProduceDeltaPaths_AddedFileDoesNotExist_Skipped() + { + var diff = Diff(added: new[] { Entry("nonexistent_added.txt") }); + + var paths = FileTreeDiffer.ProduceDeltaPaths(diff, Path.GetTempPath()); + + Assert.Empty(paths); + } + + [Fact] + public void ProduceDeltaPaths_MultipleAddedFiles_ExistingOnesReturned() + { + var file1 = Path.GetTempFileName(); + var file2 = Path.GetTempFileName(); + try + { + var root = Path.GetDirectoryName(file1)!; + var diff = Diff(added: new[] + { + Entry(Path.GetFileName(file1)), + Entry(Path.GetFileName(file2)), + Entry("missing_added.txt") + }); + + var paths = FileTreeDiffer.ProduceDeltaPaths(diff, root); + + Assert.Equal(2, paths.Count); + } + finally { TryDelete(file1); TryDelete(file2); } + } + + #endregion + + #region ProduceDeltaPaths — Modified Files + + [Fact] + public void ProduceDeltaPaths_ModifiedFileExists_ReturnsIt() + { + var filePath = Path.GetTempFileName(); + try + { + var fileName = Path.GetFileName(filePath); + var root = Path.GetDirectoryName(filePath)!; + var diff = Diff(modified: new[] { Entry(fileName, 200) }); + + var paths = FileTreeDiffer.ProduceDeltaPaths(diff, root); + + Assert.Single(paths); + Assert.Equal(fileName, paths[0].RelativePath); + } + finally { TryDelete(filePath); } + } + + [Fact] + public void ProduceDeltaPaths_ModifiedFileDoesNotExist_Skipped() + { + var diff = Diff(modified: new[] { Entry("nonexistent_modified.txt") }); + + var paths = FileTreeDiffer.ProduceDeltaPaths(diff, Path.GetTempPath()); + + Assert.Empty(paths); + } + + #endregion + + #region ProduceDeltaPaths — Mixed (Added + Modified + Deleted) + + [Fact] + public void ProduceDeltaPaths_MixedChanged_FiltersDeleted() + { + var file1 = Path.GetTempFileName(); + try + { + var root = Path.GetDirectoryName(file1)!; + var diff = Diff( + added: new[] { Entry(Path.GetFileName(file1)) }, + modified: new[] { Entry("missing_mod.txt") }, + deleted: new[] { "deleted_file.txt" } + ); + + var paths = FileTreeDiffer.ProduceDeltaPaths(diff, root); + + // Only the existing added file — missing modified + deleted excluded + Assert.Single(paths); + Assert.Equal(Path.GetFileName(file1), paths[0].RelativePath); + } + finally { TryDelete(file1); } + } + + #endregion + + #region ProduceDeltaPaths — Empty diff + + [Fact] + public void ProduceDeltaPaths_AllEmptyArrays_ReturnsEmpty() + { + var diff = Diff(); + + var paths = FileTreeDiffer.ProduceDeltaPaths(diff, Path.GetTempPath()); + + Assert.Empty(paths); + } + + [Fact] + public void ProduceDeltaPaths_OnlyDeletes_ReturnsEmpty() + { + var diff = Diff(deleted: new[] { "a.txt", "b.txt" }); + + var paths = FileTreeDiffer.ProduceDeltaPaths(diff, Path.GetTempPath()); + + Assert.Empty(paths); + } + + #endregion + + #region ProduceDeletes + + [Fact] + public void ProduceDeletes_WithDeletes_ReturnsThem() + { + var diff = Diff(deleted: new[] { "remove/a.txt", "remove/b.dll" }); + + var deletes = FileTreeDiffer.ProduceDeletes(diff); + + Assert.Equal(2, deletes.Count); + Assert.Contains("remove/a.txt", deletes); + Assert.Contains("remove/b.dll", deletes); + } + + [Fact] + public void ProduceDeletes_EmptyDeletes_ReturnsEmpty() + { + var diff = Diff(); + + var deletes = FileTreeDiffer.ProduceDeletes(diff); + + Assert.Empty(deletes); + } + + [Fact] + public void ProduceDeletes_ReturnsDeletedItems() + { + var deleted = new[] { "x.txt" }; + var diff = Diff(deleted: deleted); + + var result = FileTreeDiffer.ProduceDeletes(diff); + + Assert.Single(result); + Assert.Equal("x.txt", result[0]); + } + + #endregion + + #region ShouldUseDeltaPatching + + [Fact] + public void ShouldUseDeltaPatching_ZeroTotalFiles_ReturnsFalse() + { + var diff = Diff(added: new[] { Entry("a.txt") }); + Assert.False(FileTreeDiffer.ShouldUseDeltaPatching(diff, 0)); + } + + [Fact] + public void ShouldUseDeltaPatching_BelowThreshold_ReturnsTrue() + { + var diff = Diff(added: new[] { Entry("a.txt") }); // 1 change + + Assert.True(FileTreeDiffer.ShouldUseDeltaPatching(diff, totalFileCount: 100)); + } + + [Fact] + public void ShouldUseDeltaPatching_ExactlyAtDefaultThreshold_ReturnsTrue() + { + // 50% threshold: 50 changes / 100 files = 50% <= 50% => true + var added = Enumerable.Range(0, 50).Select(i => Entry($"file_{i}.txt")).ToArray(); + var diff = Diff(added: added); + + Assert.True(FileTreeDiffer.ShouldUseDeltaPatching(diff, 100)); + } + + [Fact] + public void ShouldUseDeltaPatching_JustAboveDefaultThreshold_ReturnsFalse() + { + // 51/100 > 50% => false + var added = Enumerable.Range(0, 51).Select(i => Entry($"file_{i}.txt")).ToArray(); + var diff = Diff(added: added); + + Assert.False(FileTreeDiffer.ShouldUseDeltaPatching(diff, 100)); + } + + [Fact] + public void ShouldUseDeltaPatching_CustomThreshold_Below_ReturnsTrue() + { + var diff = Diff(added: new[] { Entry("a.txt") }); // 1/10 = 10% + + Assert.True(FileTreeDiffer.ShouldUseDeltaPatching(diff, 10, 0.3)); + } + + [Fact] + public void ShouldUseDeltaPatching_CustomThreshold_Above_ReturnsFalse() + { + // 4/10 = 40% > 30% + var added = Enumerable.Range(0, 4).Select(i => Entry($"f{i}.txt")).ToArray(); + var diff = Diff(added: added); + + Assert.False(FileTreeDiffer.ShouldUseDeltaPatching(diff, 10, 0.3)); + } + + [Fact] + public void ShouldUseDeltaPatching_AllFilesChanged_ReturnsFalse() + { + // 100/100 = 100% + var added = Enumerable.Range(0, 100).Select(i => Entry($"f{i}.txt")).ToArray(); + var diff = Diff(added: added); + + Assert.False(FileTreeDiffer.ShouldUseDeltaPatching(diff, 100)); + } + + [Fact] + public void ShouldUseDeltaPatching_ZeroChanges_ReturnsTrue() + { + var diff = Diff(); + Assert.True(FileTreeDiffer.ShouldUseDeltaPatching(diff, 100)); + } + + [Fact] + public void ShouldUseDeltaPatching_ZeroChangesZeroFiles_ReturnsFalse() + { + var diff = Diff(); + Assert.False(FileTreeDiffer.ShouldUseDeltaPatching(diff, 0)); + } + + [Fact] + public void ShouldUseDeltaPatching_ThresholdZero_Below_ReturnsFalse() + { + // 0% threshold — any change should return false + var diff = Diff(added: new[] { Entry("a.txt") }); + Assert.False(FileTreeDiffer.ShouldUseDeltaPatching(diff, 100, 0.0)); + } + + [Fact] + public void ShouldUseDeltaPatching_ThresholdOne_AlwaysTrue() + { + var added = Enumerable.Range(0, 999).Select(i => Entry($"f{i}.txt")).ToArray(); + var diff = Diff(added: added); + + Assert.True(FileTreeDiffer.ShouldUseDeltaPatching(diff, 1000, 1.0)); + } + + #endregion + + #region Helpers + + private static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } + + #endregion +} diff --git a/tests/CoreTest/FileSystem/FileTreeSnapshotExtendedTests.cs b/tests/CoreTest/FileSystem/FileTreeSnapshotExtendedTests.cs new file mode 100644 index 00000000..cb83ea73 --- /dev/null +++ b/tests/CoreTest/FileSystem/FileTreeSnapshotExtendedTests.cs @@ -0,0 +1,216 @@ +namespace CoreTest.FileSystem; + +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.FileSystem; + +/// +/// AAAT unit tests for — additional edge case coverage. +/// Covers: null args, empty entries, root path normalization, CreatedAt timestamp, FromEnumerator with empty dir. +/// +public class FileTreeSnapshotExtendedTests +{ + #region Constructor edge cases + + [Fact] + public void Ctor_NullRootPath_ThrowsArgumentNullException() + { + Assert.Throws(() => + new FileTreeSnapshot(null!, Array.Empty())); + } + + [Fact] + public void Ctor_NullEntries_TreatedAsEmpty() + { + var snapshot = new FileTreeSnapshot("/root", null!); + + Assert.NotNull(snapshot.Entries); + Assert.Empty(snapshot.Entries); + } + + [Fact] + public void Ctor_EmptyEntries_Works() + { + var snapshot = new FileTreeSnapshot("/root", Array.Empty()); + + Assert.Empty(snapshot.Entries); + Assert.Equal("/root", snapshot.RootPath); + } + + [Fact] + public void Ctor_RootPathWithTrailingSlash_Preserved() + { + var root = "C:\\test\\"; + var snapshot = new FileTreeSnapshot(root, Array.Empty()); + + Assert.Equal(root, snapshot.RootPath); + } + + [Fact] + public void CreatedAt_IsAroundNow() + { + var before = DateTime.UtcNow; + var snapshot = new FileTreeSnapshot("/root", Array.Empty()); + var after = DateTime.UtcNow; + + Assert.True(snapshot.CreatedAt >= before && snapshot.CreatedAt <= after); + } + + #endregion + + #region Empty static factory + + [Fact] + public void Empty_ReturnsSnapshotWithEmptyEntries() + { + var snapshot = FileTreeSnapshot.Empty("/some/root"); + + Assert.Empty(snapshot.Entries); + Assert.Equal("/some/root", snapshot.RootPath); + } + + #endregion + + #region FromEnumerator + + [Fact] + public void FromEnumerator_EmptyDirectory_ReturnsEmptyEntries() + { + var safeDir = "GenUpdSnapEmpty_" + Path.GetRandomFileName(); + var rootPath = Path.Combine(Path.GetTempPath(), safeDir); + Directory.CreateDirectory(rootPath); + try + { + var config = BlackListConfig.Empty; + var enumerator = FileTreeEnumerator.FromConfig(config); + var snapshot = FileTreeSnapshot.FromEnumerator(rootPath, enumerator); + + Assert.Equal(rootPath, snapshot.RootPath); + Assert.NotNull(snapshot.Entries); + Assert.Empty(snapshot.Entries); + } + finally + { + try { Directory.Delete(rootPath, false); } catch { } + } + } + + [Fact] + public void FromEnumerator_MultipleFiles_ReturnsAll() + { + var safeDir = "GenUpdSnapMulti_" + Path.GetRandomFileName(); + var rootPath = Path.Combine(Path.GetTempPath(), safeDir); + Directory.CreateDirectory(rootPath); + try + { + File.WriteAllText(Path.Combine(rootPath, "a.txt"), "a"); + File.WriteAllText(Path.Combine(rootPath, "b.txt"), "bb"); + File.WriteAllText(Path.Combine(rootPath, "c.txt"), "ccc"); + + var config = BlackListConfig.Empty; + var enumerator = FileTreeEnumerator.FromConfig(config); + var snapshot = FileTreeSnapshot.FromEnumerator(rootPath, enumerator); + + Assert.Equal(3, snapshot.Entries.Count); + Assert.All(snapshot.Entries, e => + { + Assert.StartsWith(rootPath, Path.Combine(rootPath, e.RelativePath)); + Assert.True(e.Size > 0); + Assert.True(e.LastWriteTimeUtc <= DateTime.UtcNow); + }); + } + finally + { + try + { + foreach (var f in Directory.GetFiles(rootPath)) + { + File.SetAttributes(f, FileAttributes.Normal); + File.Delete(f); + } + Directory.Delete(rootPath, false); + } + catch { } + } + } + + [Fact] + public void FromEnumerator_Subdirectories_EnumeratedRecursively() + { + var safeDir = "GenUpdSnapSub_" + Path.GetRandomFileName(); + var rootPath = Path.Combine(Path.GetTempPath(), safeDir); + Directory.CreateDirectory(rootPath); + var subDir = Path.Combine(rootPath, "sub"); + Directory.CreateDirectory(subDir); + try + { + File.WriteAllText(Path.Combine(rootPath, "root.txt"), "r"); + File.WriteAllText(Path.Combine(subDir, "sub.txt"), "s"); + + var config = BlackListConfig.Empty; + var enumerator = FileTreeEnumerator.FromConfig(config); + var snapshot = FileTreeSnapshot.FromEnumerator(rootPath, enumerator); + + Assert.Equal(2, snapshot.Entries.Count); + + // Verify relative paths use separator + var relativePaths = snapshot.Entries.Select(e => e.RelativePath).ToArray(); + Assert.Contains(relativePaths, p => p.Contains("root.txt")); + Assert.Contains(relativePaths, p => p.Contains("sub") && p.Contains("sub.txt")); + } + finally + { + try + { + foreach (var f in Directory.GetFiles(subDir)) + { + File.SetAttributes(f, FileAttributes.Normal); + File.Delete(f); + } + Directory.Delete(subDir, false); + foreach (var f in Directory.GetFiles(rootPath)) + { + File.SetAttributes(f, FileAttributes.Normal); + File.Delete(f); + } + Directory.Delete(rootPath, false); + } + catch { } + } + } + + #endregion + + #region FileEntry struct + + [Fact] + public void FileEntry_ValueEquality_SamePropsEqual() + { + var time = DateTime.UtcNow; + var a = new FileEntry("path/file.txt", 100, time); + var b = new FileEntry("path/file.txt", 100, time); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void FileEntry_ValueEquality_DifferentSizeNotEqual() + { + var time = DateTime.UtcNow; + var a = new FileEntry("f.txt", 100, time); + var b = new FileEntry("f.txt", 200, time); + + Assert.NotEqual(a, b); + } + + [Fact] + public void FileEntry_ToString_ContainsPath() + { + var entry = new FileEntry("sub/myfile.dll", 2048, DateTime.UtcNow); + var str = entry.ToString(); + + Assert.Contains("sub/myfile.dll", str); + } + + #endregion +} diff --git a/tests/CoreTest/Pipeline/PatchMiddlewareTests.cs b/tests/CoreTest/Pipeline/PatchMiddlewareTests.cs new file mode 100644 index 00000000..bf5630e2 --- /dev/null +++ b/tests/CoreTest/Pipeline/PatchMiddlewareTests.cs @@ -0,0 +1,143 @@ +using GeneralUpdate.Core.Differential; +using GeneralUpdate.Core.Pipeline; + +namespace CoreTest.Pipeline; + +/// +/// AAAT unit tests for . +/// Covers: null differ (skip), non-null differ (invoke), success path, exception propagation, both constructors. +/// +public class PatchMiddlewareTests +{ + private sealed class StubDiffer : IBinaryDiffer + { + public bool Invoked { get; private set; } + public bool ShouldThrow { get; set; } + + public Task CleanAsync( + string oldFilePath, string newFilePath, string patchFilePath, + CancellationToken cancellationToken = default) + { + Invoked = true; + if (ShouldThrow) + throw new InvalidOperationException("test differ failure"); + return Task.CompletedTask; + } + + public Task DirtyAsync( + string oldFilePath, string newFilePath, string patchFilePath, + CancellationToken cancellationToken = default) + { + Invoked = true; + if (ShouldThrow) + throw new InvalidOperationException("test differ failure"); + return Task.CompletedTask; + } + } + + #region Parameterless constructor — null differ (skip) + + [Fact] + public async Task InvokeAsync_NullDiffer_SkipsWithoutThrow() + { + var middleware = new PatchMiddleware(); // paramless ctor = no differ + var context = new PipelineContext(); + context.Add("SourcePath", "/src/path"); + context.Add("PatchPath", "/patch/path"); + + var ex = await Record.ExceptionAsync(() => middleware.InvokeAsync(context)); + + Assert.Null(ex); + } + + [Fact] + public async Task InvokeAsync_NullContextProperties_SkipsWithoutThrow() + { + var middleware = new PatchMiddleware(); + var context = new PipelineContext(); + + var ex = await Record.ExceptionAsync(() => middleware.InvokeAsync(context)); + + Assert.Null(ex); + } + + #endregion + + #region Explicit null differ — also skip + + [Fact] + public async Task InvokeAsync_ExplicitNullDiffer_SkipsWithoutThrow() + { + var middleware = new PatchMiddleware(differ: null!); + var context = new PipelineContext(); + context.Add("SourcePath", "/src"); + context.Add("PatchPath", "/patch"); + + var ex = await Record.ExceptionAsync(() => middleware.InvokeAsync(context)); + + Assert.Null(ex); + } + + #endregion + + #region Non-null differ — invokes + + [Fact] + public async Task InvokeAsync_ValidDiffer_InvokesDirtyAsync() + { + var differ = new StubDiffer(); + var middleware = new PatchMiddleware(differ); + var context = new PipelineContext(); + context.Add("SourcePath", "/src/a.txt"); + context.Add("PatchPath", "/patch/a.txt"); + + await middleware.InvokeAsync(context); + + Assert.True(differ.Invoked); + } + + [Fact] + public async Task InvokeAsync_DifferThrows_ExceptionPropagates() + { + var differ = new StubDiffer { ShouldThrow = true }; + var middleware = new PatchMiddleware(differ); + var context = new PipelineContext(); + context.Add("SourcePath", "/src"); + context.Add("PatchPath", "/patch"); + + await Assert.ThrowsAsync(() => middleware.InvokeAsync(context)); + } + + #endregion + + #region PipelineContext values edge cases + + [Fact] + public async Task InvokeAsync_ValidDiffer_WithNullPaths_InvokesStill() + { + var differ = new StubDiffer(); + var middleware = new PatchMiddleware(differ); + var context = new PipelineContext(); + + // SourcePath/PatchPath are null in context — differ is still called with null args + await middleware.InvokeAsync(context); + + Assert.True(differ.Invoked); + } + + [Fact] + public async Task InvokeAsync_ValidDiffer_EmptyStringPaths_InvokesStill() + { + var differ = new StubDiffer(); + var middleware = new PatchMiddleware(differ); + var context = new PipelineContext(); + context.Add("SourcePath", string.Empty); + context.Add("PatchPath", string.Empty); + + await middleware.InvokeAsync(context); + + Assert.True(differ.Invoked); + } + + #endregion +} diff --git a/tests/CoreTest/Shared/NonParallelCollection.cs b/tests/CoreTest/Shared/NonParallelCollection.cs new file mode 100644 index 00000000..f21bd6f6 --- /dev/null +++ b/tests/CoreTest/Shared/NonParallelCollection.cs @@ -0,0 +1,2 @@ +[CollectionDefinition("NonParallel_EventManager", DisableParallelization = true)] +public class NonParallelEventManagerCollection { } diff --git a/tests/CoreTest/Tracer/GeneralTracerTests.cs b/tests/CoreTest/Tracer/GeneralTracerTests.cs new file mode 100644 index 00000000..91612a85 --- /dev/null +++ b/tests/CoreTest/Tracer/GeneralTracerTests.cs @@ -0,0 +1,234 @@ +using GeneralUpdate.Core; + +namespace CoreTest.Tracer; + +[Collection("NonParallel_EventManager")] +public class GeneralTracerTests : IDisposable +{ + private readonly bool _originalTracingEnabled; + + public GeneralTracerTests() + { + _originalTracingEnabled = GeneralTracer.IsTracingEnabled(); + } + + public void Dispose() + { + GeneralTracer.SetTracingEnabled(_originalTracingEnabled); + GC.SuppressFinalize(this); + } + + #region Log level methods — no-throw guarantee + + [Fact] + public void Debug_Enabled_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => GeneralTracer.Debug("test debug message")); + Assert.Null(ex); + } + + [Fact] + public void Info_Enabled_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => GeneralTracer.Info("test info message")); + Assert.Null(ex); + } + + [Fact] + public void Warn_Enabled_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => GeneralTracer.Warn("test warn message")); + Assert.Null(ex); + } + + [Fact] + public void Error_Enabled_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => GeneralTracer.Error("test error message")); + Assert.Null(ex); + } + + [Fact] + public void Fatal_Enabled_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => GeneralTracer.Fatal("test fatal message")); + Assert.Null(ex); + } + + #endregion + + #region With Exception overloads + + [Fact] + public void Error_WithException_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => + GeneralTracer.Error("error with exception", new InvalidOperationException("test"))); + Assert.Null(ex); + } + + [Fact] + public void Fatal_WithException_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => + GeneralTracer.Fatal("fatal with exception", new OutOfMemoryException("mock"))); + Assert.Null(ex); + } + + [Fact] + public void Error_WithNullException_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => GeneralTracer.Error("error", null!)); + Assert.Null(ex); + } + + [Fact] + public void Fatal_WithNullException_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => GeneralTracer.Fatal("fatal", null!)); + Assert.Null(ex); + } + + #endregion + + #region When tracing disabled — all methods are no-ops + + [Theory] + [InlineData("Debug")] + [InlineData("Info")] + [InlineData("Warn")] + [InlineData("Error")] + [InlineData("Fatal")] + public void AllLogMethods_WhenDisabled_DoNotThrow(string method) + { + GeneralTracer.SetTracingEnabled(false); + + Exception? ex = method switch + { + "Debug" => Record.Exception(() => GeneralTracer.Debug("msg")), + "Info" => Record.Exception(() => GeneralTracer.Info("msg")), + "Warn" => Record.Exception(() => GeneralTracer.Warn("msg")), + "Error" => Record.Exception(() => GeneralTracer.Error("msg")), + "Fatal" => Record.Exception(() => GeneralTracer.Fatal("msg")), + _ => null + }; + Assert.Null(ex); + } + + [Fact] + public void ErrorWithException_WhenDisabled_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(false); + var ex = Record.Exception(() => + GeneralTracer.Error("err", new Exception("test"))); + Assert.Null(ex); + } + + #endregion + + #region Toggle tracing + + [Fact] + public void SetTracingEnabled_True_IsTracingEnabledTrue() + { + GeneralTracer.SetTracingEnabled(true); + Assert.True(GeneralTracer.IsTracingEnabled()); + } + + [Fact] + public void SetTracingEnabled_False_IsTracingEnabledFalse() + { + GeneralTracer.SetTracingEnabled(false); + Assert.False(GeneralTracer.IsTracingEnabled()); + } + + [Fact] + public void SetTracingEnabled_MultipleToggles_Works() + { + GeneralTracer.SetTracingEnabled(true); + Assert.True(GeneralTracer.IsTracingEnabled()); + + GeneralTracer.SetTracingEnabled(false); + Assert.False(GeneralTracer.IsTracingEnabled()); + + GeneralTracer.SetTracingEnabled(true); + Assert.True(GeneralTracer.IsTracingEnabled()); + } + + #endregion + + #region Dispose + + [Fact] + public void Dispose_DoesNotThrow() + { + var ex = Record.Exception(() => GeneralTracer.Dispose()); + Assert.Null(ex); + } + + [Fact] + public void Dispose_MultipleCalls_DoesNotThrow() + { + GeneralTracer.Dispose(); + var ex = Record.Exception(() => GeneralTracer.Dispose()); + Assert.Null(ex); + } + + [Fact] + public void Dispose_ThenLog_DoesNotThrow() + { + GeneralTracer.Dispose(); + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => GeneralTracer.Info("after dispose")); + Assert.Null(ex); + } + + #endregion + + #region Message edge cases + + [Fact] + public void Debug_EmptyString_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => GeneralTracer.Debug(string.Empty)); + Assert.Null(ex); + } + + [Fact] + public void Info_LongMessage_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var longMsg = new string('A', 50000); + var ex = Record.Exception(() => GeneralTracer.Info(longMsg)); + Assert.Null(ex); + } + + [Fact] + public void Error_SpecialCharacters_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => + GeneralTracer.Error("Error: { } [ ] \\ / \r\n \t <>& \" '")); + Assert.Null(ex); + } + + [Fact] + public void Debug_NullMessage_DoesNotThrow() + { + GeneralTracer.SetTracingEnabled(true); + var ex = Record.Exception(() => GeneralTracer.Debug(null!)); + Assert.Null(ex); + } + + #endregion +} diff --git a/tests/CoreTest/Tracer/TextTraceListenerTests.cs b/tests/CoreTest/Tracer/TextTraceListenerTests.cs new file mode 100644 index 00000000..a25646d4 --- /dev/null +++ b/tests/CoreTest/Tracer/TextTraceListenerTests.cs @@ -0,0 +1,216 @@ +namespace CoreTest.Tracer; + +/// +/// AAAT unit tests for . +/// Covers: construction, Write, WriteLine, queue behavior, disposal lifecycle, double-dispose, write-after-dispose. +/// +public class TextTraceListenerTests : IDisposable +{ + private readonly string _logFilePath; + private TextTraceListener? _listener; + + public TextTraceListenerTests() + { + _logFilePath = Path.Combine(Path.GetTempPath(), $"test_trace_{Guid.NewGuid():N}.log"); + } + + public void Dispose() + { + try + { + _listener?.Dispose(); + } + catch { } + + try + { + if (File.Exists(_logFilePath)) + File.Delete(_logFilePath); + } + catch { } + } + + #region Construction + + [Fact] + public void Ctor_CreatesInstance() + { + _listener = new TextTraceListener(_logFilePath); + + Assert.NotNull(_listener); + Assert.NotNull(_listener.Name); // TraceListener base has Name + } + + [Fact] + public void Ctor_LogFileDoesNotExistYet_Works() + { + Assert.False(File.Exists(_logFilePath)); + + _listener = new TextTraceListener(_logFilePath); + + Assert.NotNull(_listener); + } + + #endregion + + #region Write & WriteLine + + [Fact] + public void Write_SimpleMessage_DoesNotThrow() + { + _listener = new TextTraceListener(_logFilePath); + + var ex = Record.Exception(() => _listener.Write("test message")); + + Assert.Null(ex); + } + + [Fact] + public void WriteLine_SimpleMessage_DoesNotThrow() + { + _listener = new TextTraceListener(_logFilePath); + + var ex = Record.Exception(() => _listener.WriteLine("test line")); + + Assert.Null(ex); + } + + [Fact] + public void Write_NullMessage_DoesNotThrow() + { + _listener = new TextTraceListener(_logFilePath); + + var ex = Record.Exception(() => _listener.Write(null!)); + + Assert.Null(ex); + } + + [Fact] + public void WriteLine_NullMessage_DoesNotThrow() + { + _listener = new TextTraceListener(_logFilePath); + + var ex = Record.Exception(() => _listener.WriteLine(null!)); + + Assert.Null(ex); + } + + [Fact] + public void Write_EmptyString_DoesNotThrow() + { + _listener = new TextTraceListener(_logFilePath); + + var ex = Record.Exception(() => _listener.Write(string.Empty)); + + Assert.Null(ex); + } + + [Fact] + public void Write_MultipleMessages_DoesNotThrow() + { + _listener = new TextTraceListener(_logFilePath); + + for (int i = 0; i < 100; i++) + { + var ex = Record.Exception(() => _listener.Write($"message {i}")); + Assert.Null(ex); + } + } + + #endregion + + #region Dispose lifecycle + + [Fact] + public void Dispose_SingleCall_DoesNotThrow() + { + _listener = new TextTraceListener(_logFilePath); + var ex = Record.Exception(() => _listener.Dispose()); + Assert.Null(ex); + } + + [Fact] + public void Dispose_DoubleCall_DoesNotThrow() + { + _listener = new TextTraceListener(_logFilePath); + _listener.Dispose(); + var ex = Record.Exception(() => _listener.Dispose()); + Assert.Null(ex); + } + + [Fact] + public void Write_AfterDispose_DoesNotThrow() + { + _listener = new TextTraceListener(_logFilePath); + _listener.Dispose(); + + var ex = Record.Exception(() => _listener.Write("after dispose")); + Assert.Null(ex); + } + + [Fact] + public void WriteLine_AfterDispose_DoesNotThrow() + { + _listener = new TextTraceListener(_logFilePath); + _listener.Dispose(); + + var ex = Record.Exception(() => _listener.WriteLine("after dispose line")); + Assert.Null(ex); + } + + #endregion + + #region Concurrent writes + + [Fact] + public void Write_ConcurrentWrites_DoesNotThrow() + { + _listener = new TextTraceListener(_logFilePath); + + var tasks = Enumerable.Range(0, 50).Select(i => + Task.Run(() => + { + for (int j = 0; j < 20; j++) + _listener.Write($"thread-{i}-msg-{j}"); + })).ToArray(); + + var aggregate = Record.Exception(() => Task.WaitAll(tasks)); + Assert.Null(aggregate); + } + + #endregion + + #region Background thread delivers messages + + [Fact] + public void Write_MessagesEventuallyDeliveredToFile() + { + _listener = new TextTraceListener(_logFilePath); + + for (int i = 0; i < 10; i++) + _listener.Write($"message {i}"); + + _listener.Dispose(); // Flush & close + + Assert.True(File.Exists(_logFilePath)); + var content = File.ReadAllText(_logFilePath); + Assert.Contains("message 0", content); + } + + [Fact] + public void WriteLine_AppendsNewlineEventually() + { + _listener = new TextTraceListener(_logFilePath); + + _listener.WriteLine("line content"); + + _listener.Dispose(); + + Assert.True(File.Exists(_logFilePath)); + var content = File.ReadAllText(_logFilePath); + Assert.Contains("line content", content); + Assert.EndsWith(Environment.NewLine, content); + } + + #endregion +}