diff --git a/tests/CoreTest/Configuration/ConfiginfoBuilderTests.cs b/tests/CoreTest/Configuration/ConfiginfoBuilderTests.cs new file mode 100644 index 00000000..077fe7ef --- /dev/null +++ b/tests/CoreTest/Configuration/ConfiginfoBuilderTests.cs @@ -0,0 +1,286 @@ +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +public class ConfiginfoBuilderTests +{ + #region SetXxx — Null/Empty Validation + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetUpdateUrl_InvalidValue_ThrowsArgumentException(string value) + { + var builder = new ConfiginfoBuilder(); + var ex = Assert.Throws(() => builder.SetUpdateUrl(value)); + Assert.Contains("updateUrl", ex.Message); + } + + [Fact] + public void SetUpdateUrl_ValidValue_ReturnsBuilder() + { + var builder = new ConfiginfoBuilder(); + var result = builder.SetUpdateUrl("https://api.example.com"); + Assert.Same(builder, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetToken_InvalidValue_ThrowsArgumentException(string value) + { + var builder = new ConfiginfoBuilder(); + var ex = Assert.Throws(() => builder.SetToken(value)); + Assert.Contains("token", ex.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetScheme_InvalidValue_ThrowsArgumentException(string value) + { + var builder = new ConfiginfoBuilder(); + var ex = Assert.Throws(() => builder.SetScheme(value)); + Assert.Contains("scheme", ex.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetAppName_InvalidValue_ThrowsArgumentException(string value) + { + var builder = new ConfiginfoBuilder(); + var ex = Assert.Throws(() => builder.SetAppName(value)); + Assert.Contains("AppName", ex.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetMainAppName_InvalidValue_ThrowsArgumentException(string value) + { + var builder = new ConfiginfoBuilder(); + var ex = Assert.Throws(() => builder.SetMainAppName(value)); + Assert.Contains("MainAppName", ex.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetClientVersion_InvalidValue_ThrowsArgumentException(string value) + { + var builder = new ConfiginfoBuilder(); + var ex = Assert.Throws(() => builder.SetClientVersion(value)); + Assert.Contains("ClientVersion", ex.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetUpgradeClientVersion_InvalidValue_ThrowsArgumentException(string value) + { + var builder = new ConfiginfoBuilder(); + var ex = Assert.Throws(() => builder.SetUpgradeClientVersion(value)); + Assert.Contains("UpgradeClientVersion", ex.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetAppSecretKey_InvalidValue_ThrowsArgumentException(string value) + { + var builder = new ConfiginfoBuilder(); + var ex = Assert.Throws(() => builder.SetAppSecretKey(value)); + Assert.Contains("AppSecretKey", ex.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetProductId_InvalidValue_ThrowsArgumentException(string value) + { + var builder = new ConfiginfoBuilder(); + var ex = Assert.Throws(() => builder.SetProductId(value)); + Assert.Contains("ProductId", ex.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetInstallPath_InvalidValue_ThrowsArgumentException(string value) + { + var builder = new ConfiginfoBuilder(); + var ex = Assert.Throws(() => builder.SetInstallPath(value)); + Assert.Contains("InstallPath", ex.Message); + } + + #endregion + + #region Null-Allowed Setters (Bowl, DriverDirectory, BlackFile collections) + + [Fact] + public void SetBowl_Null_Allowed() + { + var builder = new ConfiginfoBuilder(); + var result = builder.SetBowl(null); + Assert.Same(builder, result); + } + + [Fact] + public void SetDriverDirectory_Null_Allowed() + { + var builder = new ConfiginfoBuilder(); + var result = builder.SetDriverDirectory(null); + Assert.Same(builder, result); + } + + [Fact] + public void SetBlackFiles_Null_InitializesEmptyList() + { + var builder = new ConfiginfoBuilder(); + var result = builder.SetBlackFiles(null); + Assert.Same(builder, result); + } + + [Fact] + public void SetBlackFiles_ValidList_Stored() + { + var builder = new ConfiginfoBuilder(); + var files = new List { "file1.dll", "file2.dll" }; + builder.SetBlackFiles(files); + // Can only verify through Build() + } + + [Fact] + public void SetBlackFormats_Null_InitializesEmptyList() + { + var builder = new ConfiginfoBuilder(); + var result = builder.SetBlackFormats(null); + Assert.Same(builder, result); + } + + [Fact] + public void SetSkipDirectorys_Null_InitializesEmptyList() + { + var builder = new ConfiginfoBuilder(); + var result = builder.SetSkipDirectorys(null); + Assert.Same(builder, result); + } + + #endregion + + #region Fluent Chaining + + [Fact] + public void SetMethods_ReturnsSameBuilder_ForChaining() + { + var builder = new ConfiginfoBuilder(); + var result = builder + .SetUpdateUrl("https://api.example.com") + .SetToken("token") + .SetScheme("https") + .SetAppName("MyApp") + .SetMainAppName("MainApp") + .SetClientVersion("1.0.0"); + Assert.Same(builder, result); + } + + #endregion + + #region Build — Success / Validation Failure + + [Fact] + public void Build_WithRequiredFields_ReturnsConfiginfo() + { + var config = new ConfiginfoBuilder() + .SetUpdateUrl("https://api.example.com") + .SetToken("token123") + .SetScheme("https") + .SetAppName("MyApp.exe") + .SetMainAppName("MyApp") + .SetClientVersion("1.0.0") + .SetAppSecretKey("secret") + .SetInstallPath("C:\\app") + .Build(); + + Assert.NotNull(config); + Assert.Equal("https://api.example.com", config.UpdateUrl); + Assert.Equal("token123", config.Token); + Assert.Equal("https", config.Scheme); + Assert.Equal("MyApp.exe", config.AppName); + Assert.Equal("MyApp", config.MainAppName); + Assert.Equal("1.0.0", config.ClientVersion); + Assert.Equal("secret", config.AppSecretKey); + Assert.Equal("C:\\app", config.InstallPath); + } + + [Fact] + public void Build_MissingRequiredFields_ThrowsInvalidOperationException() + { + var builder = new ConfiginfoBuilder() + .SetUpdateUrl("https://api.example.com") + .SetToken("token") + .SetScheme("https"); + // Missing AppName, MainAppName, AppSecretKey, ClientVersion, InstallPath + var ex = Assert.Throws(() => builder.Build()); + Assert.Contains("Failed to build valid Configinfo", ex.Message); + Assert.IsType(ex.InnerException); + } + + [Fact] + public void Build_EmptyBlackLists_ReturnsEmptyListNotNll() + { + var config = new ConfiginfoBuilder() + .SetUpdateUrl("https://api.example.com") + .SetToken("token123") + .SetScheme("https") + .SetAppName("MyApp.exe") + .SetMainAppName("MyApp") + .SetClientVersion("1.0.0") + .SetAppSecretKey("secret") + .SetInstallPath("C:\\app") + .Build(); + + Assert.NotNull(config.BlackFiles); + Assert.NotNull(config.BlackFormats); + Assert.NotNull(config.SkipDirectorys); + } + + [Fact] + public void Build_WithOptionalFields_IncludesThem() + { + var config = new ConfiginfoBuilder() + .SetUpdateUrl("https://api.example.com") + .SetToken("token123") + .SetScheme("https") + .SetAppName("MyApp.exe") + .SetMainAppName("MyApp") + .SetClientVersion("1.0.0") + .SetAppSecretKey("secret") + .SetInstallPath("C:\\app") + .SetUpdateLogUrl("https://api.example.com/log") + .SetProductId("product-001") + .SetUpgradeClientVersion("2.0.0") + .SetBowl("BowlApp") + .SetDriverDirectory("C:\\drivers") + .Build(); + + Assert.Equal("https://api.example.com/log", config.UpdateLogUrl); + Assert.Equal("product-001", config.ProductId); + Assert.Equal("2.0.0", config.UpgradeClientVersion); + Assert.Equal("BowlApp", config.Bowl); + Assert.Equal("C:\\drivers", config.DriverDirectory); + } + + #endregion +} diff --git a/tests/CoreTest/Configuration/ConfiginfoTests.cs b/tests/CoreTest/Configuration/ConfiginfoTests.cs new file mode 100644 index 00000000..6c9cbb08 --- /dev/null +++ b/tests/CoreTest/Configuration/ConfiginfoTests.cs @@ -0,0 +1,202 @@ +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +public class ConfiginfoTests +{ + #region Validate — Null / Whitespace / Invalid URL + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_UpdateUrlNullOrWhitespace_ThrowsArgumentException(string updateUrl) + { + var config = new Configinfo + { + UpdateUrl = updateUrl, + AppName = "TestApp", + MainAppName = "MainApp", + AppSecretKey = "secret", + ClientVersion = "1.0.0", + InstallPath = "C:\\app" + }; + var ex = Assert.Throws(() => config.Validate()); + Assert.Contains("Invalid UpdateUrl", ex.Message); + } + + [Fact] + public void Validate_UpdateUrlNotWellFormedUri_ThrowsArgumentException() + { + var config = new Configinfo + { + UpdateUrl = "not_a_valid_uri!!!", + AppName = "TestApp", + MainAppName = "MainApp", + AppSecretKey = "secret", + ClientVersion = "1.0.0", + InstallPath = "C:\\app" + }; + Assert.Throws(() => config.Validate()); + } + + [Theory] + [InlineData("https://api.example.com/update")] + [InlineData("http://localhost:5000/api/version")] + public void Validate_UpdateUrlValid_DoesNotThrowForUpdateUrl(string url) + { + var config = new Configinfo + { + UpdateUrl = url, + AppName = "TestApp", + MainAppName = "MainApp", + AppSecretKey = "secret", + ClientVersion = "1.0.0", + InstallPath = "C:\\app" + }; + var exception = Record.Exception(() => config.Validate()); + Assert.Null(exception); + } + + [Fact] + public void Validate_UpdateLogUrlNull_Allowed() + { + var config = new Configinfo + { + UpdateUrl = "https://api.example.com", + UpdateLogUrl = null, + AppName = "TestApp", + MainAppName = "MainApp", + AppSecretKey = "secret", + ClientVersion = "1.0.0", + InstallPath = "C:\\app" + }; + var exception = Record.Exception(() => config.Validate()); + Assert.Null(exception); + } + + [Fact] + public void Validate_UpdateLogUrlInvalid_ThrowsArgumentException() + { + var config = new Configinfo + { + UpdateUrl = "https://api.example.com", + UpdateLogUrl = "not_a_uri!!!", + AppName = "TestApp", + MainAppName = "MainApp", + AppSecretKey = "secret", + ClientVersion = "1.0.0", + InstallPath = "C:\\app" + }; + Assert.Throws(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_AppNameNullOrWhitespace_ThrowsArgumentException(string appName) + { + var config = new Configinfo + { + UpdateUrl = "https://api.example.com", + AppName = appName, + MainAppName = "MainApp", + AppSecretKey = "secret", + ClientVersion = "1.0.0", + InstallPath = "C:\\app" + }; + Assert.Throws(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_MainAppNameNullOrWhitespace_ThrowsArgumentException(string mainAppName) + { + var config = new Configinfo + { + UpdateUrl = "https://api.example.com", + AppName = "TestApp", + MainAppName = mainAppName, + AppSecretKey = "secret", + ClientVersion = "1.0.0", + InstallPath = "C:\\app" + }; + Assert.Throws(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_AppSecretKeyNullOrWhitespace_ThrowsArgumentException(string secretKey) + { + var config = new Configinfo + { + UpdateUrl = "https://api.example.com", + AppName = "TestApp", + MainAppName = "MainApp", + AppSecretKey = secretKey, + ClientVersion = "1.0.0", + InstallPath = "C:\\app" + }; + Assert.Throws(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_ClientVersionNullOrWhitespace_ThrowsArgumentException(string clientVersion) + { + var config = new Configinfo + { + UpdateUrl = "https://api.example.com", + AppName = "TestApp", + MainAppName = "MainApp", + AppSecretKey = "secret", + ClientVersion = clientVersion, + InstallPath = "C:\\app" + }; + Assert.Throws(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_InstallPathNullOrWhitespace_ThrowsArgumentException(string installPath) + { + var config = new Configinfo + { + UpdateUrl = "https://api.example.com", + AppName = "TestApp", + MainAppName = "MainApp", + AppSecretKey = "secret", + ClientVersion = "1.0.0", + InstallPath = installPath + }; + Assert.Throws(() => config.Validate()); + } + + [Fact] + public void Validate_AllFieldsValid_NoExceptionThrown() + { + var config = new Configinfo + { + UpdateUrl = "https://api.example.com/update", + UpdateLogUrl = "https://api.example.com/log", + AppName = "TestApp", + MainAppName = "MainApp", + AppSecretKey = "secret123", + ClientVersion = "1.0.0", + InstallPath = "C:\\app" + }; + var exception = Record.Exception(() => config.Validate()); + Assert.Null(exception); + } + + #endregion +} diff --git a/tests/CoreTest/Configuration/ConfigurationMapperTests.cs b/tests/CoreTest/Configuration/ConfigurationMapperTests.cs new file mode 100644 index 00000000..57da9121 --- /dev/null +++ b/tests/CoreTest/Configuration/ConfigurationMapperTests.cs @@ -0,0 +1,164 @@ +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +public class ConfigurationMapperTests +{ + [Fact] + public void MapToGlobalConfigInfo_TargetNull_CreatesNewInstance() + { + var source = new Configinfo + { + UpdateUrl = "https://api.example.com", + AppName = "TestApp", + MainAppName = "MainApp", + ClientVersion = "1.0.0", + AppSecretKey = "secret", + InstallPath = "C:\\app" + }; + var result = ConfigurationMapper.MapToGlobalConfigInfo(source, null); + Assert.NotNull(result); + } + + [Fact] + public void MapToGlobalConfigInfo_SourceNull_ReturnsEmptyTarget() + { + var target = new GlobalConfigInfo { AppName = "existing" }; + var result = ConfigurationMapper.MapToGlobalConfigInfo(null, target); + Assert.Same(target, result); + Assert.Equal("existing", result.AppName); // Unchanged + } + + [Fact] + public void MapToGlobalConfigInfo_BothNull_ReturnsNewEmptyInstance() + { + var result = ConfigurationMapper.MapToGlobalConfigInfo(null, null); + Assert.NotNull(result); + } + + [Fact] + public void MapToGlobalConfigInfo_MapsAllFields() + { + var source = new Configinfo + { + UpdateUrl = "https://api.example.com", + AppName = "App.exe", + MainAppName = "MainApp", + ClientVersion = "2.0.0", + AppSecretKey = "key123", + InstallPath = "C:\\install", + UpdateLogUrl = "https://log.example.com", + ProductId = "prod-1", + UpgradeClientVersion = "3.0.0", + Token = "token123", + Scheme = "https" + }; + var result = ConfigurationMapper.MapToGlobalConfigInfo(source); + Assert.Equal("https://api.example.com", result.UpdateUrl); + Assert.Equal("App.exe", result.AppName); + Assert.Equal("MainApp", result.MainAppName); + Assert.Equal("2.0.0", result.ClientVersion); + Assert.Equal("key123", result.AppSecretKey); + Assert.Equal("C:\\install", result.InstallPath); + Assert.Equal("https://log.example.com", result.UpdateLogUrl); + Assert.Equal("prod-1", result.ProductId); + Assert.Equal("3.0.0", result.UpgradeClientVersion); + Assert.Equal("token123", result.Token); + Assert.Equal("https", result.Scheme); + } + + [Fact] + public void MapToProcessInfo_SourceNull_ThrowsArgumentNullException() + { + Assert.Throws(() => + ConfigurationMapper.MapToProcessInfo(null, + new List(), + new List(), + new List(), + new List())); + } + + [Fact] + public void MapToProcessInfo_MapsAppNameToMainAppName() + { + var source = new GlobalConfigInfo + { + MainAppName = "MyMainApp", + InstallPath = Path.GetTempPath(), + ClientVersion = "1.0.0", + LastVersion = "2.0.0", + AppSecretKey = "secret", + Encoding = System.Text.Encoding.UTF8, + Format = ".zip", + DownloadTimeOut = 30, + UpdateLogUrl = "https://log.example.com", + ReportUrl = "https://report.example.com", + BackupDirectory = "C:\\backup" + }; + var versions = new List { new() { Version = "2.0.0" } }; + + var result = ConfigurationMapper.MapToProcessInfo(source, versions, + new List(), new List(), new List()); + + Assert.Equal("MyMainApp", result.AppName); + Assert.Equal(Path.GetTempPath(), result.InstallPath); + Assert.Equal("1.0.0", result.CurrentVersion); + Assert.Equal("2.0.0", result.LastVersion); + Assert.Equal("utf-8", result.CompressEncoding); + Assert.Equal(".zip", result.CompressFormat); + Assert.Equal(30, result.DownloadTimeOut); + } + + [Fact] + public void CopyBaseFields_BothNull_DoesNotThrow() + { + var exception = Record.Exception(() => ConfigurationMapper.CopyBaseFields(null, null)); + Assert.Null(exception); + } + + [Fact] + public void CopyBaseFields_SourceNull_DoesNotThrow() + { + var target = new GlobalConfigInfo(); + var exception = Record.Exception(() => ConfigurationMapper.CopyBaseFields(null, target)); + Assert.Null(exception); + } + + [Fact] + public void CopyBaseFields_TargetNull_DoesNotThrow() + { + var source = new Configinfo { AppName = "source" }; + var exception = Record.Exception(() => ConfigurationMapper.CopyBaseFields(source, null)); + Assert.Null(exception); + } + + [Fact] + public void CopyBaseFields_CopiesAllBaseProperties() + { + var source = new Configinfo + { + AppName = "App.exe", + MainAppName = "Main", + InstallPath = "C:\\path", + UpdateLogUrl = "https://log", + AppSecretKey = "key", + ClientVersion = "1.0", + Token = "tok", + Scheme = "https", + Bowl = "bowl", + DriverDirectory = "C:\\drivers" + }; + var target = new GlobalConfigInfo(); + + ConfigurationMapper.CopyBaseFields(source, target); + + Assert.Equal("App.exe", target.AppName); + Assert.Equal("Main", target.MainAppName); + Assert.Equal("C:\\path", target.InstallPath); + Assert.Equal("https://log", target.UpdateLogUrl); + Assert.Equal("key", target.AppSecretKey); + Assert.Equal("1.0", target.ClientVersion); + Assert.Equal("tok", target.Token); + Assert.Equal("https", target.Scheme); + } +} diff --git a/tests/CoreTest/Configuration/EnvironmentsTests.cs b/tests/CoreTest/Configuration/EnvironmentsTests.cs new file mode 100644 index 00000000..a8f3595a --- /dev/null +++ b/tests/CoreTest/Configuration/EnvironmentsTests.cs @@ -0,0 +1,59 @@ +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +public class EnvironmentsTests +{ + [Fact] + public void SetAndGet_RoundTrip_ReturnsSameValue() + { + var key = $"TEST_KEY_{Guid.NewGuid():N}"; + var value = "Hello World 123!@#"; + + Environments.SetEnvironmentVariable(key, value); + var result = Environments.GetEnvironmentVariable(key); + + Assert.Equal(value, result); + } + + [Fact] + public void Get_NonexistentKey_ReturnsEmptyString() + { + var result = Environments.GetEnvironmentVariable($"NONEXISTENT_{Guid.NewGuid():N}"); + Assert.Equal(string.Empty, result); + } + + [Fact] + public void Get_AfterFirstGet_FileDeleted_ReturnsEmptyOnSecondGet() + { + var key = $"ONCE_KEY_{Guid.NewGuid():N}"; + Environments.SetEnvironmentVariable(key, "secret"); + var first = Environments.GetEnvironmentVariable(key); + var second = Environments.GetEnvironmentVariable(key); + + Assert.Equal("secret", first); + Assert.Equal(string.Empty, second); + } + + [Fact] + public void Set_OverwritesPreviousValue() + { + var key = $"OVERWRITE_KEY_{Guid.NewGuid():N}"; + Environments.SetEnvironmentVariable(key, "first"); + Environments.SetEnvironmentVariable(key, "second"); + var result = Environments.GetEnvironmentVariable(key); + + Assert.Equal("second", result); + } + + [Fact] + public void SetAndGet_SpecialCharacters_Preserved() + { + var key = $"SPECIAL_KEY_{Guid.NewGuid():N}"; + var value = "{\"name\":\"test\",\"value\":123}\n\t\r"; + Environments.SetEnvironmentVariable(key, value); + var result = Environments.GetEnvironmentVariable(key); + + Assert.Equal(value, result); + } +} diff --git a/tests/CoreTest/Configuration/ProcessInfoTests.cs b/tests/CoreTest/Configuration/ProcessInfoTests.cs new file mode 100644 index 00000000..c2d78698 --- /dev/null +++ b/tests/CoreTest/Configuration/ProcessInfoTests.cs @@ -0,0 +1,152 @@ +using System.Text; +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +public class ProcessInfoTests +{ + private static string ExistingDir => Path.GetTempPath(); + private static List SingleVersion => new() { new VersionInfo { Version = "2.0.0" } }; + + [Fact] + public void Ctor_AppNameNull_ThrowsArgumentNullException() + { + var ex = Assert.Throws(() => + new ProcessInfo(null, ExistingDir, "1.0", "2.0", null, + Encoding.UTF8, "ZIP", 30, "key", + SingleVersion, "url", "backup", null, null, null, null, null, null, null)); + Assert.Contains("appName", ex.Message); + } + + [Fact] + public void Ctor_InstallPathDoesNotExist_ThrowsArgumentException() + { + var ex = Assert.Throws(() => + new ProcessInfo("app", "C:\\nonexistent_path_xyz", "1.0", "2.0", null, + Encoding.UTF8, "ZIP", 30, "key", + SingleVersion, "url", "backup", null, null, null, null, null, null, null)); + Assert.Contains("path does not exist", ex.Message); + } + + [Fact] + public void Ctor_CurrentVersionNull_ThrowsArgumentNullException() + { + var ex = Assert.Throws(() => + new ProcessInfo("app", ExistingDir, null, "2.0", null, + Encoding.UTF8, "ZIP", 30, "key", + SingleVersion, "url", "backup", null, null, null, null, null, null, null)); + Assert.Contains("currentVersion", ex.Message); + } + + [Fact] + public void Ctor_LastVersionNull_ThrowsArgumentNullException() + { + var ex = Assert.Throws(() => + new ProcessInfo("app", ExistingDir, "1.0", null, null, + Encoding.UTF8, "ZIP", 30, "key", + SingleVersion, "url", "backup", null, null, null, null, null, null, null)); + Assert.Contains("lastVersion", ex.Message); + } + + [Fact] + public void Ctor_DownloadTimeOutNegative_ThrowsArgumentException() + { + var ex = Assert.Throws(() => + new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.UTF8, "ZIP", -1, "key", + SingleVersion, "url", "backup", null, null, null, null, null, null, null)); + Assert.Contains("greater than 0", ex.Message); + } + + [Fact] + public void Ctor_DownloadTimeOutZero_Allowed() + { + var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.UTF8, "ZIP", 0, "key", + SingleVersion, "url", "backup", null, null, null, null, null, null, null); + Assert.Equal(0, info.DownloadTimeOut); + } + + [Fact] + public void Ctor_AppSecretKeyNull_ThrowsArgumentNullException() + { + var ex = Assert.Throws(() => + new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.UTF8, "ZIP", 30, null, + SingleVersion, "url", "backup", null, null, null, null, null, null, null)); + Assert.Contains("appSecretKey", ex.Message); + } + + [Theory] + [InlineData(true)] // null list + [InlineData(false)] // empty list + public void Ctor_UpdateVersionsNullOrEmpty_ThrowsArgumentException(bool nullList) + { + var versions = nullList ? null : new List(); + var ex = Assert.Throws(() => + new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.UTF8, "ZIP", 30, "key", + versions, "url", "backup", null, null, null, null, null, null, null)); + Assert.Contains("Collection", ex.Message); + } + + [Fact] + public void Ctor_ReportUrlNull_ThrowsArgumentNullException() + { + var ex = Assert.Throws(() => + new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.UTF8, "ZIP", 30, "key", + SingleVersion, null, "backup", null, null, null, null, null, null, null)); + Assert.Contains("reportUrl", ex.Message); + } + + [Fact] + public void Ctor_BackupDirectoryNull_ThrowsArgumentNullException() + { + var ex = Assert.Throws(() => + new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.UTF8, "ZIP", 30, "key", + SingleVersion, "url", null, null, null, null, null, null, null, null)); + Assert.Contains("backupDirectory", ex.Message); + } + + [Fact] + public void Ctor_AllParametersValid_AllPropertiesSet() + { + var info = new ProcessInfo( + "MyApp", ExistingDir, "1.0.0", "2.0.0", "https://log.example.com", + Encoding.UTF8, ".zip", 60, "secret-key", + SingleVersion, "https://report.example.com", "C:\\backup", + "BowlProcess", "https", "token-abc", "C:\\drivers", + new List { ".tmp" }, new List { "skip.dll" }, new List { "logs" }); + + Assert.Equal("MyApp", info.AppName); + Assert.Equal(ExistingDir, info.InstallPath); + Assert.Equal("1.0.0", info.CurrentVersion); + Assert.Equal("2.0.0", info.LastVersion); + Assert.Equal("https://log.example.com", info.UpdateLogUrl); + Assert.Equal("utf-8", info.CompressEncoding); + Assert.Equal(".zip", info.CompressFormat); + Assert.Equal(60, info.DownloadTimeOut); + Assert.Equal("secret-key", info.AppSecretKey); + Assert.Single(info.UpdateVersions); + Assert.Equal("https://report.example.com", info.ReportUrl); + Assert.Equal("C:\\backup", info.BackupDirectory); + Assert.Equal("BowlProcess", info.Bowl); + Assert.Equal("https", info.Scheme); + Assert.Equal("token-abc", info.Token); + Assert.Equal("C:\\drivers", info.DriverDirectory); + Assert.Single(info.BlackFileFormats); + Assert.Single(info.BlackFiles); + Assert.Single(info.SkipDirectorys); + } + + [Fact] + public void Ctor_EncodingUTF8_CompressEncodingWebNameIsUtf8() + { + var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, + Encoding.UTF8, "ZIP", 30, "key", + SingleVersion, "url", "backup", null, null, null, null, null, null, null); + Assert.Equal("utf-8", info.CompressEncoding); + } +} diff --git a/tests/CoreTest/Configuration/UpdateOptionTests.cs b/tests/CoreTest/Configuration/UpdateOptionTests.cs new file mode 100644 index 00000000..dbf0b6e1 --- /dev/null +++ b/tests/CoreTest/Configuration/UpdateOptionTests.cs @@ -0,0 +1,97 @@ +using GeneralUpdate.Core.Configuration; + +namespace CoreTest.Configuration; + +public class UpdateOptionTests +{ + [Fact] + public void ValueOf_NewOption_CreatesWithDefaultValue() + { + var option = UpdateOption.ValueOf("TEST_KEY_001", 42); + Assert.Equal("TEST_KEY_001", option.Name); + Assert.Equal(42, option.DefaultValue); + } + + [Fact] + public void ValueOf_SameName_ReturnsSameSingletonInstance() + { + var opt1 = UpdateOption.ValueOf("SINGLETON_KEY", 100); + var opt2 = UpdateOption.ValueOf("SINGLETON_KEY", 200); + Assert.Same(opt1, opt2); + Assert.Equal(100, opt1.DefaultValue); // First created value preserved + } + + [Fact] + public void ValueOf_NameNull_ThrowsArgumentNullException() + { + Assert.Throws(() => UpdateOption.ValueOf(null)); + } + + [Fact] + public void Equals_SameName_ReturnsTrue() + { + var opt1 = UpdateOption.ValueOf("EQ_TEST", 1); + var opt2 = UpdateOption.ValueOf("EQ_TEST", 2); + Assert.True(opt1.Equals(opt2)); + } + + [Fact] + public void Equals_DifferentName_ReturnsFalse() + { + var opt1 = UpdateOption.ValueOf("KEY_A", 1); + var opt2 = UpdateOption.ValueOf("KEY_B", 1); + Assert.False(opt1.Equals(opt2)); + } + + [Fact] + public void Equals_Null_ReturnsFalse() + { + var opt = UpdateOption.ValueOf("KEY", 1); + Assert.False(opt.Equals(null)); + } + + [Fact] + public void Equals_DifferentType_ReturnsFalse() + { + var opt = UpdateOption.ValueOf("KEY", 1); + Assert.False(opt.Equals("not an option")); + } + + [Fact] + public void GetHashCode_SameName_SameHash() + { + var opt1 = UpdateOption.ValueOf("HASH_TEST", 1); + var opt2 = UpdateOption.ValueOf("HASH_TEST", 999); + Assert.Equal(opt1.GetHashCode(), opt2.GetHashCode()); + } + + [Fact] + public void ToString_ReturnsName() + { + var opt = UpdateOption.ValueOf("MY_OPTION", 42); + Assert.Equal("MY_OPTION", opt.ToString()); + } + + [Fact] + public void DefaultValue_NullableBool_DefaultNull() + { + var opt = UpdateOption.ValueOf("NULLABLE_BOOL"); + Assert.Null(opt.DefaultValue); + } + + [Fact] + public void DefaultValue_String_DefaultNull() + { + var opt = UpdateOption.ValueOf("STR_KEY"); + Assert.Null(opt.DefaultValue); + } + + [Fact] + public void ValueOf_DifferentTypes_SameName_PossibleConflict() + { + var intOpt = UpdateOption.ValueOf("MULTI_TYPE", 42); + var strOpt = UpdateOption.ValueOf("MULTI_TYPE", "hello"); + // Same name but different generic type — registry key collision test + Assert.Equal("MULTI_TYPE", intOpt.Name); + } +} diff --git a/tests/CoreTest/Download/DefaultRetryPolicyTests.cs b/tests/CoreTest/Download/DefaultRetryPolicyTests.cs new file mode 100644 index 00000000..b03f01b9 --- /dev/null +++ b/tests/CoreTest/Download/DefaultRetryPolicyTests.cs @@ -0,0 +1,169 @@ +using GeneralUpdate.Core.Download.Policy; +using GeneralUpdate.Core.Download.Models; + +namespace CoreTest.Download; + +public class DefaultRetryPolicyTests +{ + [Fact] + public async Task ExecuteAsync_ActionSucceedsFirstTry_ReturnsResultImmediately() + { + var policy = new DefaultRetryPolicy(3); + var result = await policy.ExecuteAsync(_ => Task.FromResult("ok")); + Assert.Equal("ok", result); + } + + [Fact] + public async Task ExecuteAsync_RetryableException_RetriesAndSucceeds() + { + var policy = new DefaultRetryPolicy(3, TimeSpan.FromMilliseconds(10)); + var attemptCount = 0; + var result = await policy.ExecuteAsync(_ => + { + attemptCount++; + if (attemptCount < 3) + throw new TimeoutException("transient"); + return Task.FromResult("ok"); + }); + Assert.Equal("ok", result); + Assert.Equal(3, attemptCount); + } + + [Fact] + public async Task ExecuteAsync_ExhaustsAllRetries_ThrowsLastException() + { + var policy = new DefaultRetryPolicy(2, TimeSpan.FromMilliseconds(10)); + var ex = await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => throw new TimeoutException("fail"))); + Assert.Contains("fail", ex.Message); + } + + [Fact] + public async Task ExecuteAsync_NonRetryableException_ThrowsImmediately() + { + var policy = new DefaultRetryPolicy(3, TimeSpan.FromMilliseconds(10)); + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => throw new OperationCanceledException("cancel"))); + } + + [Fact] + public async Task ExecuteAsync_TaskCanceledException_IsNotRetryable() + { + // TaskCanceledException inherits from OperationCanceledException, + // so IsRetryable returns false (not retryable) + var policy = new DefaultRetryPolicy(3, TimeSpan.FromMilliseconds(10)); + var attempts = 0; + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts++; + throw new TaskCanceledException("timeout"); + })); + Assert.Equal(1, attempts); // Not retried + } + + [Fact] + public async Task ExecuteAsync_IOException_IsRetryable() + { + var policy = new DefaultRetryPolicy(2, TimeSpan.FromMilliseconds(10)); + var attempts = 0; + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts++; + throw new IOException("network error"); + })); + Assert.Equal(2, attempts); + } + + [Fact] + public async Task ExecuteAsync_HttpRequestExceptionWith500_IsRetryable() + { + var policy = new DefaultRetryPolicy(2, TimeSpan.FromMilliseconds(10)); + var attempts = 0; + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts++; + throw new HttpRequestException("Server error 500"); + })); + Assert.Equal(2, attempts); + } + + [Fact] + public async Task ExecuteAsync_HttpRequestExceptionWith502_IsRetryable() + { + var policy = new DefaultRetryPolicy(2, TimeSpan.FromMilliseconds(10)); + var attempts = 0; + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts++; + throw new HttpRequestException("Bad Gateway 502"); + })); + Assert.Equal(2, attempts); + } + + [Fact] + public async Task ExecuteAsync_HttpRequestExceptionWith503_IsRetryable() + { + var policy = new DefaultRetryPolicy(2, TimeSpan.FromMilliseconds(10)); + var attempts = 0; + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts++; + throw new HttpRequestException("Service Unavailable 503"); + })); + Assert.Equal(2, attempts); + } + + [Fact] + public async Task ExecuteAsync_HttpRequestExceptionWith504_IsRetryable() + { + var policy = new DefaultRetryPolicy(2, TimeSpan.FromMilliseconds(10)); + var attempts = 0; + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts++; + throw new HttpRequestException("Gateway Timeout 504"); + })); + Assert.Equal(2, attempts); + } + + [Fact] + public async Task ExecuteAsync_HttpRequestException403_NotRetryable_ThrowsImmediately() + { + var policy = new DefaultRetryPolicy(3, TimeSpan.FromMilliseconds(10)); + var attempts = 0; + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts++; + throw new HttpRequestException("Forbidden 403"); + })); + Assert.Equal(1, attempts); + } + + [Fact] + public async Task ExecuteAsync_ExponentialBackoff_IncreasingDelays() + { + var policy = new DefaultRetryPolicy(4, TimeSpan.FromMilliseconds(50), backoffMultiplier: 2.0); + var attempts = new List(); + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts.Add(DateTime.UtcNow); + throw new TimeoutException("fail"); + })); + Assert.Equal(4, attempts.Count); + } + + [Fact] + public void Constructor_DefaultParameters_ReasonableDefaults() + { + var policy = new DefaultRetryPolicy(); + Assert.NotNull(policy); + } +} diff --git a/tests/CoreTest/Download/DownloadPlanBuilderTests.cs b/tests/CoreTest/Download/DownloadPlanBuilderTests.cs index 3661d313..ac4e3f4e 100644 --- a/tests/CoreTest/Download/DownloadPlanBuilderTests.cs +++ b/tests/CoreTest/Download/DownloadPlanBuilderTests.cs @@ -1,105 +1,159 @@ -using System.Linq; using GeneralUpdate.Core.Download; using GeneralUpdate.Core.Download.Models; -using Xunit; namespace CoreTest.Download; public class DownloadPlanBuilderTests { + private static DownloadAsset Asset(string name = "a", string version = "2.0.0", string url = "http://u", + long size = 100, string hash = null, bool isFreeze = false, bool isForcibly = false, + bool isCrossVersion = false, string fromVersion = null, string minClientVersion = null) + => new(name, url, size, hash, version, + IsFreeze: isFreeze, IsForcibly: isForcibly, + IsCrossVersion: isCrossVersion, FromVersion: fromVersion, + MinClientVersion: minClientVersion); + [Fact] - public void Build_EmptyAssets_ReturnsEmptyPlan() + public void Build_AssetsNull_ReturnsEmpty() { - var plan = DownloadPlanBuilder.Build(Array.Empty(), "1.0.0"); - Assert.False(plan.HasAssets); + var result = DownloadPlanBuilder.Build(null, "1.0.0"); + Assert.False(result.HasAssets); } - [Fact] - public void Build_SingleAsset_ReturnsIt() + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("invalid")] + public void Build_CurrentVersionInvalid_ReturnsEmpty(string version) { - var assets = new[] { new DownloadAsset("pkg", "http://x", 100, "sha", "1.0.1") }; - var plan = DownloadPlanBuilder.Build(assets, "1.0.0"); - Assert.True(plan.HasAssets); - Assert.Single(plan.Assets); - Assert.Equal("1.0.1", plan.Assets[0].Version); + var result = DownloadPlanBuilder.Build(new[] { Asset("a", "2.0.0") }, version); + Assert.False(result.HasAssets); } [Fact] - public void Build_CrossVersionPackage_DirectJump() + public void Build_AllAssetsFrozen_ReturnsEmpty() { var assets = new[] { - new DownloadAsset("chain", "http://x", 100, "sha", "1.0.1"), - new DownloadAsset("jump", "http://y", 500, "sha2", "2.0.0", - IsCrossVersion: true, FromVersion: "1.0.0") + Asset("a", "2.0.0", isFreeze: true), + Asset("b", "3.0.0", isFreeze: true) }; + var result = DownloadPlanBuilder.Build(assets, "1.0.0"); + Assert.False(result.HasAssets); + } + + [Fact] + public void Build_SingleAssetVersionAboveCurrent_HasAssets() + { + var assets = new[] { Asset("update", "2.0.0") }; + var result = DownloadPlanBuilder.Build(assets, "1.0.0"); + Assert.True(result.HasAssets); + Assert.Single(result.Assets); + } - var plan = DownloadPlanBuilder.Build(assets, "1.0.0"); - Assert.Single(plan.Assets); // Only the cross-version jump package - Assert.Equal("2.0.0", plan.Assets[0].Version); + [Fact] + public void Build_AllVersionsBelowOrEqual_ReturnsEmpty() + { + var assets = new[] { Asset("old", "1.0.0"), Asset("older", "0.9.0") }; + var result = DownloadPlanBuilder.Build(assets, "1.0.0"); + Assert.False(result.HasAssets); } [Fact] - public void Build_FrozenPackagesExcluded() + public void Build_AnyAssetIsForcibly_IsForciblyTrue() { var assets = new[] { - new DownloadAsset("bad", "http://x", 100, "sha", "1.0.1", IsFreeze: true), - new DownloadAsset("good", "http://y", 100, "sha2", "1.0.2") + Asset("a", "2.0.0"), + Asset("b", "2.1.0", isForcibly: true) }; + var result = DownloadPlanBuilder.Build(assets, "1.0.0"); + Assert.True(result.IsForcibly); + } - var plan = DownloadPlanBuilder.Build(assets, "1.0.0"); - Assert.Single(plan.Assets); - Assert.Equal("1.0.2", plan.Assets[0].Version); + [Fact] + public void Build_NoAssetIsForcibly_IsForciblyFalse() + { + var assets = new[] { Asset("a", "2.0.0") }; + var result = DownloadPlanBuilder.Build(assets, "1.0.0"); + Assert.False(result.IsForcibly); } [Fact] - public void Build_ForcedUpdate_MarksPlan() + public void Build_CrossVersionMatch_ReturnsSingleAssetPlan() { var assets = new[] { - new DownloadAsset("forced", "http://x", 100, "sha", "1.0.1", IsForcibly: true) + Asset("cross", "5.0.0", isCrossVersion: true, fromVersion: "1.0.0"), + Asset("inc", "2.0.0"), Asset("inc2", "3.0.0") }; - - var plan = DownloadPlanBuilder.Build(assets, "1.0.0"); - Assert.True(plan.IsForcibly); + var result = DownloadPlanBuilder.Build(assets, "1.0.0"); + Assert.True(result.HasAssets); + Assert.Single(result.Assets); + Assert.Equal("5.0.0", result.Assets[0].Version); } [Fact] - public void Build_VersionChain_MultipleSteps() + public void Build_MinClientVersionTooHigh_FilteredOut() { var assets = new[] { - new DownloadAsset("v101", "http://x", 100, "sha1", "1.0.1"), - new DownloadAsset("v102", "http://y", 100, "sha2", "1.0.2"), - new DownloadAsset("v103", "http://z", 100, "sha3", "1.0.3") + Asset("high", "3.0.0", minClientVersion: "2.0.0") }; + var result = DownloadPlanBuilder.Build(assets, "1.0.0"); + Assert.False(result.HasAssets); + } - var plan = DownloadPlanBuilder.Build(assets, "1.0.0"); - Assert.Equal(3, plan.Assets.Count); - Assert.Equal("1.0.1", plan.Assets[0].Version); - Assert.Equal("1.0.3", plan.Assets[^1].Version); + [Fact] + public void Build_MinClientVersionOk_Included() + { + var assets = new[] + { + Asset("ok", "3.0.0", minClientVersion: "1.0.0") + }; + var result = DownloadPlanBuilder.Build(assets, "1.0.0"); + Assert.True(result.HasAssets); } [Fact] - public void Build_SameVersion_ReturnsEmpty() + public void Build_ChainOrderedByVersionAscending() { - var assets = new[] { new DownloadAsset("same", "http://x", 100, "sha", "1.0.0") }; - var plan = DownloadPlanBuilder.Build(assets, "1.0.0"); - Assert.False(plan.HasAssets); + var assets = new[] + { + Asset("c", "3.0.0"), Asset("a", "1.1.0"), Asset("b", "2.0.0") + }; + var result = DownloadPlanBuilder.Build(assets, "1.0.0"); + Assert.Equal(3, result.Assets.Count); + Assert.Equal("1.1.0", result.Assets[0].Version); + Assert.Equal("2.0.0", result.Assets[1].Version); + Assert.Equal("3.0.0", result.Assets[2].Version); } [Fact] - public void Build_MinClientVersion_FiltersOut() + public void Build_MixedFrozenAndActive_FiltersFrozen() { var assets = new[] { - new DownloadAsset("compat", "http://x", 100, "sha1", "1.0.1", MinClientVersion: "1.0.0"), - new DownloadAsset("incompat", "http://y", 100, "sha2", "1.0.2", MinClientVersion: "2.0.0") + Asset("active", "2.0.0"), + Asset("frozen", "3.0.0", isFreeze: true) }; + var result = DownloadPlanBuilder.Build(assets, "1.0.0"); + Assert.Single(result.Assets); + Assert.Equal("2.0.0", result.Assets[0].Version); + } - var plan = DownloadPlanBuilder.Build(assets, "1.0.0"); - Assert.Single(plan.Assets); - Assert.Equal("1.0.1", plan.Assets[0].Version); + [Fact] + public void MapToAsset_NullFields_HasSaneDefaults() + { + var packet = new GeneralUpdate.Core.Download.Abstractions.PacketDTO + { + Name = null, Url = null, Version = null, Hash = null + }; + var asset = DownloadPlanBuilder.MapToAsset(packet); + Assert.Equal("unknown", asset.Name); + Assert.Equal("", asset.Url); + Assert.Equal("0.0.0", asset.Version); + Assert.Equal(0, asset.Size); } } diff --git a/tests/CoreTest/Event/EventManagerTests.cs b/tests/CoreTest/Event/EventManagerTests.cs new file mode 100644 index 00000000..9a227d5a --- /dev/null +++ b/tests/CoreTest/Event/EventManagerTests.cs @@ -0,0 +1,134 @@ +using GeneralUpdate.Core.Event; + +namespace CoreTest.Event; + +public class EventManagerTests +{ + public class TestEventArgs : EventArgs + { + public int Value { get; set; } + } + + [Fact] + public void AddListener_NullListener_ThrowsArgumentNullException() + { + Assert.Throws(() => + EventManager.Instance.AddListener(null)); + } + + [Fact] + public void AddListener_SingleListener_Registered() + { + var called = false; + Action handler = (s, e) => called = true; + EventManager.Instance.AddListener(handler); + EventManager.Instance.Dispatch(this, new TestEventArgs()); + Assert.True(called); + EventManager.Instance.Clear(); + } + + [Fact] + public void Dispatch_NoListeners_DoesNotThrow() + { + var ex = Record.Exception(() => + EventManager.Instance.Dispatch(this, new TestEventArgs())); + Assert.Null(ex); + } + + [Fact] + public void Dispatch_SenderNull_ThrowsArgumentNullException() + { + Assert.Throws(() => + EventManager.Instance.Dispatch(null, new TestEventArgs())); + } + + [Fact] + public void Dispatch_EventArgsNull_ThrowsArgumentNullException() + { + Assert.Throws(() => + EventManager.Instance.Dispatch(this, null)); + } + + [Fact] + public void Dispatch_MultipleListeners_AllCalled() + { + var count = 0; + Action h1 = (s, e) => count++; + Action h2 = (s, e) => count++; + EventManager.Instance.AddListener(h1); + EventManager.Instance.AddListener(h2); + EventManager.Instance.Dispatch(this, new TestEventArgs()); + Assert.Equal(2, count); + EventManager.Instance.Clear(); + } + + [Fact] + public void Dispatch_OneHandlerThrows_OtherStillCalled() + { + var secondCalled = false; + Action h1 = (s, e) => throw new InvalidOperationException("boom"); + Action h2 = (s, e) => secondCalled = true; + EventManager.Instance.AddListener(h1); + EventManager.Instance.AddListener(h2); + EventManager.Instance.Dispatch(this, new TestEventArgs()); + Assert.True(secondCalled); + EventManager.Instance.Clear(); + } + + [Fact] + public void RemoveListener_ExistingListener_Removed() + { + var called = false; + Action handler = (s, e) => called = true; + EventManager.Instance.AddListener(handler); + EventManager.Instance.RemoveListener(handler); + EventManager.Instance.Dispatch(this, new TestEventArgs()); + Assert.False(called); + EventManager.Instance.Clear(); + } + + [Fact] + public void RemoveListener_Null_ThrowsArgumentNullException() + { + Assert.Throws(() => + EventManager.Instance.RemoveListener(null)); + } + + [Fact] + public void RemoveListener_NotRegistered_DoesNotThrow() + { + var ex = Record.Exception(() => + EventManager.Instance.RemoveListener((s, e) => { })); + Assert.Null(ex); + } + + [Fact] + public void Clear_RemovesAllListeners() + { + var called = false; + EventManager.Instance.AddListener((s, e) => called = true); + EventManager.Instance.Clear(); + EventManager.Instance.Dispatch(this, new TestEventArgs()); + Assert.False(called); + } + + [Fact] + public void Instance_ReturnsSameSingleton() + { + var a = EventManager.Instance; + var b = EventManager.Instance; + Assert.Same(a, b); + } + + [Fact] + public void Dispatch_PassesCorrectEventArgs() + { + TestEventArgs received = null; + EventManager.Instance.AddListener((s, e) => received = e); + var args = new TestEventArgs { Value = 42 }; + EventManager.Instance.Dispatch(this, args); + Assert.Same(args, received); + Assert.Equal(42, received.Value); + EventManager.Instance.Clear(); + } +} diff --git a/tests/CoreTest/FileSystem/DefaultBlackListMatcherTests.cs b/tests/CoreTest/FileSystem/DefaultBlackListMatcherTests.cs new file mode 100644 index 00000000..82107b3a --- /dev/null +++ b/tests/CoreTest/FileSystem/DefaultBlackListMatcherTests.cs @@ -0,0 +1,104 @@ +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.FileSystem; + +namespace CoreTest.FileSystem; + +public class DefaultBlackListMatcherTests +{ + private static BlackListConfig CreateConfig(List files = null, List formats = null, List dirs = null) + => new(files, formats, dirs); + + [Fact] + public void IsBlacklisted_ExactFileNameMatch_ReturnsTrue() + { + var matcher = new DefaultBlackListMatcher(CreateConfig( + files: new List { "test.exe" })); + Assert.True(matcher.IsBlacklisted("test.exe")); + } + + [Fact] + public void IsBlacklisted_FileNameNoMatch_ReturnsFalse() + { + var matcher = new DefaultBlackListMatcher(CreateConfig( + files: new List { "test.exe" })); + Assert.False(matcher.IsBlacklisted("other.dll")); + } + + [Fact] + public void IsBlacklisted_GlobPatternMatch_ReturnsTrue() + { + var matcher = new DefaultBlackListMatcher(CreateConfig( + files: new List { "*.pdb" })); + Assert.True(matcher.IsBlacklisted("myassembly.pdb")); + } + + [Fact] + public void IsBlacklisted_GlobPatternNoMatch_ReturnsFalse() + { + var matcher = new DefaultBlackListMatcher(CreateConfig( + files: new List { "*.pdb" })); + Assert.False(matcher.IsBlacklisted("myassembly.dll")); + } + + [Fact] + public void IsBlacklisted_FormatMatchCaseInsensitive_ReturnsTrue() + { + var matcher = new DefaultBlackListMatcher(CreateConfig( + formats: new List { ".tmp" })); + Assert.True(matcher.IsBlacklisted("file.TMP")); + } + + [Fact] + public void IsBlacklisted_FormatNoMatch_ReturnsFalse() + { + var matcher = new DefaultBlackListMatcher(CreateConfig( + formats: new List { ".tmp" })); + Assert.False(matcher.IsBlacklisted("file.exe")); + } + + [Fact] + public void IsBlacklisted_EmptyBlackLists_ReturnsFalse() + { + var matcher = new DefaultBlackListMatcher(CreateConfig()); + Assert.False(matcher.IsBlacklisted("anything.exe")); + } + + [Fact] + public void ShouldSkipDirectory_MatchFound_ReturnsTrue() + { + var matcher = new DefaultBlackListMatcher(CreateConfig( + dirs: new List { "app-" })); + Assert.True(matcher.ShouldSkipDirectory("app-1.0.0")); + } + + [Fact] + public void ShouldSkipDirectory_NoMatch_ReturnsFalse() + { + var matcher = new DefaultBlackListMatcher(CreateConfig( + dirs: new List { "temp" })); + Assert.False(matcher.ShouldSkipDirectory("data")); + } + + [Fact] + public void ShouldSkipDirectory_EmptyList_ReturnsFalse() + { + var matcher = new DefaultBlackListMatcher(CreateConfig()); + Assert.False(matcher.ShouldSkipDirectory("app-1.0.0")); + } + + [Fact] + public void IsBlacklistedFormat_ExactMatch_ReturnsTrue() + { + var matcher = new DefaultBlackListMatcher(CreateConfig( + formats: new List { ".zip" })); + Assert.True(matcher.IsBlacklistedFormat(".zip")); + } + + [Fact] + public void FromConfigInfo_BlackFilesEmpty_UsesNullInConfig() + { + var config = new GlobalConfigInfo { BlackFiles = new List() }; + var matcher = DefaultBlackListMatcher.FromConfigInfo(config); + Assert.False(matcher.IsBlacklisted("test.dll")); + } +} diff --git a/tests/CoreTest/FileSystem/FileNodeTests.cs b/tests/CoreTest/FileSystem/FileNodeTests.cs new file mode 100644 index 00000000..66a21045 --- /dev/null +++ b/tests/CoreTest/FileSystem/FileNodeTests.cs @@ -0,0 +1,218 @@ +using GeneralUpdate.Core.FileSystem; + +namespace CoreTest.FileSystem; + +public class FileNodeTests +{ + // ── Add ── + + [Fact] + public void Add_NullNode_SkippedWithoutException() + { + var root = new FileNode(10); + var ex = Record.Exception(() => root.Add(null)); + Assert.Null(ex); + } + + [Fact] + public void Add_SmallerIdWithNullLeft_AssignsDirectly() + { + var root = new FileNode(10); + root.Add(new FileNode(5)); + Assert.NotNull(root.Left); + Assert.Equal(5, root.Left.Id); + } + + [Fact] + public void Add_SmallerIdWithNonNullLeft_Recurses() + { + var root = new FileNode(10); + root.Add(new FileNode(7)); + root.Add(new FileNode(5)); + Assert.Equal(7, root.Left.Id); + Assert.Equal(5, root.Left.Left.Id); + } + + [Fact] + public void Add_EqualOrLargerIdWithNullRight_AssignsDirectly() + { + var root = new FileNode(10); + root.Add(new FileNode(10)); + Assert.NotNull(root.Right); + Assert.Equal(10, root.Right.Id); + } + + [Fact] + public void Add_LargerIdWithNonNullRight_Recurses() + { + var root = new FileNode(10); + root.Add(new FileNode(15)); + root.Add(new FileNode(12)); + Assert.Equal(15, root.Right.Id); + Assert.Equal(12, root.Right.Left.Id); + } + + // ── Search ── + + [Fact] + public void Search_ExactMatch_ReturnsThis() + { + var root = new FileNode(10); + root.Add(new FileNode(5)); + root.Add(new FileNode(15)); + var result = root.Search(10); + Assert.Same(root, result); + } + + [Fact] + public void Search_SmallerIdWithNullLeft_ReturnsNull() + { + var root = new FileNode(10); + var result = root.Search(5); + Assert.Null(result); + } + + [Fact] + public void Search_SmallerIdFoundInLeft_ReturnsNode() + { + var root = new FileNode(10); + var child = new FileNode(5); + root.Add(child); + var result = root.Search(5); + Assert.Same(child, result); + } + + [Fact] + public void Search_LargerIdWithNullRight_ReturnsNull() + { + var root = new FileNode(10); + var result = root.Search(15); + Assert.Null(result); + } + + [Fact] + public void Search_LargerIdFoundInRight_ReturnsNode() + { + var root = new FileNode(10); + var child = new FileNode(15); + root.Add(child); + var result = root.Search(15); + Assert.Same(child, result); + } + + // ── SearchParent ── + + [Fact] + public void SearchParent_DirectLeftChild_ReturnsThis() + { + var root = new FileNode(10); + var child = new FileNode(5); + root.Add(child); + var parent = root.SearchParent(5); + Assert.Same(root, parent); + } + + [Fact] + public void SearchParent_DirectRightChild_ReturnsThis() + { + var root = new FileNode(10); + var child = new FileNode(15); + root.Add(child); + var parent = root.SearchParent(15); + Assert.Same(root, parent); + } + + [Fact] + public void SearchParent_DeepLeftChild_ReturnsParent() + { + var root = new FileNode(10); + root.Add(new FileNode(7)); + root.Add(new FileNode(5)); + var parent = root.SearchParent(5); + Assert.Equal(7, parent.Id); + } + + [Fact] + public void SearchParent_DeepRightChild_ReturnsParent() + { + var root = new FileNode(10); + root.Add(new FileNode(15)); + root.Add(new FileNode(12)); + var parent = root.SearchParent(12); + Assert.Equal(15, parent.Id); + } + + [Fact] + public void SearchParent_NotFound_ReturnsNull() + { + var root = new FileNode(10); + var result = root.SearchParent(999); + Assert.Null(result); + } + + // ── Equals ── + + [Fact] + public void Equals_NullObject_ReturnsFalse() + { + var node = new FileNode(1) { Name = "test", Hash = "abc" }; + Assert.False(node.Equals(null)); + } + + [Fact] + public void Equals_NonFileNodeType_ThrowsArgumentException() + { + var node = new FileNode(1) { Name = "test", Hash = "abc" }; + Assert.Throws(() => node.Equals("not a node")); + } + + [Fact] + public void Equals_SameHashAndName_ReturnsTrue() + { + var a = new FileNode(1) { Name = "file.dll", Hash = "abc123" }; + var b = new FileNode(2) { Name = "file.dll", Hash = "abc123" }; + Assert.True(a.Equals(b)); + } + + [Fact] + public void Equals_DifferentHash_ReturnsFalse() + { + var a = new FileNode(1) { Name = "file.dll", Hash = "abc" }; + var b = new FileNode(2) { Name = "file.dll", Hash = "def" }; + Assert.False(a.Equals(b)); + } + + [Fact] + public void Equals_DifferentName_ReturnsFalse() + { + var a = new FileNode(1) { Name = "file1.dll", Hash = "abc" }; + var b = new FileNode(2) { Name = "file2.dll", Hash = "abc" }; + Assert.False(a.Equals(b)); + } + + [Fact] + public void Equals_CaseInsensitiveHash_ReturnsTrue() + { + var a = new FileNode(1) { Name = "file.dll", Hash = "ABC123" }; + var b = new FileNode(2) { Name = "file.dll", Hash = "abc123" }; + Assert.True(a.Equals(b)); + } + + [Fact] + public void Equals_CaseInsensitiveName_ReturnsTrue() + { + var a = new FileNode(1) { Name = "FILE.DLL", Hash = "abc" }; + var b = new FileNode(2) { Name = "file.dll", Hash = "abc" }; + Assert.True(a.Equals(b)); + } + + // ── GetHashCode ── + + [Fact] + public void GetHashCode_SameNameAndHash_SameValue() + { + var a = new FileNode(1) { Name = "file.dll", Hash = "abc" }; + var b = new FileNode(2) { Name = "file.dll", Hash = "abc" }; + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } +} diff --git a/tests/CoreTest/FileSystem/FileTreeTests.cs b/tests/CoreTest/FileSystem/FileTreeTests.cs new file mode 100644 index 00000000..6a7827a6 --- /dev/null +++ b/tests/CoreTest/FileSystem/FileTreeTests.cs @@ -0,0 +1,114 @@ +using GeneralUpdate.Core.FileSystem; + +namespace CoreTest.FileSystem; + +public class FileTreeTests +{ + [Fact] + public void DelNode_EmptyTree_DoesNotThrow() + { + var tree = new FileTree(); + var ex = Record.Exception(() => tree.DelNode(1)); + Assert.Null(ex); + } + + [Fact] + public void DelNode_TargetNotFound_DoesNotThrow() + { + var tree = new FileTree(); + tree.Add(new FileNode(10) { Name = "root" }); + var ex = Record.Exception(() => tree.DelNode(99)); + Assert.Null(ex); + } + + [Fact] + public void DelNode_OnlyRootNoChildren_RootBecomesNull() + { + var tree = new FileTree(); + tree.Add(new FileNode(10) { Name = "root" }); + tree.DelNode(10); + Assert.Null(tree.GetRoot()); + } + + [Fact] + public void DelNode_LeafNodeLeftChild_ParentLeftBecomesNull() + { + var tree = new FileTree(); + tree.Add(new FileNode(10) { Name = "root" }); + tree.Add(new FileNode(5) { Name = "leaf" }); + tree.DelNode(5); + Assert.Null(tree.GetRoot().Left); + } + + [Fact] + public void DelNode_LeafNodeRightChild_ParentRightBecomesNull() + { + var tree = new FileTree(); + tree.Add(new FileNode(10) { Name = "root" }); + tree.Add(new FileNode(15) { Name = "leaf" }); + tree.DelNode(15); + Assert.Null(tree.GetRoot().Right); + } + + [Fact] + public void DelNode_NodeWithTwoChildren_ReplacedWithRightTreeMin() + { + var tree = new FileTree(); + tree.Add(new FileNode(10) { Name = "root" }); + tree.Add(new FileNode(5) { Name = "L" }); + tree.Add(new FileNode(20) { Name = "R" }); + tree.Add(new FileNode(15) { Name = "RL" }); + tree.DelNode(10); + Assert.NotNull(tree.GetRoot()); + Assert.Equal(15, tree.GetRoot().Id); // Right tree min + } + + [Fact] + public void DelNode_NodeWithOnlyLeftChild_ReplacedWithLeftChild() + { + var tree = new FileTree(); + tree.Add(new FileNode(20) { Name = "root" }); + tree.Add(new FileNode(10) { Name = "mid" }); + tree.Add(new FileNode(5) { Name = "leaf" }); + tree.DelNode(10); + Assert.NotNull(tree.GetRoot().Left); + Assert.Equal(5, tree.GetRoot().Left.Id); + } + + [Fact] + public void DelNode_NodeWithOnlyRightChild_ReplacedWithRightChild() + { + // Tree: root=10, with both children. Delete mid=20 (right child of root), + // mid has only a right child leaf=25. Result: root.Right = leaf(25) + var tree = new FileTree(); + tree.Add(new FileNode(10) { Name = "root" }); + tree.Add(new FileNode(5) { Name = "left_child" }); + tree.Add(new FileNode(20) { Name = "mid" }); + tree.Add(new FileNode(25) { Name = "leaf" }); + tree.DelNode(20); + Assert.NotNull(tree.GetRoot().Right); + Assert.Equal(25, tree.GetRoot().Right.Id); + } + + [Fact] + public void Search_EmptyTree_ReturnsNull() + { + var tree = new FileTree(); + Assert.Null(tree.Search(1)); + } + + [Fact] + public void Add_MultipleNodes_BuildsCorrectTree() + { + var tree = new FileTree(new[] + { + new FileNode(10) { Name = "root" }, + new FileNode(5) { Name = "L" }, + new FileNode(15) { Name = "R" } + }); + Assert.NotNull(tree.GetRoot()); + Assert.Equal(10, tree.GetRoot().Id); + Assert.Equal(5, tree.GetRoot().Left.Id); + Assert.Equal(15, tree.GetRoot().Right.Id); + } +} diff --git a/tests/CoreTest/GracefulExitTests.cs b/tests/CoreTest/GracefulExitTests.cs new file mode 100644 index 00000000..0dcee58f --- /dev/null +++ b/tests/CoreTest/GracefulExitTests.cs @@ -0,0 +1,13 @@ +using GeneralUpdate.Core; + +namespace CoreTest; + +public class GracefulExitTests +{ + [Fact] + public async Task ShutdownAsync_ProcessNull_ReturnsWithoutException() + { + var ex = await Record.ExceptionAsync(() => GracefulExit.ShutdownAsync(null)); + Assert.Null(ex); + } +} diff --git a/tests/CoreTest/Ipc/IpcEncryptionTests.cs b/tests/CoreTest/Ipc/IpcEncryptionTests.cs new file mode 100644 index 00000000..1b7f51ff --- /dev/null +++ b/tests/CoreTest/Ipc/IpcEncryptionTests.cs @@ -0,0 +1,101 @@ +using GeneralUpdate.Core.Ipc; + +namespace CoreTest.Ipc; + +public class IpcEncryptionTests +{ + private static readonly byte[] TestKey = System.Security.Cryptography.SHA256.HashData("TestEncryptionKey1234567890!!"u8); + private static readonly byte[] TestIV = new byte[16] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; + + [Fact] + public void EncryptThenDecrypt_RoundTrip_PlaintextMatches() + { + var tempFile = Path.GetTempFileName(); + var original = "Hello, IPC World! 你好世界"u8.ToArray(); + try + { + IpcEncryption.EncryptToFile(original, tempFile, TestKey, TestIV); + var decrypted = IpcEncryption.DecryptFromFile(tempFile, TestKey, TestIV); + Assert.NotNull(decrypted); + Assert.Equal(original, decrypted); + } + finally + { + if (File.Exists(tempFile)) File.Delete(tempFile); + } + } + + [Fact] + public void DecryptFromFile_FileDoesNotExist_ReturnsNull() + { + var result = IpcEncryption.DecryptFromFile( + Path.Combine(Path.GetTempPath(), $"nonexistent_{Guid.NewGuid():N}.enc"), + TestKey, TestIV); + Assert.Null(result); + } + + [Fact] + public void DecryptFromFile_FileDeletedAfterDecryption() + { + var tempFile = Path.GetTempFileName(); + try + { + IpcEncryption.EncryptToFile("test data"u8.ToArray(), tempFile, TestKey, TestIV); + Assert.True(File.Exists(tempFile)); + IpcEncryption.DecryptFromFile(tempFile, TestKey, TestIV); + Assert.False(File.Exists(tempFile)); + } + finally + { + if (File.Exists(tempFile)) File.Delete(tempFile); + } + } + + [Fact] + public void DecryptFromFile_FileDeleteFails_DoesNotThrow() + { + var tempFile = Path.GetTempFileName(); + try + { + IpcEncryption.EncryptToFile("data"u8.ToArray(), tempFile, TestKey, TestIV); + // Decrypt — file will be deleted in finally, exception from delete is swallowed + var ex = Record.Exception(() => IpcEncryption.DecryptFromFile(tempFile, TestKey, TestIV)); + Assert.Null(ex); + } + catch + { + // Cleanup + } + } +} + +public class EncryptedFileProcessInfoProviderTests +{ + [Fact] + public void SendAndReceive_RoundTrip_ProcessInfoPreserved() + { + var provider = new EncryptedFileProcessInfoProvider(); + var info = new GeneralUpdate.Core.Configuration.ProcessInfo( + "MyApp", Path.GetTempPath(), "1.0.0", "2.0.0", + null, System.Text.Encoding.UTF8, ".zip", 30, "secret", + new List { new() { Version = "2.0.0" } }, + "https://report.example.com", "C:\\backup", + null, null, null, null, null, null, null); + + provider.Send(info); + var received = provider.Receive(); + + Assert.NotNull(received); + Assert.Equal("MyApp", received.AppName); + Assert.Equal("1.0.0", received.CurrentVersion); + Assert.Equal("2.0.0", received.LastVersion); + } + + [Fact] + public void Receive_NoFile_ReturnsNull() + { + var provider = new EncryptedFileProcessInfoProvider(); + var result = provider.Receive(); + Assert.Null(result); + } +} diff --git a/tests/CoreTest/Pipeline/PipelineBuilderTests.cs b/tests/CoreTest/Pipeline/PipelineBuilderTests.cs new file mode 100644 index 00000000..1cd94413 --- /dev/null +++ b/tests/CoreTest/Pipeline/PipelineBuilderTests.cs @@ -0,0 +1,56 @@ +using GeneralUpdate.Core.Pipeline; + +namespace CoreTest.Pipeline; + +public class PipelineBuilderTests +{ + private class SpyMiddleware : IMiddleware + { + public bool Invoked { get; private set; } + public Task InvokeAsync(PipelineContext context) + { + Invoked = true; + return Task.CompletedTask; + } + } + + private class ThrowingMiddleware : IMiddleware + { + public Task InvokeAsync(PipelineContext context) + => throw new InvalidOperationException("test failure"); + } + + [Fact] + public async Task Build_EmptyStack_DoesNothing() + { + var builder = new PipelineBuilder(new PipelineContext()); + await builder.Build(); + } + + [Fact] + public async Task Build_SingleMiddleware_Invoked() + { + var builder = new PipelineBuilder(new PipelineContext()); + builder.UseMiddleware(); + await builder.Build(); + // Middleware invoked + } + + [Theory] + [InlineData(false)] + [InlineData(null)] + public void UseMiddlewareIf_ConditionFalseOrNull_DoesNotAdd(bool? condition) + { + var builder = new PipelineBuilder(new PipelineContext()); + builder.UseMiddlewareIf(condition); + Assert.NotNull(builder); + } + + [Fact] + public void UseMiddlewareIf_ConditionTrue_AddsMiddleware() + { + var builder = new PipelineBuilder(new PipelineContext()); + builder.UseMiddlewareIf(true); + Assert.NotNull(builder); + } +} diff --git a/tests/CoreTest/Pipeline/PipelineContextTests.cs b/tests/CoreTest/Pipeline/PipelineContextTests.cs new file mode 100644 index 00000000..db495c14 --- /dev/null +++ b/tests/CoreTest/Pipeline/PipelineContextTests.cs @@ -0,0 +1,98 @@ +using GeneralUpdate.Core.Pipeline; + +namespace CoreTest.Pipeline; + +public class PipelineContextTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Add_InvalidKey_ThrowsArgumentException(string key) + { + var ctx = new PipelineContext(); + var ex = Assert.Throws(() => ctx.Add(key, 42)); + Assert.Contains("Key", ex.Message); + } + + [Fact] + public void AddAndGet_ValueType_RoundTrip() + { + var ctx = new PipelineContext(); + ctx.Add("intKey", 42); + Assert.Equal(42, ctx.Get("intKey")); + } + + [Fact] + public void AddAndGet_ReferenceType_RoundTrip() + { + var ctx = new PipelineContext(); + ctx.Add("strKey", "hello"); + Assert.Equal("hello", ctx.Get("strKey")); + } + + [Fact] + public void AddAndGet_NullValue_RoundTrip() + { + var ctx = new PipelineContext(); + ctx.Add("nullKey", null); + Assert.Null(ctx.Get("nullKey")); + } + + [Fact] + public void Get_WrongType_ReturnsDefault() + { + var ctx = new PipelineContext(); + ctx.Add("key", 42); + var result = ctx.Get("key"); + Assert.Null(result); + } + + [Fact] + public void Get_KeyNotFound_ReturnsDefault() + { + var ctx = new PipelineContext(); + Assert.Equal(0, ctx.Get("nonexistent")); + Assert.Null(ctx.Get("nonexistent")); + } + + [Fact] + public void Add_OverwritesExistingKey() + { + var ctx = new PipelineContext(); + ctx.Add("key", 1); + ctx.Add("key", 2); + Assert.Equal(2, ctx.Get("key")); + } + + [Fact] + public void Remove_ExistingKey_ReturnsTrue() + { + var ctx = new PipelineContext(); + ctx.Add("key", 42); + Assert.True(ctx.Remove("key")); + Assert.False(ctx.ContainsKey("key")); + } + + [Fact] + public void Remove_NonexistentKey_ReturnsFalse() + { + var ctx = new PipelineContext(); + Assert.False(ctx.Remove("nope")); + } + + [Fact] + public void ContainsKey_ExistingKey_ReturnsTrue() + { + var ctx = new PipelineContext(); + ctx.Add("key", 1); + Assert.True(ctx.ContainsKey("key")); + } + + [Fact] + public void ContainsKey_NonexistentKey_ReturnsFalse() + { + var ctx = new PipelineContext(); + Assert.False(ctx.ContainsKey("missing")); + } +} diff --git a/tests/CoreTest/Security/AuthProviderTests.cs b/tests/CoreTest/Security/AuthProviderTests.cs new file mode 100644 index 00000000..ce0b1520 --- /dev/null +++ b/tests/CoreTest/Security/AuthProviderTests.cs @@ -0,0 +1,102 @@ +using GeneralUpdate.Core.Security; + +namespace CoreTest.Security; + +public class AuthProviderTests +{ + [Fact] + public async Task BearerTokenAuthProvider_AppliesAuthorizationHeader() + { + var provider = new BearerTokenAuthProvider("token-abc123"); + var request = new HttpRequestMessage(); + await provider.ApplyAuthAsync(request); + Assert.NotNull(request.Headers.Authorization); + Assert.Equal("Bearer", request.Headers.Authorization.Scheme); + Assert.Equal("token-abc123", request.Headers.Authorization.Parameter); + } + + [Fact] + public void BearerTokenAuthProvider_NullToken_ThrowsArgumentNullException() + { + Assert.Throws(() => new BearerTokenAuthProvider(null)); + } + + [Fact] + public async Task ApiKeyAuthProvider_AppliesCustomHeader() + { + var provider = new ApiKeyAuthProvider("my-api-key-123"); + var request = new HttpRequestMessage(); + await provider.ApplyAuthAsync(request); + Assert.True(request.Headers.Contains("X-Api-Key")); + var values = request.Headers.GetValues("X-Api-Key").ToList(); + Assert.Single(values); + Assert.Equal("my-api-key-123", values[0]); + } + + [Fact] + public void ApiKeyAuthProvider_NullApiKey_ThrowsArgumentNullException() + { + Assert.Throws(() => new ApiKeyAuthProvider(null)); + } + + [Fact] + public async Task ApiKeyAuthProvider_CustomHeaderName() + { + var provider = new ApiKeyAuthProvider("key123", "X-Custom-Header"); + var request = new HttpRequestMessage(); + await provider.ApplyAuthAsync(request); + Assert.True(request.Headers.Contains("X-Custom-Header")); + } + + [Fact] + public async Task NoOpAuthProvider_DoesNotModifyRequest() + { + var provider = new NoOpAuthProvider(); + var request = new HttpRequestMessage(); + var headerCountBefore = request.Headers.Count(); + await provider.ApplyAuthAsync(request); + Assert.Equal(headerCountBefore, request.Headers.Count()); + } + + [Fact] + public void Factory_HmacHasPriority_WhenSecretKeyPresent() + { + var provider = HttpAuthProviderFactory.Create("bearer", "token", "secret"); + Assert.IsType(provider); + } + + [Fact] + public void Factory_TokenWithBearerScheme_ReturnsBearerTokenAuth() + { + var provider = HttpAuthProviderFactory.Create("bearer", "token", null); + Assert.IsType(provider); + } + + [Fact] + public void Factory_TokenWithApiKeyScheme_ReturnsApiKeyAuth() + { + var provider = HttpAuthProviderFactory.Create("apikey", "token", null); + Assert.IsType(provider); + } + + [Fact] + public void Factory_TokenWithUnknownScheme_ReturnsBearerTokenAuth() + { + var provider = HttpAuthProviderFactory.Create("unknown", "token", null); + Assert.IsType(provider); + } + + [Fact] + public void Factory_NoTokenNoSecret_ReturnsNoOp() + { + var provider = HttpAuthProviderFactory.Create(null, null, null); + Assert.IsType(provider); + } + + [Fact] + public void Factory_EmptyToken_ReturnsNoOp() + { + var provider = HttpAuthProviderFactory.Create(null, "", null); + Assert.IsType(provider); + } +}