using AvParser.Core.Collecting; using AvParser.Core.Proxies; using AvParser.Core.Settings; using AvParser.Infrastructure.Settings; using AvParser.Infrastructure.Storage; using Microsoft.Extensions.Logging.Abstractions; namespace AvParser.Infrastructure.Tests; public sealed class JsonSettingsServiceTests : IDisposable { private readonly string _directory = Path.Combine( Path.GetTempPath(), "AvParserTests", Guid.NewGuid().ToString("N") ); private JsonSettingsService Create() => new(new AppPaths(_directory), NullLogger.Instance); private void WriteSettings(string json) { Directory.CreateDirectory(_directory); File.WriteAllText(Path.Combine(_directory, "settings.json"), json); } public void Dispose() { if (Directory.Exists(_directory)) { Directory.Delete(_directory, recursive: true); } } [Fact] public void An_absent_file_yields_the_defaults() { using var service = Create(); service.Current.ShouldBe(new AppSettings()); } /// /// A settings file written before a setting existed must not zero that setting. /// /// /// This is a regression test with a real bug behind it. Defaults used to live on property /// initialisers, which the source-generated deserialiser does not run — so an older file came /// back with default(T) everywhere, switching the proxy feed off, clearing the protocol /// filter and zeroing the probe timeout. The app then loaded an empty pool and looked as if /// the network had failed. /// [Fact] public void A_file_from_an_older_version_keeps_the_defaults_for_settings_it_predates() { WriteSettings( """ { "theme": "System", "lastParserId": "delimited", "windowWidth": 1280, "windowHeight": 800, "windowMaximized": false, "minimumLogLevel": "Information" } """ ); using var service = Create(); var settings = service.Current; settings.ProxyUseFeed.ShouldBeTrue(); settings.ProxyProtocols.ShouldBe(ProxyProtocolFilter.All); settings.ProxyProbeTimeoutSeconds.ShouldBe(8); settings.ProxyProbeConcurrency.ShouldBe(64); settings.ProxyProbeUrl.ShouldNotBeNullOrEmpty(); settings.Language.ShouldBe(AppLanguage.System); // And the values the file did carry must survive. settings.LastParserId.ShouldBe("delimited"); settings.MinimumLogLevel.ShouldBe("Information"); } /// /// The same guarantee, for the collector settings added afterwards. /// /// /// The failure this prevents is worse here than it was for the proxy pool: a zeroed /// MaxItemBytes would refuse everything, and a zeroed concurrency would deadlock the /// run outright. /// [Fact] public void A_file_that_predates_the_collector_keeps_the_collector_defaults() { WriteSettings("""{ "theme": "Dark", "proxyMinimumLive": 25 }"""); using var service = Create(); var settings = service.Current; settings.MaxConcurrentDownloads.ShouldBe(4); settings.MaxConcurrentPerHost.ShouldBe(2); settings.HostDelayMs.ShouldBe(250); settings.MaxItemBytes.ShouldBe(33_554_432); settings.MinItemBytes.ShouldBe(1024); settings.MaxRedirects.ShouldBe(5); settings.ConnectTimeoutSeconds.ShouldBe(15); settings.HeaderTimeoutSeconds.ShouldBe(30); settings.IdleTimeoutSeconds.ShouldBe(20); settings.AllowedMediaKinds.ShouldBe(MediaKindFilter.All); settings.ShowcaseMode.ShouldBe(ShowcaseMode.HardLink); settings.CollectUserAgent.ShouldNotBeNullOrWhiteSpace(); // And what the file did carry survives. settings.ProxyMinimumLive.ShouldBe(25); } [Fact] public void Collect_options_from_an_older_file_are_usable() { WriteSettings("""{ "theme": "Dark" }"""); using var service = Create(); var options = service.Current.ToCollectOptions(); options.MaxConcurrentDownloads.ShouldBeGreaterThan(0); options.MaxItemBytes.ShouldBeGreaterThan(0); options.AllowedKinds.ShouldBe(MediaKindFilter.All); options.IdleTimeout.ShouldBeGreaterThan(TimeSpan.Zero); } [Fact] public void Collector_values_out_of_range_are_clamped_rather_than_thrown() { var options = new AppSettings( MaxConcurrentDownloads: 0, MaxConcurrentPerHost: -5, MaxItemBytes: 0, MaxRedirects: 9999, IdleTimeoutSeconds: 0, CollectUserAgent: " " ).ToCollectOptions(); options.MaxConcurrentDownloads.ShouldBe(1); options.MaxConcurrentPerHost.ShouldBe(1); options.MaxItemBytes.ShouldBe(1024); options.MaxRedirects.ShouldBe(20); options.IdleTimeout.ShouldBe(TimeSpan.FromSeconds(1)); options.UserAgent.ShouldBe("AvParser/0.1"); } [Fact] public void An_empty_media_filter_is_treated_as_everything() { // Switching every format off is far more likely to be an accident than an instruction to // collect nothing at all. new AppSettings(AllowedMediaKinds: MediaKindFilter.None) .ToCollectOptions() .AllowedKinds.ShouldBe(MediaKindFilter.All); } [Fact] public void The_proxy_gate_setting_reaches_the_collector() { new AppSettings(AllowDirectConnection: false).ToCollectOptions().RequireProxy.ShouldBeTrue(); new AppSettings(AllowDirectConnection: true).ToCollectOptions().RequireProxy.ShouldBeFalse(); } [Fact] public void Options_built_from_an_older_file_still_consult_the_feed() { WriteSettings("""{ "theme": "Dark" }"""); using var service = Create(); var options = service.Current.ToProxyOptions(); options.UseFeed.ShouldBeTrue(); options.Protocols.ShouldBe(ProxyProtocolFilter.All); } [Fact] public void An_empty_protocol_filter_is_treated_as_all() { // Nothing in the UI can produce None, but a hand-edited file can — and a pool that // silently matches nothing is the least useful possible reading of it. var options = new AppSettings(ProxyProtocols: ProxyProtocolFilter.None).ToProxyOptions(); options.Protocols.ShouldBe(ProxyProtocolFilter.All); } [Fact] public void Out_of_range_values_are_clamped_rather_than_thrown() { var options = new AppSettings( ProxyProbeTimeoutSeconds: 0, ProxyProbeConcurrency: 100_000, ProxyProbeUrl: "not a url" ).ToProxyOptions(); options.ProbeTimeout.ShouldBe(TimeSpan.FromSeconds(1)); options.ProbeConcurrency.ShouldBe(512); options.ProbeUrl.ShouldBe(new ProxyOptions().ProbeUrl); } [Fact] public void A_corrupt_file_falls_back_to_the_defaults_instead_of_failing_to_start() { WriteSettings("{ this is not json"); using var service = Create(); service.Current.ShouldBe(new AppSettings()); } [Fact] public async Task Updates_round_trip_through_the_file() { using (var service = Create()) { service.Update(current => current with { Theme = AppTheme.Dark, ProxyUseFeed = false }); await service.FlushAsync(TestContext.Current.CancellationToken); } using var reopened = Create(); reopened.Current.Theme.ShouldBe(AppTheme.Dark); reopened.Current.ProxyUseFeed.ShouldBeFalse(); } }