diff --git a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs index 9435231e..4643d416 100644 --- a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs @@ -285,6 +285,18 @@ private void ApplyRuntimeOptions() _configInfo.Encoding = GetOption(UpdateOptions.Encoding); _configInfo.Format = GetOption(UpdateOptions.Format); _configInfo.DownloadTimeOut = GetOption(UpdateOptions.DownloadTimeout) ?? 60; + + // Download behaviour + _configInfo.MaxConcurrency = GetOption(UpdateOptions.MaxConcurrency); + _configInfo.EnableResume = GetOption(UpdateOptions.EnableResume); + _configInfo.RetryCount = GetOption(UpdateOptions.RetryCount); + _configInfo.RetryInterval = GetOption(UpdateOptions.RetryInterval); + _configInfo.VerifyChecksum = GetOption(UpdateOptions.VerifyChecksum); + + // Update behaviour + _configInfo.BackupEnabled = GetOption(UpdateOptions.BackupEnabled); + _configInfo.PatchEnabled = GetOption(UpdateOptions.PatchEnabled); + _configInfo.DiffMode = GetOption(UpdateOptions.DiffMode); } /// diff --git a/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs b/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs index 33e25a7a..56cb91e1 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Text; @@ -112,9 +113,54 @@ public class GlobalConfigInfo : BaseConfigInfo /// public bool? PatchEnabled { get; set; } + /// + /// Whether to back up the current version before applying an update. + /// Computed from UpdateOption.BackupEnabled, defaults to true. + /// + public bool? BackupEnabled { get; set; } + /// /// Directory path where the current version files are backed up before update. /// Computed by combining InstallPath with a versioned directory name. /// public string BackupDirectory { get; set; } -} \ No newline at end of file + + // ═══ Download/update behaviour options (wired from UpdateOptions) ═══ + + /// + /// Maximum number of concurrent download operations. + /// Computed from UpdateOption.MaxConcurrency, defaults to 3. + /// Valid range: 1 to * 2. + /// + public int MaxConcurrency { get; set; } = 3; + + /// + /// Whether to resume interrupted downloads via HTTP Range requests. + /// Computed from UpdateOption.EnableResume, defaults to true. + /// + public bool EnableResume { get; set; } = true; + + /// + /// Maximum number of retry attempts for failed download operations. + /// Computed from UpdateOption.RetryCount, defaults to 3. + /// + public int RetryCount { get; set; } = 3; + + /// + /// Initial retry interval for exponential back-off. + /// Computed from UpdateOption.RetryInterval, defaults to 1 second. + /// + public TimeSpan RetryInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Whether to perform SHA256 checksum verification after download. + /// Computed from UpdateOption.VerifyChecksum, defaults to true. + /// + public bool VerifyChecksum { get; set; } = true; + + /// + /// Diff/patch generation mode — Serial or Parallel. + /// Computed from UpdateOption.DiffMode, defaults to . + /// + public DiffMode DiffMode { get; set; } = DiffMode.Serial; +} diff --git a/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs b/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs index 6774fd7c..70aa25ed 100644 --- a/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs +++ b/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs @@ -10,18 +10,20 @@ namespace GeneralUpdate.Core.Download.Executors; /// -/// HTTP-based download executor with Range/resume support. +/// HTTP-based download executor with optional Range/resume support. /// Uses the shared HttpClient from VersionService for consistent SSL/auth handling. /// public class HttpDownloadExecutor : IDownloadExecutor { private readonly HttpClient _client; private readonly TimeSpan _timeout; + private readonly bool _enableResume; - public HttpDownloadExecutor(HttpClient client, TimeSpan? timeout = null) + public HttpDownloadExecutor(HttpClient client, TimeSpan? timeout = null, bool enableResume = true) { _client = client ?? throw new ArgumentNullException(nameof(client)); _timeout = timeout ?? TimeSpan.FromSeconds(30); + _enableResume = enableResume; } public async Task ExecuteAsync( @@ -34,8 +36,8 @@ public async Task ExecuteAsync( long totalBytes = -1; long existingBytes = 0; - // Check for existing partial file (resume support) - if (File.Exists(destPath)) + // Check for existing partial file (resume support; skip when disabled) + if (_enableResume && File.Exists(destPath)) { existingBytes = new FileInfo(destPath).Length; } @@ -44,8 +46,8 @@ public async Task ExecuteAsync( { using var request = new HttpRequestMessage(HttpMethod.Get, url); - // Request resume from existing position - if (existingBytes > 0) + // Request resume from existing position (skip when resume is disabled) + if (_enableResume && existingBytes > 0) request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(existingBytes, null); using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); @@ -56,7 +58,7 @@ public async Task ExecuteAsync( .ConfigureAwait(false); // If server doesn't support Range, discard partial file - if (existingBytes > 0 && response.StatusCode != System.Net.HttpStatusCode.PartialContent) + if (_enableResume && existingBytes > 0 && response.StatusCode != System.Net.HttpStatusCode.PartialContent) { existingBytes = 0; File.Delete(destPath); diff --git a/src/c#/GeneralUpdate.Core/Download/Models/DownloadOrchestratorOptions.cs b/src/c#/GeneralUpdate.Core/Download/Models/DownloadOrchestratorOptions.cs new file mode 100644 index 00000000..7870df70 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/Models/DownloadOrchestratorOptions.cs @@ -0,0 +1,82 @@ +using System; + +namespace GeneralUpdate.Core.Download.Models; + +/// +/// Bundles all configurable download behaviour options into a single value object. +/// Used by and +/// to avoid constructor parameter explosion. +/// +public class DownloadOrchestratorOptions +{ + /// + /// Maximum number of concurrent download operations. + /// Valid range: 1 to * 2. + /// Default: 3. + /// + public int MaxConcurrency { get; set; } = 3; + + /// + /// Whether to resume interrupted downloads via HTTP Range requests. + /// Default: true. + /// + public bool EnableResume { get; set; } = true; + + /// + /// Maximum number of retry attempts for failed download operations. + /// Default: 3. + /// + public int RetryCount { get; set; } = 3; + + /// + /// Initial retry interval for exponential back-off. + /// Actual delay before N-th retry = RetryInterval * 2^(N-1). + /// Default: 1 second. + /// + public TimeSpan RetryInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Whether to perform SHA256 checksum verification after download. + /// Default: true. + /// + public bool VerifyChecksum { get; set; } = true; + + /// + /// Diff/patch generation mode — Serial or Parallel. + /// When , is forced to 1. + /// Default: . + /// + public Configuration.DiffMode DiffMode { get; set; } = Configuration.DiffMode.Serial; + + /// + /// HTTP download timeout duration. + /// Default: 30 seconds. + /// + public TimeSpan DownloadTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Creates a from . + /// + public static DownloadOrchestratorOptions From(Configuration.GlobalConfigInfo config) + { + return new DownloadOrchestratorOptions + { + MaxConcurrency = SanitizeMaxConcurrency(config.MaxConcurrency), + EnableResume = config.EnableResume, + RetryCount = Math.Max(0, config.RetryCount), + RetryInterval = config.RetryInterval, + VerifyChecksum = config.VerifyChecksum, + DiffMode = config.DiffMode, + DownloadTimeout = TimeSpan.FromSeconds(config.DownloadTimeOut > 0 ? config.DownloadTimeOut : 30), + }; + } + + /// Clamps to [1, ProcessorCount * 2]. + public static int SanitizeMaxConcurrency(int value) + { + var max = Math.Max(1, Environment.ProcessorCount * 2); + if (value < 1) return 1; + if (value > max) return max; + return value; + } +} diff --git a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs index 1e1fcb6f..492f666f 100644 --- a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs +++ b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs @@ -6,6 +6,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using GeneralUpdate.Core.Configuration; using GeneralUpdate.Core.Download.Abstractions; using GeneralUpdate.Core.Download.Executors; using GeneralUpdate.Core.Download.Policy; @@ -16,17 +17,22 @@ namespace GeneralUpdate.Core.Download.Orchestrators; /// /// Default download orchestrator with parallel execution, concurrency limit, -/// SHA256 verification, and progress reporting. +/// SHA256 verification, resume support, and progress reporting. +/// +/// All configurable behaviour is driven by , +/// which maps to the defined in the bootstrap layer. /// public class DefaultDownloadOrchestrator : IDownloadOrchestrator { private readonly HttpClient _httpClient; private readonly IDownloadPolicy _policy; + private readonly DownloadOrchestratorOptions _options; - public DefaultDownloadOrchestrator(HttpClient httpClient, IDownloadPolicy? policy = null) + public DefaultDownloadOrchestrator(HttpClient httpClient, DownloadOrchestratorOptions? options = null, IDownloadPolicy? policy = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - _policy = policy ?? new DefaultRetryPolicy(); + _options = options ?? new DownloadOrchestratorOptions(); + _policy = policy ?? new DefaultRetryPolicy(_options.RetryCount, _options.RetryInterval); } /// Execute downloads for all assets in the plan. @@ -42,9 +48,20 @@ public async Task ExecuteAsync( Directory.CreateDirectory(destDir); + // Resolve effective concurrency: Serial mode forces 1. + // Uses _options.MaxConcurrency as primary value; the method parameter + // maxConcurrency acts as an override (default 3). + var baseConcurrency = maxConcurrency > 0 ? maxConcurrency : _options.MaxConcurrency; + var effectiveConcurrency = _options.DiffMode == DiffMode.Serial + ? 1 + : DownloadOrchestratorOptions.SanitizeMaxConcurrency(Math.Max(1, baseConcurrency)); + + GeneralTracer.Info($"DefaultDownloadOrchestrator.ExecuteAsync: concurrency={effectiveConcurrency}, " + + $"resume={_options.EnableResume}, verifyChecksum={_options.VerifyChecksum}, diffMode={_options.DiffMode}"); + var sw = Stopwatch.StartNew(); var results = new List(); - using var sem = new SemaphoreSlim(maxConcurrency); + using var sem = new SemaphoreSlim(effectiveConcurrency); long totalBytes = 0; var tasks = plan.Assets.Select(async asset => @@ -55,7 +72,7 @@ public async Task ExecuteAsync( var fileName = GetFileName(asset); var destPath = Path.Combine(destDir, fileName); - var executor = new HttpDownloadExecutor(_httpClient); + var executor = new HttpDownloadExecutor(_httpClient, _options.DownloadTimeout, _options.EnableResume); var pipeline = new DefaultDownloadPipeline(asset.SHA256); var result = await _policy.ExecuteAsync(async ct => @@ -69,7 +86,13 @@ public async Task ExecuteAsync( if (!downloadResult.Success) return downloadResult; - // Verify (SHA256) + // Verify (SHA256) — conditionally skipped when VerifyChecksum is false + if (!_options.VerifyChecksum) + { + GeneralTracer.Info($"DefaultDownloadOrchestrator: checksum verification skipped for {asset.Name} (VerifyChecksum=false)."); + return downloadResult; + } + try { await pipeline.ProcessAsync(destPath, ct).ConfigureAwait(false); diff --git a/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs index e34add4a..0810c2b4 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs @@ -183,12 +183,20 @@ private async Task ExecuteStandardWorkflowAsync(Encoding encoding, int timeout) BlackListManager.Instance.SkipDirectorys.ToList()), ProcessInfoJsonContext.Default.ProcessInfo); - // Backup - Backup(); + // Backup — conditionally skipped when BackupEnabled is false + if (_configInfo.BackupEnabled != false) + { + Backup(); + } + else + { + GeneralTracer.Info("ClientUpdateStrategy: backup skipped (BackupEnabled=false)."); + } _osStrategy!.Create(_configInfo); - // Download via orchestrator + // Download via orchestrator — wired with options from GlobalConfigInfo + var orchOptions = Download.Models.DownloadOrchestratorOptions.From(_configInfo); GeneralTracer.Info($"ClientUpdateStrategy: downloading {downloadPlan.Assets.Count} asset(s)."); if (_orchestrator != null) { @@ -199,7 +207,7 @@ private async Task ExecuteStandardWorkflowAsync(Encoding encoding, int timeout) var httpClient = new System.Net.Http.HttpClient(); try { - var orchestrator = new Download.Orchestrators.DefaultDownloadOrchestrator(httpClient); + var orchestrator = new Download.Orchestrators.DefaultDownloadOrchestrator(httpClient, orchOptions); await orchestrator.ExecuteAsync(downloadPlan, _configInfo.TempPath).ConfigureAwait(false); } finally { httpClient.Dispose(); } diff --git a/tests/CoreTest/Configuration/GlobalConfigInfoWiringTests.cs b/tests/CoreTest/Configuration/GlobalConfigInfoWiringTests.cs new file mode 100644 index 00000000..4303913a --- /dev/null +++ b/tests/CoreTest/Configuration/GlobalConfigInfoWiringTests.cs @@ -0,0 +1,241 @@ +using System; +using System.IO; +using GeneralUpdate.Core; +using GeneralUpdate.Core.Configuration; +using Xunit; + +namespace CoreTest.Configuration; + +/// +/// Verifies that properties are correctly +/// populated from via +/// . +/// +public class GlobalConfigInfoWiringTests +{ + #region GlobalConfigInfo Default Values + + [Fact] + public void GlobalConfigInfo_MaxConcurrency_DefaultsTo3() + { + var config = new GlobalConfigInfo(); + Assert.Equal(3, config.MaxConcurrency); + } + + [Fact] + public void GlobalConfigInfo_EnableResume_DefaultsToTrue() + { + var config = new GlobalConfigInfo(); + Assert.True(config.EnableResume); + } + + [Fact] + public void GlobalConfigInfo_RetryCount_DefaultsTo3() + { + var config = new GlobalConfigInfo(); + Assert.Equal(3, config.RetryCount); + } + + [Fact] + public void GlobalConfigInfo_RetryInterval_DefaultsToOneSecond() + { + var config = new GlobalConfigInfo(); + Assert.Equal(TimeSpan.FromSeconds(1), config.RetryInterval); + } + + [Fact] + public void GlobalConfigInfo_VerifyChecksum_DefaultsToTrue() + { + var config = new GlobalConfigInfo(); + Assert.True(config.VerifyChecksum); + } + + [Fact] + public void GlobalConfigInfo_BackupEnabled_DefaultsToNull() + { + var config = new GlobalConfigInfo(); + Assert.Null(config.BackupEnabled); + } + + [Fact] + public void GlobalConfigInfo_PatchEnabled_DefaultsToNull() + { + var config = new GlobalConfigInfo(); + Assert.Null(config.PatchEnabled); + } + + [Fact] + public void GlobalConfigInfo_DiffMode_DefaultsToSerial() + { + var config = new GlobalConfigInfo(); + Assert.Equal(DiffMode.Serial, config.DiffMode); + } + + [Fact] + public void GlobalConfigInfo_AllDownloadProperties_HaveReasonableDefaults() + { + var config = new GlobalConfigInfo(); + Assert.True(config.MaxConcurrency >= 1); + Assert.True(config.EnableResume); + Assert.True(config.RetryCount >= 0); + Assert.True(config.RetryInterval > TimeSpan.Zero); + Assert.True(config.VerifyChecksum); + } + + #endregion + + #region Bootstrap Option → GetOption Roundtrip + + /// Test subclass that exposes the protected GetOption method. + private sealed class TestableBootstrap : GeneralUpdateBootstrap + { + public T PublicGetOption(UpdateOption? option) => GetOption(option); + } + + [Fact] + public void Bootstrap_GetOption_MaxConcurrency_ReturnsSetValue() + { + var b = new TestableBootstrap(); + b.Option(UpdateOptions.MaxConcurrency, 8); + Assert.Equal(8, b.PublicGetOption(UpdateOptions.MaxConcurrency)); + } + + [Fact] + public void Bootstrap_GetOption_EnableResume_ReturnsSetValue() + { + var b = new TestableBootstrap(); + b.Option(UpdateOptions.EnableResume, false); + Assert.False(b.PublicGetOption(UpdateOptions.EnableResume)); + } + + [Fact] + public void Bootstrap_GetOption_RetryCount_ReturnsSetValue() + { + var b = new TestableBootstrap(); + b.Option(UpdateOptions.RetryCount, 10); + Assert.Equal(10, b.PublicGetOption(UpdateOptions.RetryCount)); + } + + [Fact] + public void Bootstrap_GetOption_RetryInterval_ReturnsSetValue() + { + var b = new TestableBootstrap(); + b.Option(UpdateOptions.RetryInterval, TimeSpan.FromSeconds(5)); + Assert.Equal(TimeSpan.FromSeconds(5), b.PublicGetOption(UpdateOptions.RetryInterval)); + } + + [Fact] + public void Bootstrap_GetOption_VerifyChecksum_ReturnsSetValue() + { + var b = new TestableBootstrap(); + b.Option(UpdateOptions.VerifyChecksum, false); + Assert.False(b.PublicGetOption(UpdateOptions.VerifyChecksum)); + } + + [Fact] + public void Bootstrap_GetOption_BackupEnabled_ReturnsSetValue() + { + var b = new TestableBootstrap(); + b.Option(UpdateOptions.BackupEnabled, false); + Assert.False(b.PublicGetOption(UpdateOptions.BackupEnabled)); + } + + [Fact] + public void Bootstrap_GetOption_PatchEnabled_ReturnsSetValue() + { + var b = new TestableBootstrap(); + b.Option(UpdateOptions.PatchEnabled, true); + Assert.True(b.PublicGetOption(UpdateOptions.PatchEnabled)); + } + + [Fact] + public void Bootstrap_GetOption_DiffMode_ReturnsSetValue() + { + var b = new TestableBootstrap(); + b.Option(UpdateOptions.DiffMode, DiffMode.Parallel); + Assert.Equal(DiffMode.Parallel, b.PublicGetOption(UpdateOptions.DiffMode)); + } + + [Fact] + public void Bootstrap_AllEightOptions_SetWithoutError() + { + var b = new TestableBootstrap(); + b.Option(UpdateOptions.MaxConcurrency, 6) + .Option(UpdateOptions.EnableResume, false) + .Option(UpdateOptions.RetryCount, 5) + .Option(UpdateOptions.RetryInterval, TimeSpan.FromSeconds(3)) + .Option(UpdateOptions.VerifyChecksum, false) + .Option(UpdateOptions.BackupEnabled, false) + .Option(UpdateOptions.PatchEnabled, true) + .Option(UpdateOptions.DiffMode, DiffMode.Parallel); + + // Verify each option was stored correctly + Assert.Equal(6, b.PublicGetOption(UpdateOptions.MaxConcurrency)); + Assert.False(b.PublicGetOption(UpdateOptions.EnableResume)); + Assert.Equal(5, b.PublicGetOption(UpdateOptions.RetryCount)); + Assert.Equal(TimeSpan.FromSeconds(3), b.PublicGetOption(UpdateOptions.RetryInterval)); + Assert.False(b.PublicGetOption(UpdateOptions.VerifyChecksum)); + Assert.False(b.PublicGetOption(UpdateOptions.BackupEnabled)); + Assert.True(b.PublicGetOption(UpdateOptions.PatchEnabled)); + Assert.Equal(DiffMode.Parallel, b.PublicGetOption(UpdateOptions.DiffMode)); + } + + #endregion + + #region Option Defaults Match UpdateOptions + + [Fact] + public void UpdateOptions_MaxConcurrency_DefaultIs3() + => Assert.Equal(3, UpdateOptions.MaxConcurrency.DefaultValue); + + [Fact] + public void UpdateOptions_EnableResume_DefaultIsTrue() + => Assert.True(UpdateOptions.EnableResume.DefaultValue); + + [Fact] + public void UpdateOptions_RetryCount_DefaultIs3() + => Assert.Equal(3, UpdateOptions.RetryCount.DefaultValue); + + [Fact] + public void UpdateOptions_RetryInterval_DefaultIsOneSecond() + => Assert.Equal(TimeSpan.FromSeconds(1), UpdateOptions.RetryInterval.DefaultValue); + + [Fact] + public void UpdateOptions_VerifyChecksum_DefaultIsTrue() + => Assert.True(UpdateOptions.VerifyChecksum.DefaultValue); + + [Fact] + public void UpdateOptions_BackupEnabled_DefaultIsTrue() + => Assert.True(UpdateOptions.BackupEnabled.DefaultValue); + + [Fact] + public void UpdateOptions_PatchEnabled_DefaultIsTrue() + => Assert.True(UpdateOptions.PatchEnabled.DefaultValue); + + [Fact] + public void UpdateOptions_DiffMode_DefaultIsSerial() + => Assert.Equal(DiffMode.Serial, UpdateOptions.DiffMode.DefaultValue); + + #endregion + + #region Helpers + + private static GeneralUpdateBootstrap MakeBootstrap() + { + var testDir = Path.Combine(Path.GetTempPath(), $"GU_Wiring_{Guid.NewGuid():N}"); + Directory.CreateDirectory(testDir); + + return new GeneralUpdateBootstrap().SetConfig(new Configinfo + { + UpdateUrl = "https://api.example.com", + MainAppName = "MyApp.exe", + ClientVersion = "1.0.0", + InstallPath = testDir, + AppSecretKey = "secret", + Scheme = "https", + Token = "token", + }); + } + + #endregion +} diff --git a/tests/CoreTest/Download/DownloadOrchestratorOptionsTests.cs b/tests/CoreTest/Download/DownloadOrchestratorOptionsTests.cs new file mode 100644 index 00000000..59a1f962 --- /dev/null +++ b/tests/CoreTest/Download/DownloadOrchestratorOptionsTests.cs @@ -0,0 +1,173 @@ +using System; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Download.Models; +using Xunit; + +namespace CoreTest.Download; + +/// +/// Unit tests for covering: +/// - default values +/// - SanitizeMaxConcurrency clamping +/// - From(GlobalConfigInfo) mapping +/// +public class DownloadOrchestratorOptionsTests +{ + #region Defaults + + [Fact] + public void Defaults_AreAsSpecified() + { + var opts = new DownloadOrchestratorOptions(); + + Assert.Equal(3, opts.MaxConcurrency); + Assert.True(opts.EnableResume); + Assert.Equal(3, opts.RetryCount); + Assert.Equal(TimeSpan.FromSeconds(1), opts.RetryInterval); + Assert.True(opts.VerifyChecksum); + Assert.Equal(DiffMode.Serial, opts.DiffMode); + Assert.Equal(TimeSpan.FromSeconds(30), opts.DownloadTimeout); + } + + #endregion + + #region SanitizeMaxConcurrency + + [Theory] + [InlineData(0, 1)] + [InlineData(-1, 1)] + [InlineData(-100, 1)] + [InlineData(1, 1)] + [InlineData(3, 3)] + [InlineData(5, 5)] + [InlineData(100, 1)] + public void SanitizeMaxConcurrency_ClampsCorrectly(int input, int expectedMin) + { + var result = DownloadOrchestratorOptions.SanitizeMaxConcurrency(input); + + Assert.True(result >= 1, $"Expected result >= 1, got {result}"); + var max = Math.Max(1, Environment.ProcessorCount * 2); + Assert.True(result <= max, $"Expected result <= {max}, got {result}"); + + if (input <= 1) + Assert.Equal(expectedMin, result); + } + + [Fact] + public void SanitizeMaxConcurrency_DoesNotExceedProcessorCountTimes2() + { + var max = Math.Max(1, Environment.ProcessorCount * 2); + var result = DownloadOrchestratorOptions.SanitizeMaxConcurrency(int.MaxValue); + Assert.Equal(max, result); + } + + [Fact] + public void SanitizeMaxConcurrency_NormalValue_Unchanged() + { + var normalValue = Math.Min(3, Math.Max(1, Environment.ProcessorCount * 2)); + var result = DownloadOrchestratorOptions.SanitizeMaxConcurrency(normalValue); + Assert.Equal(normalValue, result); + } + + #endregion + + #region From(GlobalConfigInfo) + + [Fact] + public void From_CopiesAllFields() + { + var config = new GlobalConfigInfo + { + MaxConcurrency = 5, + EnableResume = false, + RetryCount = 7, + RetryInterval = TimeSpan.FromSeconds(2), + VerifyChecksum = false, + DiffMode = DiffMode.Parallel, + DownloadTimeOut = 60, + }; + + var opts = DownloadOrchestratorOptions.From(config); + + Assert.Equal(5, opts.MaxConcurrency); + Assert.False(opts.EnableResume); + Assert.Equal(7, opts.RetryCount); + Assert.Equal(TimeSpan.FromSeconds(2), opts.RetryInterval); + Assert.False(opts.VerifyChecksum); + Assert.Equal(DiffMode.Parallel, opts.DiffMode); + Assert.Equal(TimeSpan.FromSeconds(60), opts.DownloadTimeout); + } + + [Fact] + public void From_DefaultsWhenConfigIsMinimal() + { + var config = new GlobalConfigInfo(); + + var opts = DownloadOrchestratorOptions.From(config); + + Assert.Equal(3, opts.MaxConcurrency); + Assert.True(opts.EnableResume); + Assert.Equal(3, opts.RetryCount); + } + + [Fact] + public void From_SanitizesMaxConcurrency() + { + var config = new GlobalConfigInfo { MaxConcurrency = -5 }; + var opts = DownloadOrchestratorOptions.From(config); + Assert.Equal(1, opts.MaxConcurrency); + } + + [Fact] + public void From_ClampsNegativeRetryCount() + { + var config = new GlobalConfigInfo { RetryCount = -1 }; + var opts = DownloadOrchestratorOptions.From(config); + Assert.Equal(0, opts.RetryCount); + } + + [Fact] + public void From_FallsBackDownloadTimeoutWhenZero() + { + var config = new GlobalConfigInfo { DownloadTimeOut = 0 }; + var opts = DownloadOrchestratorOptions.From(config); + Assert.Equal(TimeSpan.FromSeconds(30), opts.DownloadTimeout); + } + + [Fact] + public void From_SerialMode() + { + var config = new GlobalConfigInfo { DiffMode = DiffMode.Serial }; + var opts = DownloadOrchestratorOptions.From(config); + Assert.Equal(DiffMode.Serial, opts.DiffMode); + } + + #endregion + + #region All Properties Settable + + [Fact] + public void AllProperties_AreSettable() + { + var opts = new DownloadOrchestratorOptions + { + MaxConcurrency = 8, + EnableResume = false, + RetryCount = 5, + RetryInterval = TimeSpan.FromSeconds(3), + VerifyChecksum = false, + DiffMode = DiffMode.Parallel, + DownloadTimeout = TimeSpan.FromSeconds(120), + }; + + Assert.Equal(8, opts.MaxConcurrency); + Assert.False(opts.EnableResume); + Assert.Equal(5, opts.RetryCount); + Assert.Equal(TimeSpan.FromSeconds(3), opts.RetryInterval); + Assert.False(opts.VerifyChecksum); + Assert.Equal(DiffMode.Parallel, opts.DiffMode); + Assert.Equal(TimeSpan.FromSeconds(120), opts.DownloadTimeout); + } + + #endregion +} diff --git a/tests/CoreTest/Download/OrchestratorOptionsBehaviourTests.cs b/tests/CoreTest/Download/OrchestratorOptionsBehaviourTests.cs new file mode 100644 index 00000000..dd6add3e --- /dev/null +++ b/tests/CoreTest/Download/OrchestratorOptionsBehaviourTests.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Download.Abstractions; +using GeneralUpdate.Core.Download.Executors; +using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.Download.Orchestrators; +using GeneralUpdate.Core.Download.Policy; +using Moq; +using Moq.Protected; +using Xunit; + +namespace CoreTest.Download; + +/// +/// Tests that correctly reads and +/// applies behaviour from . +/// +public class OrchestratorOptionsBehaviourTests +{ + #region MaxConcurrency + + [Fact] + public void Constructor_ReadsMaxConcurrencyFromOptions() + { + var httpClient = new HttpClient(); + var opts = new DownloadOrchestratorOptions { MaxConcurrency = 5 }; + var orch = new DefaultDownloadOrchestrator(httpClient, opts); + Assert.NotNull(orch); + } + + [Fact] + public async Task ExecuteAsync_SerialMode_ForcesConcurrencyToOne() + { + var fakeHandler = new FakeSuccessHandler(); + var tracker = new ConcurrencyTracker(fakeHandler.PublicSendAsync); + using var httpClient = new HttpClient(tracker); + var opts = new DownloadOrchestratorOptions + { + DiffMode = DiffMode.Serial, + MaxConcurrency = 10, + VerifyChecksum = false, + RetryCount = 1, + RetryInterval = TimeSpan.Zero, + }; + + var assets = new List + { + new("file1.zip", "http://example.com/1.zip", 10, null, "1.0"), + new("file2.zip", "http://example.com/2.zip", 10, null, "1.0"), + }; + var plan = new DownloadPlan(assets, false); + var destDir = Path.Combine(Path.GetTempPath(), "GU_Serial_" + Guid.NewGuid().ToString("N")); + + try + { + var orch = new DefaultDownloadOrchestrator(httpClient, opts); + var report = await orch.ExecuteAsync(plan, destDir, token: CancellationToken.None); + Assert.Equal(2, report.SuccessCount); + // Serial mode must never exceed 1 concurrent request + Assert.True(tracker.PeakConcurrency <= 1, + $"Expected peak concurrency <= 1 for Serial mode, got {tracker.PeakConcurrency}"); + } + finally + { + if (Directory.Exists(destDir)) Directory.Delete(destDir, true); + } + } + + #endregion + + #region VerifyChecksum + + private sealed class FakeSuccessHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new ByteArrayContent(new byte[100]), + }); + } + + /// Public wrapper for testing. + public Task PublicSendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => SendAsync(request, cancellationToken); + } + + /// Wraps an inner handler and tracks peak concurrent requests. + private sealed class ConcurrencyTracker : HttpMessageHandler + { + private readonly Func> _sendAsync; + private int _current; + public int PeakConcurrency; + + public ConcurrencyTracker(Func> sendAsync) + => _sendAsync = sendAsync; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var current = Interlocked.Increment(ref _current); + InterlockedAddMax(ref PeakConcurrency, current); + try + { + return await _sendAsync(request, cancellationToken); + } + finally + { + Interlocked.Decrement(ref _current); + } + } + + private static void InterlockedAddMax(ref int target, int value) + { + int snapshot; + do { snapshot = Volatile.Read(ref target); } + while (value > snapshot && Interlocked.CompareExchange(ref target, value, snapshot) != snapshot); + } + } + + [Fact] + public async Task ExecuteAsync_VerifyChecksumFalse_SkipsVerification() + { + using var httpClient = new HttpClient(new FakeSuccessHandler()); + var opts = new DownloadOrchestratorOptions + { + VerifyChecksum = false, + RetryCount = 1, + RetryInterval = TimeSpan.Zero, + }; + + var assets = new List + { + new("test.zip", "http://example.com/test.zip", 100, "sha256:invalid_hash_would_fail", "1.0"), + }; + var plan = new DownloadPlan(assets, false); + var destDir = Path.Combine(Path.GetTempPath(), "GU_NoVerify_" + Guid.NewGuid().ToString("N")); + + try + { + var orch = new DefaultDownloadOrchestrator(httpClient, opts); + var report = await orch.ExecuteAsync(plan, destDir, token: CancellationToken.None); + Assert.Equal(1, report.SuccessCount); + } + finally + { + if (Directory.Exists(destDir)) Directory.Delete(destDir, true); + } + } + + [Fact] + public async Task ExecuteAsync_VerifyChecksumTrueWithInvalidHash_Fails() + { + using var httpClient = new HttpClient(new FakeSuccessHandler()); + var opts = new DownloadOrchestratorOptions + { + VerifyChecksum = true, + RetryCount = 1, + RetryInterval = TimeSpan.Zero, + }; + + var assets = new List + { + new("test.zip", "http://example.com/test.zip", 100, + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", "1.0"), + }; + var plan = new DownloadPlan(assets, false); + var destDir = Path.Combine(Path.GetTempPath(), "GU_Verify_" + Guid.NewGuid().ToString("N")); + + try + { + var orch = new DefaultDownloadOrchestrator(httpClient, opts); + var report = await orch.ExecuteAsync(plan, destDir, token: CancellationToken.None); + Assert.Equal(0, report.SuccessCount); + } + finally + { + if (Directory.Exists(destDir)) Directory.Delete(destDir, true); + } + } + + #endregion + + #region RetryCount & RetryInterval + + [Fact] + public async Task ExecuteAsync_RetryCountFromOptions_IsUsedByPolicy() + { + var policy = new DefaultRetryPolicy(maxRetries: 5, initialDelay: TimeSpan.FromMilliseconds(10)); + int attempts = 0; + + await Assert.ThrowsAsync(() => + policy.ExecuteAsync(_ => + { + attempts++; + throw new HttpRequestException("timeout"); + }, CancellationToken.None)); + + Assert.Equal(5, attempts); + } + + [Fact] + public void Orchestrator_PassesOptionsToPolicy() + { + using var httpClient = new HttpClient(); + var opts = new DownloadOrchestratorOptions + { + RetryCount = 7, + RetryInterval = TimeSpan.FromSeconds(3), + }; + + var orch = new DefaultDownloadOrchestrator(httpClient, opts); + Assert.NotNull(orch); + } + + [Fact] + public void Orchestrator_AcceptsCustomPolicyOverride() + { + using var httpClient = new HttpClient(); + var customPolicy = new DefaultRetryPolicy(maxRetries: 10, initialDelay: TimeSpan.FromMilliseconds(100)); + var opts = new DownloadOrchestratorOptions { RetryCount = 1 }; + + var orch = new DefaultDownloadOrchestrator(httpClient, opts, customPolicy); + Assert.NotNull(orch); + } + + #endregion + + #region EnableResume + + [Fact] + public void HttpDownloadExecutor_EnableResumeFalse_Constructs() + { + using var client = new HttpClient(); + var executor = new HttpDownloadExecutor(client, timeout: TimeSpan.FromSeconds(10), enableResume: false); + Assert.NotNull(executor); + } + + [Fact] + public void HttpDownloadExecutor_EnableResumeTrue_Default() + { + using var client = new HttpClient(); + var executor = new HttpDownloadExecutor(client); + Assert.NotNull(executor); + } + + [Fact] + public async Task HttpDownloadExecutor_EnableResumeFalse_DeletesExistingFile() + { + var tmpDir = Path.Combine(Path.GetTempPath(), "GU_Resume_" + Guid.NewGuid().ToString("N")); + var destPath = Path.Combine(tmpDir, "download.bin"); + Directory.CreateDirectory(tmpDir); + File.WriteAllText(destPath, "partial-data"); + + using var client = new HttpClient(new FakeSuccessHandler()); + var executor = new HttpDownloadExecutor(client, enableResume: false); + + try + { + var result = await executor.ExecuteAsync( + "http://example.com/file.bin", destPath, token: CancellationToken.None); + Assert.True(result.Success, $"Download should succeed, error: {result.ErrorMessage}"); + Assert.True(File.Exists(destPath)); + } + finally + { + if (Directory.Exists(tmpDir)) Directory.Delete(tmpDir, true); + } + } + + [Fact] + public async Task HttpDownloadExecutor_WithRangeResponse_AppendsCorrectly() + { + var tmpDir = Path.Combine(Path.GetTempPath(), "GU_ResumeAppend_" + Guid.NewGuid().ToString("N")); + var destPath = Path.Combine(tmpDir, "download.bin"); + Directory.CreateDirectory(tmpDir); + var partialData = new byte[20]; + File.WriteAllBytes(destPath, partialData); + + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.Headers.Range != null), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.PartialContent, + Content = new ByteArrayContent(new byte[30]) { Headers = { ContentLength = 30 } } + }); + handler.Protected() + .Setup>("SendAsync", + ItExpr.Is(r => r.Headers.Range == null), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new ByteArrayContent(new byte[50]) + }); + + using var client = new HttpClient(handler.Object); + var executor = new HttpDownloadExecutor(client, enableResume: true); + + try + { + var result = await executor.ExecuteAsync( + "http://example.com/file.bin", destPath, token: CancellationToken.None); + Assert.True(result.Success); + // Partial content (30 bytes) appended to existing (20 bytes) = 50 total + var fileInfo = new FileInfo(destPath); + Assert.Equal(50, fileInfo.Length); + } + finally + { + if (Directory.Exists(tmpDir)) Directory.Delete(tmpDir, true); + } + } + + #endregion + + #region DiffMode Behaviour + + [Fact] + public async Task ExecuteAsync_ParallelMode_UsesConfiguredConcurrency() + { + using var httpClient = new HttpClient(new FakeSuccessHandler()); + var opts = new DownloadOrchestratorOptions + { + DiffMode = DiffMode.Parallel, + MaxConcurrency = 4, + VerifyChecksum = false, + RetryCount = 1, + RetryInterval = TimeSpan.Zero, + }; + + var assets = new List(); + for (int i = 0; i < 3; i++) + assets.Add(new($"file{i}.zip", $"http://example.com/{i}.zip", 10, null, "1.0")); + var plan = new DownloadPlan(assets, false); + var destDir = Path.Combine(Path.GetTempPath(), "GU_Parallel_" + Guid.NewGuid().ToString("N")); + + try + { + var orch = new DefaultDownloadOrchestrator(httpClient, opts); + var report = await orch.ExecuteAsync(plan, destDir, token: CancellationToken.None); + Assert.Equal(3, report.SuccessCount); + } + finally + { + if (Directory.Exists(destDir)) Directory.Delete(destDir, true); + } + } + + #endregion + + #region Empty Plan + + [Fact] + public async Task ExecuteAsync_EmptyPlan_ReturnsEmptyReport() + { + using var httpClient = new HttpClient(); + var orch = new DefaultDownloadOrchestrator(httpClient); + var report = await orch.ExecuteAsync(DownloadPlan.Empty, Path.GetTempPath(), token: CancellationToken.None); + Assert.Equal(0, report.SuccessCount); + Assert.Equal(0, report.FailedCount); + } + + #endregion +}