diff --git a/tests/CoreTest/Bootstrap/BootstrapFullParameterMatrixTests.cs b/tests/CoreTest/Bootstrap/BootstrapFullParameterMatrixTests.cs new file mode 100644 index 00000000..b3647c39 --- /dev/null +++ b/tests/CoreTest/Bootstrap/BootstrapFullParameterMatrixTests.cs @@ -0,0 +1,442 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using GeneralUpdate.Core; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.FileSystem; +using Xunit; + +namespace CoreTest.Bootstrap +{ + /// + /// Full parameter matrix tests -- verifies ALL UpdateOptions constants + /// can be set via .Option() without throwing. Covers 37 options across + /// core, deployment, silent, download, security, reporting, OSS, and + /// blacklist categories. + /// + public class BootstrapFullParameterMatrixTests : IDisposable + { + private readonly string _testDir; + + public BootstrapFullParameterMatrixTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"GU_FullMatrix_{Guid.NewGuid()}"); + Directory.CreateDirectory(_testDir); + } + + public void Dispose() + { + try { Directory.Delete(_testDir, true); } catch { /* ignore */ } + } + + private GeneralUpdateBootstrap B() => new GeneralUpdateBootstrap() + .SetConfig(new Configinfo + { + UpdateUrl = "https://api.example.com", + MainAppName = "MyApp.exe", + ClientVersion = "1.0.0", + InstallPath = _testDir, + AppSecretKey = "key", + Scheme = "https", + Token = "token" + }); + + #region Core + [Fact] public void AppType_Client() => Assert.NotNull(B().Option(UpdateOptions.AppType, AppType.Client)); + [Fact] public void AppType_Upgrade() => Assert.NotNull(B().Option(UpdateOptions.AppType, AppType.Upgrade)); + [Fact] public void AppType_OSS() => Assert.NotNull(B().Option(UpdateOptions.AppType, AppType.OSS)); + [Fact] public void DiffMode_Serial() => Assert.NotNull(B().Option(UpdateOptions.DiffMode, DiffMode.Serial)); + [Fact] public void DiffMode_Parallel() => Assert.NotNull(B().Option(UpdateOptions.DiffMode, DiffMode.Parallel)); + [Fact] public void Encoding_Utf8() => Assert.NotNull(B().Option(UpdateOptions.Encoding, Encoding.UTF8)); + [Fact] public void Encoding_Ascii() => Assert.NotNull(B().Option(UpdateOptions.Encoding, Encoding.ASCII)); + [Fact] public void Format_ZIP() => Assert.NotNull(B().Option(UpdateOptions.Format, "ZIP")); + [Theory][InlineData(10)][InlineData(30)][InlineData(60)][InlineData(300)] + public void DownloadTimeout_Various(int t) => Assert.NotNull(B().Option(UpdateOptions.DownloadTimeout, t)); + [Fact] public void DriveEnabled_True() => Assert.NotNull(B().Option(UpdateOptions.DriveEnabled, true)); + [Fact] public void DriveEnabled_False() => Assert.NotNull(B().Option(UpdateOptions.DriveEnabled, false)); + [Fact] public void PatchEnabled_True() => Assert.NotNull(B().Option(UpdateOptions.PatchEnabled, true)); + [Fact] public void PatchEnabled_False() => Assert.NotNull(B().Option(UpdateOptions.PatchEnabled, false)); + [Fact] public void BackupEnabled_False() => Assert.NotNull(B().Option(UpdateOptions.BackupEnabled, false)); + [Fact] public void Silent_True() => Assert.NotNull(B().Option(UpdateOptions.Silent, true)); + [Fact] public void Mode_Default() => Assert.NotNull(B().Option(UpdateOptions.Mode, UpdateMode.Default)); + [Fact] public void Mode_Scripts() => Assert.NotNull(B().Option(UpdateOptions.Mode, UpdateMode.Scripts)); + #endregion + + #region Deployment + [Fact] public void UpdateUrl_Custom() => Assert.NotNull(B().Option(UpdateOptions.UpdateUrl, "https://update.company.com/api")); + [Fact] public void AppSecretKey_Custom() => Assert.NotNull(B().Option(UpdateOptions.AppSecretKey, "prod-secret")); + [Fact] public void AppName_Custom() => Assert.NotNull(B().Option(UpdateOptions.AppName, "Update.exe")); + [Fact] public void MainAppName_Custom() => Assert.NotNull(B().Option(UpdateOptions.MainAppName, "ProductApp.exe")); + [Fact] public void InstallPath_Custom() => Assert.NotNull(B().Option(UpdateOptions.InstallPath, _testDir)); + [Fact] public void ClientVersion_Custom() => Assert.NotNull(B().Option(UpdateOptions.ClientVersion, "3.1.0-beta")); + [Fact] public void UpgradeClientVersion_Custom() => Assert.NotNull(B().Option(UpdateOptions.UpgradeClientVersion, "2.0.0")); + [Fact] public void Platform_Windows() => Assert.NotNull(B().Option(UpdateOptions.Platform, PlatformType.Windows)); + [Fact] public void Platform_Linux() => Assert.NotNull(B().Option(UpdateOptions.Platform, PlatformType.Linux)); + #endregion + + #region Silent + [Fact] public void SilentAutoInstall_True() => Assert.NotNull(B().Option(UpdateOptions.SilentAutoInstall, true)); + [Theory][InlineData(15)][InlineData(30)][InlineData(60)] + public void SilentPollInterval_Various(int m) => Assert.NotNull(B().Option(UpdateOptions.SilentPollIntervalMinutes, m)); + #endregion + + #region Download Performance + [Theory][InlineData(1)][InlineData(3)][InlineData(5)][InlineData(10)] + public void MaxConcurrency_Various(int c) => Assert.NotNull(B().Option(UpdateOptions.MaxConcurrency, c)); + [Fact] public void EnableResume_False() => Assert.NotNull(B().Option(UpdateOptions.EnableResume, false)); + [Theory][InlineData(1)][InlineData(3)][InlineData(5)] + public void RetryCount_Various(int c) => Assert.NotNull(B().Option(UpdateOptions.RetryCount, c)); + [Fact] public void VerifyChecksum_False() => Assert.NotNull(B().Option(UpdateOptions.VerifyChecksum, false)); + [Fact] public void RetryInterval_Custom() => Assert.NotNull(B().Option(UpdateOptions.RetryInterval, TimeSpan.FromSeconds(3))); + #endregion + + #region Security + [Fact] public void Scheme_Bearer() => Assert.NotNull(B().Option(UpdateOptions.Scheme, "Bearer")); + [Fact] public void Scheme_ApiKey() => Assert.NotNull(B().Option(UpdateOptions.Scheme, "ApiKey")); + [Fact] public void Scheme_HMAC() => Assert.NotNull(B().Option(UpdateOptions.Scheme, "HMAC")); + [Fact] public void Token_Custom() => Assert.NotNull(B().Option(UpdateOptions.Token, "jwt-token-xyz")); + [Fact] public void PermissionScript_Custom() => Assert.NotNull(B().Option(UpdateOptions.PermissionScript, "#!/bin/bash\nchmod +x")); + #endregion + + #region Reporting + [Fact] public void ReportUrl_Custom() => Assert.NotNull(B().Option(UpdateOptions.ReportUrl, "https://telemetry.example.com/report")); + [Fact] public void ProductId_Custom() => Assert.NotNull(B().Option(UpdateOptions.ProductId, "enterprise-pro")); + [Fact] public void UpdateLogUrl_Custom() => Assert.NotNull(B().Option(UpdateOptions.UpdateLogUrl, "https://myapp.com/releases")); + #endregion + + #region OSS + [Fact] public void OSS_AliYun() => Assert.NotNull(B().Option(UpdateOptions.OSSProvider, OssProvider.AliYun)); + [Fact] public void OSS_AWS() => Assert.NotNull(B().Option(UpdateOptions.OSSProvider, OssProvider.AWS)); + [Fact] public void OSSBucketRegion() => Assert.NotNull(B().Option(UpdateOptions.OSSBucketRegion, "cn-shanghai")); + #endregion + + #region Blacklist/Misc + [Fact] public void BlackList_Empty() => Assert.NotNull(B().Option(UpdateOptions.BlackList, BlackListConfig.Empty)); + [Fact] public void BlackList_Configured() => Assert.NotNull(B().Option(UpdateOptions.BlackList, + new BlackListConfig(new List { "*.pdb" }, new List { ".log" }, new List { "logs" }))); + [Fact] public void Bowl_Custom() => Assert.NotNull(B().Option(UpdateOptions.Bowl, "Bowl.exe")); + [Fact] public void Script_Custom() => Assert.NotNull(B().Option(UpdateOptions.Script, "chmod +x /app/Update")); + [Fact] public void Hub_Configured() => Assert.NotNull(B().Option(UpdateOptions.Hub, + new HubConfig { Url = "https://signalr.example.com/hub" })); + #endregion + + #region Full Combination Chains + [Fact] public void Chain_All33Options() + { + var b = new GeneralUpdateBootstrap() + .Option(UpdateOptions.AppType, AppType.Client) + .Option(UpdateOptions.DiffMode, DiffMode.Parallel) + .Option(UpdateOptions.Encoding, Encoding.UTF8) + .Option(UpdateOptions.Format, "ZIP") + .Option(UpdateOptions.DownloadTimeout, 120) + .Option(UpdateOptions.DriveEnabled, false) + .Option(UpdateOptions.PatchEnabled, true) + .Option(UpdateOptions.BackupEnabled, true) + .Option(UpdateOptions.Mode, UpdateMode.Default) + .Option(UpdateOptions.Silent, false) + .Option(UpdateOptions.UpdateUrl, "https://update.example.com/api/v2") + .Option(UpdateOptions.AppSecretKey, "secret-key-2026") + .Option(UpdateOptions.AppName, "Update.exe") + .Option(UpdateOptions.MainAppName, "MyProduct.exe") + .Option(UpdateOptions.InstallPath, _testDir) + .Option(UpdateOptions.ClientVersion, "1.0.0") + .Option(UpdateOptions.UpgradeClientVersion, "0.5.0") + .Option(UpdateOptions.MaxConcurrency, 4) + .Option(UpdateOptions.EnableResume, true) + .Option(UpdateOptions.RetryCount, 5) + .Option(UpdateOptions.VerifyChecksum, true) + .Option(UpdateOptions.RetryInterval, TimeSpan.FromSeconds(2)) + .Option(UpdateOptions.SilentAutoInstall, false) + .Option(UpdateOptions.SilentPollIntervalMinutes, 30) + .Option(UpdateOptions.ReportUrl, "https://telemetry.example.com/report") + .Option(UpdateOptions.ProductId, "my-product-001") + .Option(UpdateOptions.Scheme, "Bearer") + .Option(UpdateOptions.Token, "jwt-token-xyz") + .Option(UpdateOptions.PermissionScript, "#!/bin/bash\nchmod +x") + .Option(UpdateOptions.BlackList, BlackListConfig.Empty) + .Option(UpdateOptions.Bowl, "Bowl.exe") + .Option(UpdateOptions.UpdateLogUrl, "https://example.com/changelog") + .Option(UpdateOptions.Script, "chmod +x /opt/app/Update") + .SetConfig(new Configinfo + { + UpdateUrl = "https://update.example.com/api/v2", + MainAppName = "MyProduct.exe", + ClientVersion = "1.0.0", + InstallPath = _testDir, + AppSecretKey = "secret-key-2026", + Scheme = "Bearer", + Token = "jwt-token-xyz" + }); + Assert.NotNull(b); + } + + [Fact] public void Chain_SilentClient() + { + var b = new GeneralUpdateBootstrap() + .Option(UpdateOptions.AppType, AppType.Client) + .Option(UpdateOptions.Silent, true) + .Option(UpdateOptions.SilentAutoInstall, true) + .Option(UpdateOptions.SilentPollIntervalMinutes, 15) + .SetConfig(new Configinfo { UpdateUrl = "https://api.example.com", MainAppName = "MyApp.exe", ClientVersion = "1.0.0", InstallPath = _testDir, AppSecretKey = "key", Scheme = "https", Token = "token" }); + Assert.NotNull(b); + } + + [Fact] public void Chain_ParallelDiffHighConcurrency() + { + var b = new GeneralUpdateBootstrap() + .Option(UpdateOptions.DiffMode, DiffMode.Parallel).Option(UpdateOptions.MaxConcurrency, 8) + .SetConfig(new Configinfo { UpdateUrl = "https://api.example.com", MainAppName = "MyApp.exe", ClientVersion = "1.0.0", InstallPath = _testDir, AppSecretKey = "key", Scheme = "https", Token = "token" }); + Assert.NotNull(b); + } + + [Fact] public void Chain_UpgradeNoBackup() + { + var b = new GeneralUpdateBootstrap() + .Option(UpdateOptions.AppType, AppType.Upgrade).Option(UpdateOptions.BackupEnabled, false) + .Option(UpdateOptions.PatchEnabled, true).Option(UpdateOptions.VerifyChecksum, true) + .SetConfig(new Configinfo { UpdateUrl = "https://api.example.com", MainAppName = "MyApp.exe", ClientVersion = "1.0.0", InstallPath = _testDir, AppSecretKey = "key", Scheme = "https", Token = "token" }); + Assert.NotNull(b); + } + + [Fact] public void Chain_FullSecurity() + { + var b = new GeneralUpdateBootstrap() + .Option(UpdateOptions.Scheme, "HMAC").Option(UpdateOptions.Token, "hmac-secret") + .Option(UpdateOptions.ReportUrl, "https://telemetry.example.com/report") + .Option(UpdateOptions.VerifyChecksum, true) + .Option(UpdateOptions.PermissionScript, "#!/bin/bash\nchmod +x /opt/app/Update") + .SetConfig(new Configinfo { UpdateUrl = "https://secure.example.com/api", MainAppName = "SecureApp.exe", ClientVersion = "2.0.0", InstallPath = _testDir, AppSecretKey = "secure-key", Scheme = "HMAC", Token = "hmac-secret", ReportUrl = "https://telemetry.example.com/report" }); + Assert.NotNull(b); + } + + [Fact] public void Chain_UpgradeWithExtensions() + { + var b = new GeneralUpdateBootstrap() + .Option(UpdateOptions.AppType, AppType.Upgrade) + .Option(UpdateOptions.ReportUrl, "https://telemetry.example.com/report") + .Option(UpdateOptions.Scheme, "Bearer").Option(UpdateOptions.Token, "jwt") + .SetConfig(new Configinfo { UpdateUrl = "https://api.example.com", MainAppName = "MyApp.exe", ClientVersion = "1.0.0", InstallPath = _testDir, AppSecretKey = "key", Scheme = "Bearer", Token = "jwt" }) + .AddListenerException((s, e) => { }).AddListenerUpdateInfo((s, e) => { }) + .AddCustomOption(new List> { () => true }); + Assert.NotNull(b); + } + + /// + /// Complete production deployment: Client + Upgrade bootstraps configured + /// simultaneously with ALL non-conflicting parameters, hooks, listeners, + /// and extension points. + /// + [Fact] + public void Chain_ClientAndUpgrade_BothFullyConfigured() + { + var sharedConfig = new Configinfo + { + UpdateUrl = "https://update.enterprise.com/api/v2", + AppName = "Update.exe", + MainAppName = "EnterpriseApp.exe", + ClientVersion = "4.2.1", + UpgradeClientVersion = "2.0.0", + InstallPath = _testDir, + AppSecretKey = "enterprise-prod-key-2026", + ProductId = "enterprise-app-v4", + UpdateLogUrl = "https://enterprise.com/releases", + ReportUrl = "https://telemetry.enterprise.com/api/report", + Scheme = "HMAC", + Token = "hmac-prod-secret", + Bowl = "Bowl.exe", + Script = "#!/bin/bash\nset -e\nchmod +x /opt/enterprise/Update", + BlackFiles = new List { "*.pdb", "*.config" }, + BlackFormats = new List { ".log", ".tmp", ".cache", ".etl" }, + SkipDirectorys = new List { "logs", "temp", "cache", "diagnostics" } + }; + + var clientBootstrap = new GeneralUpdateBootstrap() + .Option(UpdateOptions.AppType, AppType.Client) + .Option(UpdateOptions.DiffMode, DiffMode.Parallel) + .Option(UpdateOptions.Encoding, Encoding.UTF8) + .Option(UpdateOptions.Format, "ZIP") + .Option(UpdateOptions.DownloadTimeout, 120) + .Option(UpdateOptions.DriveEnabled, false) + .Option(UpdateOptions.PatchEnabled, true) + .Option(UpdateOptions.BackupEnabled, true) + .Option(UpdateOptions.Mode, UpdateMode.Default) + .Option(UpdateOptions.Silent, false) + .Option(UpdateOptions.UpdateUrl, "https://update.enterprise.com/api/v2") + .Option(UpdateOptions.AppSecretKey, "enterprise-prod-key-2026") + .Option(UpdateOptions.AppName, "Update.exe") + .Option(UpdateOptions.MainAppName, "EnterpriseApp.exe") + .Option(UpdateOptions.InstallPath, _testDir) + .Option(UpdateOptions.ClientVersion, "4.2.1") + .Option(UpdateOptions.UpgradeClientVersion, "2.0.0") + .Option(UpdateOptions.Platform, PlatformType.Windows) + .Option(UpdateOptions.MaxConcurrency, 4) + .Option(UpdateOptions.EnableResume, true) + .Option(UpdateOptions.RetryCount, 5) + .Option(UpdateOptions.VerifyChecksum, true) + .Option(UpdateOptions.RetryInterval, TimeSpan.FromSeconds(2)) + .Option(UpdateOptions.Scheme, "HMAC") + .Option(UpdateOptions.Token, "hmac-prod-secret") + .Option(UpdateOptions.PermissionScript, "#!/bin/bash\nchmod +x /opt/enterprise/Update") + .Option(UpdateOptions.ReportUrl, "https://telemetry.enterprise.com/api/report") + .Option(UpdateOptions.ProductId, "enterprise-app-v4") + .Option(UpdateOptions.UpdateLogUrl, "https://enterprise.com/releases") + .Option(UpdateOptions.BlackList, new BlackListConfig( + new List { "*.pdb", "*.config" }, + new List { ".log", ".tmp" }, + new List { "logs", "temp" })) + .Option(UpdateOptions.Bowl, "Bowl.exe") + .Option(UpdateOptions.Script, "chmod +x /opt/enterprise/Update") + .SetConfig(sharedConfig) + .AddListenerUpdatePrecheck(args => + { + var hour = DateTime.Now.Hour; + return hour < 2 || hour > 6; + }) + .AddListenerUpdateInfo((s, e) => { }) + .AddListenerMultiAllDownloadCompleted((s, e) => { }) + .AddListenerMultiDownloadCompleted((s, e) => { }) + .AddListenerMultiDownloadError((s, e) => { }) + .AddListenerMultiDownloadStatistics((s, e) => { }) + .AddListenerException((s, e) => { }) + .AddCustomOption(new List> + { + () => true, () => true, () => true + }); + + Assert.NotNull(clientBootstrap); + + var upgradeBootstrap = new GeneralUpdateBootstrap() + .Option(UpdateOptions.AppType, AppType.Upgrade) + .Option(UpdateOptions.DiffMode, DiffMode.Parallel) + .Option(UpdateOptions.Encoding, Encoding.UTF8) + .Option(UpdateOptions.Format, "ZIP") + .Option(UpdateOptions.DownloadTimeout, 30) + .Option(UpdateOptions.DriveEnabled, true) + .Option(UpdateOptions.PatchEnabled, true) + .Option(UpdateOptions.BackupEnabled, false) + .Option(UpdateOptions.Mode, UpdateMode.Default) + .Option(UpdateOptions.AppName, "Update.exe") + .Option(UpdateOptions.MainAppName, "EnterpriseApp.exe") + .Option(UpdateOptions.InstallPath, _testDir) + .Option(UpdateOptions.ClientVersion, "4.2.1") + .Option(UpdateOptions.Platform, PlatformType.Windows) + .Option(UpdateOptions.MaxConcurrency, 2) + .Option(UpdateOptions.VerifyChecksum, true) + .Option(UpdateOptions.RetryInterval, TimeSpan.FromSeconds(1)) + .Option(UpdateOptions.Scheme, "HMAC") + .Option(UpdateOptions.Token, "hmac-prod-secret") + .Option(UpdateOptions.PermissionScript, "#!/bin/bash\nchmod +x /opt/enterprise/Update") + .Option(UpdateOptions.ReportUrl, "https://telemetry.enterprise.com/api/report") + .Option(UpdateOptions.ProductId, "enterprise-app-v4") + .Option(UpdateOptions.BlackList, BlackListConfig.Empty) + .Option(UpdateOptions.Script, "chmod +x /opt/enterprise/Update") + .SetConfig(sharedConfig) + .AddListenerException((s, e) => { }) + .AddListenerUpdateInfo((s, e) => { }); + + Assert.NotNull(upgradeBootstrap); + Assert.NotSame(clientBootstrap, upgradeBootstrap); + } + + /// + /// Real-world developer workflow: configure both Client and Upgrade + /// bootstraps with hooks, reporter, and full extension chain. + /// + [Fact] + public void Chain_ClientAndUpgrade_CompleteDeveloperWorkflow() + { + var installPath = _testDir; + var updateUrl = "https://update.myapp.com/api"; + var mainApp = "MyApp.exe"; + var currentVersion = "3.0.0"; + + var client = new GeneralUpdateBootstrap() + .Option(UpdateOptions.AppType, AppType.Client) + .Option(UpdateOptions.DiffMode, DiffMode.Parallel) + .Option(UpdateOptions.UpdateUrl, updateUrl) + .Option(UpdateOptions.AppName, "Update.exe") + .Option(UpdateOptions.MainAppName, mainApp) + .Option(UpdateOptions.InstallPath, installPath) + .Option(UpdateOptions.ClientVersion, currentVersion) + .Option(UpdateOptions.UpgradeClientVersion, "2.0.0") + .Option(UpdateOptions.Encoding, Encoding.UTF8) + .Option(UpdateOptions.Format, "ZIP") + .Option(UpdateOptions.DownloadTimeout, 120) + .Option(UpdateOptions.PatchEnabled, true) + .Option(UpdateOptions.BackupEnabled, true) + .Option(UpdateOptions.MaxConcurrency, 4) + .Option(UpdateOptions.EnableResume, true) + .Option(UpdateOptions.RetryCount, 5) + .Option(UpdateOptions.VerifyChecksum, true) + .Option(UpdateOptions.RetryInterval, TimeSpan.FromSeconds(2)) + .Option(UpdateOptions.Scheme, "Bearer") + .Option(UpdateOptions.Token, "client-jwt") + .Option(UpdateOptions.ReportUrl, "https://telemetry.myapp.com/report") + .Option(UpdateOptions.ProductId, "myapp-pro") + .Option(UpdateOptions.UpdateLogUrl, "https://myapp.com/changelog") + .Option(UpdateOptions.BlackList, new BlackListConfig( + new List { "*.pdb" }, + new List { ".log", ".tmp" }, + new List { "logs", "temp" })) + .Option(UpdateOptions.Bowl, "Bowl.exe") + .Option(UpdateOptions.Platform, PlatformType.Windows) + .SetConfig(new Configinfo + { + UpdateUrl = updateUrl, AppName = "Update.exe", MainAppName = mainApp, + ClientVersion = currentVersion, UpgradeClientVersion = "2.0.0", + InstallPath = installPath, AppSecretKey = "myapp-key", ProductId = "myapp-pro", + Scheme = "Bearer", Token = "client-jwt", + ReportUrl = "https://telemetry.myapp.com/report", + UpdateLogUrl = "https://myapp.com/changelog", Bowl = "Bowl.exe", + BlackFiles = new List { "*.pdb" }, + BlackFormats = new List { ".log", ".tmp" }, + SkipDirectorys = new List { "logs", "temp" } + }) + .AddListenerUpdateInfo((s, e) => { }) + .AddListenerMultiDownloadCompleted((s, e) => { }) + .AddListenerMultiAllDownloadCompleted((s, e) => { }) + .AddListenerMultiDownloadError((s, e) => { }) + .AddListenerMultiDownloadStatistics((s, e) => { }) + .AddListenerException((s, e) => { }) + .AddListenerUpdatePrecheck(args => false) + .AddCustomOption(new List> { () => Directory.Exists(installPath), () => true }); + + Assert.NotNull(client); + + var upgrade = new GeneralUpdateBootstrap() + .Option(UpdateOptions.AppType, AppType.Upgrade) + .Option(UpdateOptions.DiffMode, DiffMode.Parallel) + .Option(UpdateOptions.AppName, "Update.exe") + .Option(UpdateOptions.MainAppName, mainApp) + .Option(UpdateOptions.InstallPath, installPath) + .Option(UpdateOptions.ClientVersion, currentVersion) + .Option(UpdateOptions.Encoding, Encoding.UTF8) + .Option(UpdateOptions.Format, "ZIP") + .Option(UpdateOptions.PatchEnabled, true) + .Option(UpdateOptions.VerifyChecksum, true) + .Option(UpdateOptions.MaxConcurrency, 2) + .Option(UpdateOptions.RetryCount, 3) + .Option(UpdateOptions.RetryInterval, TimeSpan.FromSeconds(1)) + .Option(UpdateOptions.Scheme, "Bearer") + .Option(UpdateOptions.Token, "upgrade-jwt") + .Option(UpdateOptions.ReportUrl, "https://telemetry.myapp.com/report") + .Option(UpdateOptions.ProductId, "myapp-pro") + .Option(UpdateOptions.BlackList, BlackListConfig.Empty) + .Option(UpdateOptions.Platform, PlatformType.Windows) + .SetConfig(new Configinfo + { + UpdateUrl = updateUrl, AppName = "Update.exe", MainAppName = mainApp, + ClientVersion = currentVersion, InstallPath = installPath, + AppSecretKey = "myapp-key", Scheme = "Bearer", Token = "upgrade-jwt", + ReportUrl = "https://telemetry.myapp.com/report" + }) + .AddListenerException((s, e) => { }) + .AddListenerUpdateInfo((s, e) => { }); + + Assert.NotNull(upgrade); + Assert.NotSame(client, upgrade); + } + #endregion + } +} diff --git a/tests/CoreTest/Bootstrap/BootstrapHooksAndExtensionsTests.cs b/tests/CoreTest/Bootstrap/BootstrapHooksAndExtensionsTests.cs new file mode 100644 index 00000000..612416cd --- /dev/null +++ b/tests/CoreTest/Bootstrap/BootstrapHooksAndExtensionsTests.cs @@ -0,0 +1,486 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using GeneralUpdate.Core; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Download; +using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.Download.Reporting; +using GeneralUpdate.Core.Event; +using GeneralUpdate.Core.Hooks; +using Xunit; + +namespace CoreTest.Bootstrap +{ + /// + /// Integration tests for Hooks mechanism, UpdateReporter, + /// IUpdateEventListener, and extension point models. + /// + /// Covers: + /// - IUpdateHooks lifecycle (OnBeforeUpdate, OnDownloadCompleted, OnAfterUpdate, OnError, OnBeforeStartApp) + /// - Custom IUpdateHooks implementation with lifecycle tracking + /// - NoOpUpdateHooks default behavior + /// - UpdateContext and DownloadContext data models + /// - IUpdateReporter / UpdateReport / UpdateEvent types + /// - IUpdateEventListener batch listener + /// - Security/Scheme extensibility via UpdateOptions + /// - HubConfig model + /// + public class BootstrapHooksAndExtensionsTests : IDisposable + { + private readonly string _testDir; + + public BootstrapHooksAndExtensionsTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"GU_HooksExt_{Guid.NewGuid()}"); + Directory.CreateDirectory(_testDir); + } + + public void Dispose() + { + try { Directory.Delete(_testDir, true); } catch { /* ignore */ } + } + + #region Custom TrackingHooks + + private sealed class TrackingHooks : IUpdateHooks + { + public bool BeforeUpdateCalled { get; private set; } + public bool DownloadCompletedCalled { get; private set; } + public bool AfterUpdateCalled { get; private set; } + public bool ErrorCalled { get; private set; } + public bool BeforeStartAppCalled { get; private set; } + + public UpdateContext? BeforeCtx { get; private set; } + public DownloadContext? DownloadCtx { get; private set; } + public Exception? CapturedError { get; private set; } + + public Task OnBeforeUpdateAsync(UpdateContext ctx) + { + BeforeUpdateCalled = true; + BeforeCtx = ctx; + return Task.FromResult(true); + } + + public Task OnDownloadCompletedAsync(DownloadContext ctx) + { + DownloadCompletedCalled = true; + DownloadCtx = ctx; + return Task.CompletedTask; + } + + public Task OnAfterUpdateAsync(UpdateContext ctx) + { + AfterUpdateCalled = true; + return Task.CompletedTask; + } + + public Task OnUpdateErrorAsync(UpdateContext ctx, Exception ex) + { + ErrorCalled = true; + CapturedError = ex; + return Task.CompletedTask; + } + + public Task OnBeforeStartAppAsync(UpdateContext ctx) + { + BeforeStartAppCalled = true; + return Task.CompletedTask; + } + } + + #endregion + + #region Hook Lifecycle + + [Fact] + public void TrackingHooks_InitialState_AllFlagsFalse() + { + var hooks = new TrackingHooks(); + Assert.False(hooks.BeforeUpdateCalled); + Assert.False(hooks.DownloadCompletedCalled); + Assert.False(hooks.AfterUpdateCalled); + Assert.False(hooks.ErrorCalled); + Assert.False(hooks.BeforeStartAppCalled); + } + + [Fact] + public async Task TrackingHooks_OnBeforeUpdate_ReturnsTrueAndRecordsContext() + { + var hooks = new TrackingHooks(); + var ctx = new UpdateContext("MyApp.exe", "/install", "1.0.0", "2.0.0", AppType.Client); + + var result = await hooks.OnBeforeUpdateAsync(ctx); + + Assert.True(result); + Assert.True(hooks.BeforeUpdateCalled); + Assert.NotNull(hooks.BeforeCtx); + Assert.Equal("MyApp.exe", hooks.BeforeCtx.AppName); + Assert.Equal("1.0.0", hooks.BeforeCtx.CurrentVersion); + Assert.Equal("2.0.0", hooks.BeforeCtx.TargetVersion); + Assert.Equal(AppType.Client, hooks.BeforeCtx.AppType); + } + + [Fact] + public async Task TrackingHooks_OnBeforeUpdate_CanReject() + { + var rejectingHooks = new RejectingHooks(); + var ctx = new UpdateContext("App.exe", "/app", "1.0.0", "2.0.0", AppType.Client); + + var result = await rejectingHooks.OnBeforeUpdateAsync(ctx); + + Assert.False(result, "Hook should be able to reject update"); + } + + private sealed class RejectingHooks : IUpdateHooks + { + public Task OnBeforeUpdateAsync(UpdateContext ctx) => Task.FromResult(false); + public Task OnDownloadCompletedAsync(DownloadContext ctx) => Task.CompletedTask; + public Task OnAfterUpdateAsync(UpdateContext ctx) => Task.CompletedTask; + public Task OnUpdateErrorAsync(UpdateContext ctx, Exception ex) => Task.CompletedTask; + public Task OnBeforeStartAppAsync(UpdateContext ctx) => Task.CompletedTask; + } + + [Fact] + public async Task TrackingHooks_OnDownloadCompleted_Success() + { + var hooks = new TrackingHooks(); + var ctx = new DownloadContext("update.zip", "2.0.0", 50 * 1024 * 1024L, TimeSpan.FromSeconds(30), "/tmp/update.zip", true); + + await hooks.OnDownloadCompletedAsync(ctx); + + Assert.True(hooks.DownloadCompletedCalled); + Assert.NotNull(hooks.DownloadCtx); + Assert.Equal("update.zip", hooks.DownloadCtx.AssetName); + Assert.Equal("2.0.0", hooks.DownloadCtx.Version); + Assert.True(hooks.DownloadCtx.Success); + } + + [Fact] + public async Task TrackingHooks_OnDownloadCompleted_Failure() + { + var hooks = new TrackingHooks(); + var ctx = new DownloadContext("corrupt.zip", "2.0.0", 0, TimeSpan.FromSeconds(5), null, false); + + await hooks.OnDownloadCompletedAsync(ctx); + + Assert.True(hooks.DownloadCompletedCalled); + Assert.NotNull(hooks.DownloadCtx); + Assert.False(hooks.DownloadCtx.Success); + } + + [Fact] + public async Task TrackingHooks_OnAfterUpdate_RecordsCall() + { + var hooks = new TrackingHooks(); + var ctx = new UpdateContext("MyApp.exe", "/install", "1.0.0", "2.0.0", AppType.Client); + + await hooks.OnAfterUpdateAsync(ctx); + + Assert.True(hooks.AfterUpdateCalled); + } + + [Fact] + public async Task TrackingHooks_OnUpdateError_CapturesException() + { + var hooks = new TrackingHooks(); + var ctx = new UpdateContext("MyApp.exe", "/install", "1.0.0", "2.0.0", AppType.Client); + var ex = new InvalidOperationException("Hash verification failed"); + + await hooks.OnUpdateErrorAsync(ctx, ex); + + Assert.True(hooks.ErrorCalled); + Assert.NotNull(hooks.CapturedError); + Assert.Equal("Hash verification failed", hooks.CapturedError.Message); + } + + [Fact] + public async Task TrackingHooks_OnBeforeStartApp_RecordsCall() + { + var hooks = new TrackingHooks(); + var ctx = new UpdateContext("MyApp.exe", "/install", "2.0.0", null, AppType.Client); + + await hooks.OnBeforeStartAppAsync(ctx); + + Assert.True(hooks.BeforeStartAppCalled); + } + + [Fact] + public async Task TrackingHooks_FullLifecycle_AllFiveMethodsCalled() + { + var hooks = new TrackingHooks(); + var beforeCtx = new UpdateContext("App.exe", "/app", "1.0.0", "2.0.0", AppType.Client); + var downloadCtx = new DownloadContext("pkg.zip", "2.0.0", 100, TimeSpan.FromSeconds(10), "/tmp/pkg.zip", true); + var afterCtx = new UpdateContext("App.exe", "/app", "2.0.0", null, AppType.Client); + + await hooks.OnBeforeUpdateAsync(beforeCtx); + await hooks.OnDownloadCompletedAsync(downloadCtx); + await hooks.OnAfterUpdateAsync(afterCtx); + await hooks.OnBeforeStartAppAsync(afterCtx); + + Assert.True(hooks.BeforeUpdateCalled); + Assert.True(hooks.DownloadCompletedCalled); + Assert.True(hooks.AfterUpdateCalled); + Assert.True(hooks.BeforeStartAppCalled); + Assert.False(hooks.ErrorCalled); // No error injected + } + + #endregion + + #region NoOpUpdateHooks + + [Fact] + public async Task NoOpUpdateHooks_AllMethods_ReturnDefaults() + { + var hooks = new NoOpUpdateHooks(); + var ctx = new UpdateContext("App.exe", "/app", "1.0.0", "2.0.0", AppType.Client); + var dlCtx = new DownloadContext("pkg.zip", "2.0.0", 100, TimeSpan.FromSeconds(10), "/tmp/pkg.zip", true); + + var beforeResult = await hooks.OnBeforeUpdateAsync(ctx); + Assert.True(beforeResult); + + await hooks.OnDownloadCompletedAsync(dlCtx); + await hooks.OnAfterUpdateAsync(ctx); + await hooks.OnUpdateErrorAsync(ctx, new Exception("test")); + await hooks.OnBeforeStartAppAsync(ctx); + // No-op hooks should never throw + } + + #endregion + + #region UpdateContext & DownloadContext + + [Fact] + public void UpdateContext_AllFields_SetCorrectly() + { + var ctx = new UpdateContext("MyApp.exe", "/opt/app", "3.2.1", "4.0.0", AppType.Client); + + Assert.Equal("MyApp.exe", ctx.AppName); + Assert.Equal("/opt/app", ctx.InstallPath); + Assert.Equal("3.2.1", ctx.CurrentVersion); + Assert.Equal("4.0.0", ctx.TargetVersion); + Assert.Equal(AppType.Client, ctx.AppType); + } + + [Fact] + public void UpdateContext_UpgradeType() + { + var ctx = new UpdateContext("Update.exe", "/opt/updater", "1.0.0", "1.5.0", AppType.Upgrade); + Assert.Equal(AppType.Upgrade, ctx.AppType); + } + + [Fact] + public void UpdateContext_TargetVersionCanBeNull() + { + var ctx = new UpdateContext("App.exe", "/app", "2.0.0", null, AppType.Client); + Assert.Null(ctx.TargetVersion); + } + + [Fact] + public void DownloadContext_Successful() + { + var ctx = new DownloadContext("client-v2.0.0.zip", "2.0.0", + 1024L * 1024 * 50, TimeSpan.FromMinutes(2), "/tmp/update/client-v2.0.0.zip", true); + + Assert.Equal("client-v2.0.0.zip", ctx.AssetName); + Assert.Equal("2.0.0", ctx.Version); + Assert.Equal(52428800, ctx.TotalBytes); + Assert.Equal(TimeSpan.FromMinutes(2), ctx.Duration); + Assert.Equal("/tmp/update/client-v2.0.0.zip", ctx.LocalPath); + Assert.True(ctx.Success); + } + + [Fact] + public void DownloadContext_Failed() + { + var ctx = new DownloadContext("failed-pkg.zip", "2.0.0", 0, TimeSpan.FromSeconds(3), null, false); + + Assert.False(ctx.Success); + Assert.Null(ctx.LocalPath); + } + + #endregion + + #region UpdateReport / IUpdateReporter + + [Fact] + public void UpdateReport_StartedEvent() + { + var report = new UpdateReport("MyApp.exe", "1.0.0", "2.0.0", + UpdateEvent.UpdateStarted, AppType.Client, + DateTimeOffset.UtcNow); + + Assert.Equal(UpdateEvent.UpdateStarted, report.Event); + Assert.Equal("MyApp.exe", report.AppName); + Assert.Equal("1.0.0", report.FromVersion); + Assert.Equal("2.0.0", report.ToVersion); + Assert.Equal(AppType.Client, report.AppType); + } + + [Fact] + public void UpdateReport_FailedWithError() + { + var report = new UpdateReport("MyApp.exe", "1.0.0", "2.0.0", + UpdateEvent.UpdateFailed, AppType.Client, + DateTimeOffset.UtcNow, + ErrorMessage: "Disk space insufficient", + DurationMs: 15000.0); + + Assert.Equal(UpdateEvent.UpdateFailed, report.Event); + Assert.Equal("Disk space insufficient", report.ErrorMessage); + Assert.Equal(15000.0, report.DurationMs); + } + + [Fact] + public void UpdateReport_DownloadCompletedWithDuration() + { + var report = new UpdateReport("MyApp.exe", "1.0.0", "2.0.0", + UpdateEvent.DownloadCompleted, AppType.Client, + DateTimeOffset.UtcNow, + DurationMs: 45200.5); + + Assert.Equal(UpdateEvent.DownloadCompleted, report.Event); + Assert.Equal(45200.5, report.DurationMs); + } + + [Fact] + public void UpdateEvent_AllValues_AreDefined() + { + var values = Enum.GetValues(); + Assert.Contains(UpdateEvent.UpdateStarted, values); + Assert.Contains(UpdateEvent.DownloadCompleted, values); + Assert.Contains(UpdateEvent.UpdateApplied, values); + Assert.Contains(UpdateEvent.UpdateFailed, values); + Assert.Contains(UpdateEvent.AppStarted, values); + } + + #endregion + + #region IUpdateEventListener + + [Fact] + public void UpdateEventListener_AllMethods_AreCallable() + { + var listener = new TestEventListener(); + var versionInfo = new VersionInfo { Version = "2.0.0", Url = "https://cdn.example.com/pkg.zip", Format = "ZIP" }; + + listener.OnAllDownloadCompleted(new MultiAllDownloadCompletedEventArgs(true, new List<(object, string)>())); + listener.OnDownloadCompleted(new MultiDownloadCompletedEventArgs(versionInfo, true)); + listener.OnDownloadError(new MultiDownloadErrorEventArgs(new Exception("test"), versionInfo)); + listener.OnDownloadStatistics(new MultiDownloadStatisticsEventArgs(versionInfo, TimeSpan.Zero, "0 B/s", 0, 0, 0)); + listener.OnUpdateInfo(new UpdateInfoEventArgs(new VersionRespDTO { Code = 200 })); + listener.OnException(new ExceptionEventArgs(new Exception("test"), "test")); + listener.OnProgress(new ProgressEventArgs(new DownloadProgress("update.zip", 50L * 1024 * 1024, 100L * 1024 * 1024, 50.0, DownloadStatus.Downloading))); + Assert.True(listener.AllDownloadCalled); + Assert.True(listener.DownloadCompletedCalled); + Assert.True(listener.DownloadErrorCalled); + Assert.True(listener.StatisticsCalled); + Assert.True(listener.UpdateInfoCalled); + Assert.True(listener.ExceptionCalled); + Assert.True(listener.ProgressCalled); + } + + private sealed class TestEventListener : IUpdateEventListener + { + public bool AllDownloadCalled { get; private set; } + public bool DownloadCompletedCalled { get; private set; } + public bool DownloadErrorCalled { get; private set; } + public bool StatisticsCalled { get; private set; } + public bool UpdateInfoCalled { get; private set; } + public bool ExceptionCalled { get; private set; } + public bool ProgressCalled { get; private set; } + public bool CustomEventCalled { get; private set; } + + public void OnAllDownloadCompleted(MultiAllDownloadCompletedEventArgs e) => AllDownloadCalled = true; + public void OnDownloadCompleted(MultiDownloadCompletedEventArgs e) => DownloadCompletedCalled = true; + public void OnDownloadError(MultiDownloadErrorEventArgs e) => DownloadErrorCalled = true; + public void OnDownloadStatistics(MultiDownloadStatisticsEventArgs e) => StatisticsCalled = true; + public void OnUpdateInfo(UpdateInfoEventArgs e) => UpdateInfoCalled = true; + public void OnException(ExceptionEventArgs e) => ExceptionCalled = true; + public void OnProgress(ProgressEventArgs e) => ProgressCalled = true; + public void OnCustomEvent(string eventName, EventArgs e) => CustomEventCalled = true; + } + + #endregion + + #region Security Extensibility + + [Fact] + public void AuthSchemeAndToken_CanBeConfigured() + { + var b = new GeneralUpdateBootstrap() + .Option(UpdateOptions.Scheme, "Bearer") + .Option(UpdateOptions.Token, "jwt-token-abc123") + .SetConfig(new Configinfo + { + UpdateUrl = "https://api.example.com", + MainAppName = "MyApp.exe", + ClientVersion = "1.0.0", + InstallPath = _testDir, + AppSecretKey = "key", + Scheme = "Bearer", + Token = "jwt-token-abc123" + }); + Assert.NotNull(b); + } + + [Fact] + public void AllAuthSchemes_CanBeConfigured() + { + foreach (var scheme in new[] { "Bearer", "ApiKey", "Basic", "HMAC" }) + { + var b = new GeneralUpdateBootstrap() + .Option(UpdateOptions.Scheme, scheme) + .Option(UpdateOptions.Token, "test-token") + .SetConfig(new Configinfo + { + UpdateUrl = "https://api.example.com", + MainAppName = "MyApp.exe", + ClientVersion = "1.0.0", + InstallPath = _testDir, + AppSecretKey = "key", + Scheme = scheme, + Token = "test-token" + }); + Assert.NotNull(b); + } + } + + #endregion + + #region Full Extension Chain + + [Fact] + public void Bootstrap_FullExtensions_AllConfigured() + { + var b = new GeneralUpdateBootstrap() + .Option(UpdateOptions.AppType, AppType.Client) + .Option(UpdateOptions.UpdateUrl, "https://update.example.com/api") + .Option(UpdateOptions.ReportUrl, "https://telemetry.example.com/report") + .Option(UpdateOptions.Scheme, "Bearer") + .Option(UpdateOptions.Token, "jwt-token") + .Option(UpdateOptions.PermissionScript, "#!/bin/bash\nchmod +x /opt/app/Update") + .SetConfig(new Configinfo + { + UpdateUrl = "https://update.example.com/api", + MainAppName = "MyApp.exe", + ClientVersion = "1.0.0", + InstallPath = _testDir, + AppSecretKey = "key", + Scheme = "Bearer", + Token = "jwt-token", + ReportUrl = "https://telemetry.example.com/report" + }) + .AddListenerUpdateInfo((s, e) => { }) + .AddListenerException((s, e) => { }) + .AddListenerMultiAllDownloadCompleted((s, e) => { }) + .AddListenerUpdatePrecheck(args => false) + .AddCustomOption(new List> { () => true }); + + Assert.NotNull(b); + } + + #endregion + } +} diff --git a/tests/CoreTest/Configuration/ConfigurationModelsTests.cs b/tests/CoreTest/Configuration/ConfigurationModelsTests.cs new file mode 100644 index 00000000..89a936e3 --- /dev/null +++ b/tests/CoreTest/Configuration/ConfigurationModelsTests.cs @@ -0,0 +1,371 @@ +using System; +using System.Collections.Generic; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Download; +using GeneralUpdate.Core.Download.Reporting; +using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.FileSystem; +using Xunit; + +namespace CoreTest.Configuration +{ + /// + /// Unit tests for configuration models used across the update framework. + /// Covers: + /// - BlackListConfig (new config-based blacklist with IReadOnlyList) + /// - HubConfig (SignalR hub configuration) + /// - DownloadAsset / DownloadPlan / DownloadProgress / DownloadResult (download pipeline models) + /// - DownloadStatus / DownloadPriority enums + /// - AppType / DiffMode / UpdateMode / PlatformType / OssProvider enums + /// - UpdateOption<T> value semantics + /// - UpdateReport / UpdateEvent types + /// + public class ConfigurationModelsTests + { + #region BlackListConfig + + [Fact] + public void BlackListConfig_Empty_HasAllNullLists() + { + var config = BlackListConfig.Empty; + Assert.NotNull(config); + Assert.Null(config.BlackFiles); + Assert.Null(config.BlackFormats); + Assert.Null(config.SkipDirectorys); + } + + [Fact] + public void BlackListConfig_WithAllFields_SetsCorrectly() + { + var config = new BlackListConfig( + BlackFiles: new List { "*.pdb", "*.config", "secret.key" }, + BlackFormats: new List { ".log", ".tmp", ".cache" }, + SkipDirectorys: new List { "logs", "temp", "__backups__" } + ); + + Assert.NotNull(config.BlackFiles); + Assert.Equal(3, config.BlackFiles.Count); + Assert.Contains("*.pdb", config.BlackFiles); + Assert.NotNull(config.BlackFormats); + Assert.Equal(3, config.BlackFormats.Count); + Assert.NotNull(config.SkipDirectorys); + Assert.Equal(3, config.SkipDirectorys.Count); + } + + [Fact] + public void BlackListConfig_Partial_SingleListOnly() + { + var config = new BlackListConfig( + BlackFiles: new List { "*.pdb" }, + BlackFormats: null, + SkipDirectorys: null + ); + + Assert.Single(config.BlackFiles); + Assert.Null(config.BlackFormats); + Assert.Null(config.SkipDirectorys); + } + + #endregion + + #region HubConfig + + [Fact] + public void HubConfig_WithUrl_DefaultsReasonable() + { + var config = new HubConfig { Url = "https://signalr.example.com/hub" }; + + Assert.Equal("https://signalr.example.com/hub", config.Url); + Assert.Equal(TimeSpan.FromSeconds(5), config.ReconnectDelay); + Assert.Equal(10, config.MaxReconnectAttempts); + } + + [Fact] + public void HubConfig_AllFields_Customized() + { + var config = new HubConfig + { + Url = "wss://push.example.com/update-hub", + ReconnectDelay = TimeSpan.FromSeconds(10), + MaxReconnectAttempts = 20 + }; + + Assert.Equal("wss://push.example.com/update-hub", config.Url); + Assert.Equal(TimeSpan.FromSeconds(10), config.ReconnectDelay); + Assert.Equal(20, config.MaxReconnectAttempts); + } + + #endregion + + #region DownloadAsset + + [Fact] + public void DownloadAsset_AllFields_ConstructsCorrectly() + { + var asset = new DownloadAsset( + Name: "client-app-v2.0.0.zip", + Url: "https://cdn.example.com/packages/v2.0.0.zip", + Size: 100L * 1024 * 1024, + SHA256: "sha256:abc123def456", + Version: "2.0.0", + Priority: DownloadPriority.Normal + ); + + Assert.Equal("client-app-v2.0.0.zip", asset.Name); + Assert.Equal("2.0.0", asset.Version); + Assert.Equal("https://cdn.example.com/packages/v2.0.0.zip", asset.Url); + Assert.Equal("sha256:abc123def456", asset.SHA256); + Assert.Equal(104857600, asset.Size); + Assert.Equal(DownloadPriority.Normal, asset.Priority); + Assert.False(asset.IsCrossVersion); + Assert.False(asset.IsForcibly); + Assert.False(asset.IsFreeze); + } + + [Fact] + public void DownloadAsset_CrossVersion_IsForcibly() + { + var asset = new DownloadAsset( + Name: "critical-patch.zip", + Url: "https://cdn.example.com/security/hotfix.zip", + Size: 5L * 1024 * 1024, + SHA256: null, + Version: "2.0.1-hotfix", + Priority: DownloadPriority.High, + IsForcibly: true, + IsCrossVersion: true, + FromVersion: "2.0.0" + ); + + Assert.Equal(DownloadPriority.High, asset.Priority); + Assert.True(asset.IsForcibly); + Assert.True(asset.IsCrossVersion); + Assert.Equal("2.0.0", asset.FromVersion); + } + + #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_WithAssets_HasCorrectCount() + { + var assets = new List + { + new("update-v2.zip", "https://cdn.example.com/v2.zip", 50 * 1024 * 1024, null, "2.0.0"), + new("patch-v2.1.zip", "https://cdn.example.com/v2.1.zip", 5 * 1024 * 1024, null, "2.1.0") + }; + var plan = new DownloadPlan(assets, false); + + Assert.True(plan.HasAssets); + Assert.Equal(2, plan.Assets.Count); + } + + #endregion + + #region DownloadProgress + + [Fact] + public void DownloadProgress_Pending() + { + var progress = new DownloadProgress("update.zip", 0, 50L * 1024 * 1024, 0.0, DownloadStatus.Pending); + + Assert.Equal(DownloadStatus.Pending, progress.Status); + Assert.Equal(0, progress.BytesDownloaded); + Assert.Equal(0.0, progress.Percentage); + } + + [Fact] + public void DownloadProgress_HalfComplete() + { + var progress = new DownloadProgress("update.zip", 25L * 1024 * 1024, 50L * 1024 * 1024, 50.0, DownloadStatus.Downloading); + + Assert.Equal(DownloadStatus.Downloading, progress.Status); + Assert.Equal(50.0, progress.Percentage); + } + + [Fact] + public void DownloadProgress_Completed() + { + var progress = new DownloadProgress("update.zip", 50L * 1024 * 1024, 50L * 1024 * 1024, 100.0, DownloadStatus.Completed); + + Assert.Equal(DownloadStatus.Completed, progress.Status); + Assert.Equal(100.0, progress.Percentage); + } + + [Fact] + public void DownloadProgress_Failed() + { + var progress = new DownloadProgress("update.zip", 10L * 1024 * 1024, 50L * 1024 * 1024, 20.0, DownloadStatus.Failed); + + Assert.Equal(DownloadStatus.Failed, progress.Status); + Assert.NotEqual(100.0, progress.Percentage); + } + + #endregion + + #region DownloadResult + + [Fact] + public void DownloadResult_Success() + { + var result = new DownloadResult( + "https://cdn.example.com/update.zip", + "/tmp/update.zip", + 50L * 1024 * 1024, + TimeSpan.FromSeconds(30), + 0, + true, + null); + + Assert.True(result.Success); + Assert.Equal("/tmp/update.zip", result.LocalPath); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public void DownloadResult_FailureWithRetries() + { + var result = new DownloadResult( + "https://cdn.example.com/update.zip", + null, + 0, + TimeSpan.FromSeconds(15), + 3, + false, + "Connection timeout after 3 retries"); + + Assert.False(result.Success); + Assert.Equal(3, result.RetryCount); + Assert.Equal("Connection timeout after 3 retries", result.ErrorMessage); + } + + #endregion + + #region Enum Types + + [Fact] + public void AppType_ClientIs1() => Assert.Equal(1, (int)AppType.Client); + [Fact] + public void AppType_UpgradeIs2() => Assert.Equal(2, (int)AppType.Upgrade); + [Fact] + public void AppType_OSSIs3() => Assert.Equal(3, (int)AppType.OSS); + + [Fact] + public void DiffMode_SerialAndParallel_AreDefined() + { + Assert.Contains(DiffMode.Serial, Enum.GetValues()); + Assert.Contains(DiffMode.Parallel, Enum.GetValues()); + } + + [Fact] + public void UpdateMode_HasDefaultAndScripts() + { + var values = Enum.GetValues(); + Assert.Contains(UpdateMode.Default, values); + Assert.Contains(UpdateMode.Scripts, values); + } + + [Fact] + public void PlatformType_AllFour() + { + var values = Enum.GetValues(); + Assert.Contains(PlatformType.Windows, values); + Assert.Contains(PlatformType.Linux, values); + Assert.Contains(PlatformType.MacOS, values); + Assert.Contains(PlatformType.Unknown, values); + } + + [Fact] + public void OssProvider_AllFour() + { + var values = Enum.GetValues(); + Assert.Contains(OssProvider.AliYun, values); + Assert.Contains(OssProvider.AWS, values); + Assert.Contains(OssProvider.MinIO, values); + Assert.Contains(OssProvider.Tencent, values); + } + + [Fact] + public void DownloadStatus_FiveValues() + { + var values = Enum.GetValues(); + Assert.Contains(DownloadStatus.Pending, values); + Assert.Contains(DownloadStatus.Downloading, values); + Assert.Contains(DownloadStatus.Completed, values); + Assert.Contains(DownloadStatus.Failed, values); + Assert.Contains(DownloadStatus.Retrying, values); + } + + [Fact] + public void DownloadPriority_ThreeValues() + { + var values = Enum.GetValues(); + Assert.Contains(DownloadPriority.Low, values); + Assert.Contains(DownloadPriority.Normal, values); + Assert.Contains(DownloadPriority.High, values); + } + + [Fact] + public void UpdateEvent_FiveValues() + { + var values = Enum.GetValues(); + Assert.Contains(UpdateEvent.UpdateStarted, values); + Assert.Contains(UpdateEvent.DownloadCompleted, values); + Assert.Contains(UpdateEvent.UpdateApplied, values); + Assert.Contains(UpdateEvent.UpdateFailed, values); + Assert.Contains(UpdateEvent.AppStarted, values); + } + + #endregion + + #region UpdateOption Semantics + + [Fact] + public void UpdateOption_ValueOf_String_DefaultsCorrectly() + { + var option = UpdateOption.ValueOf("STRING_KEY", "hello"); + Assert.Equal("STRING_KEY", option.Name); + Assert.Equal("hello", option.DefaultValue); + } + + [Fact] + public void UpdateOption_ValueOf_Int_DefaultsCorrectly() + { + var option = UpdateOption.ValueOf("INT_KEY", 42); + Assert.Equal(42, option.DefaultValue); + } + + [Fact] + public void UpdateOption_ValueOf_Bool_DefaultsCorrectly() + { + var option = UpdateOption.ValueOf("BOOL_KEY", true); + Assert.True(option.DefaultValue); + } + + [Fact] + public void UpdateOption_ValueOf_NullableInt_DefaultsCorrectly() + { + var option = UpdateOption.ValueOf("NULLABLE_KEY", null); + Assert.Null(option.DefaultValue); + } + + [Fact] + public void UpdateOption_ValueOf_Enum_DefaultsCorrectly() + { + var option = UpdateOption.ValueOf("ENUM_KEY", AppType.Client); + Assert.Equal(AppType.Client, option.DefaultValue); + } + + #endregion + } +}