Files
Leonid Pershin 0fa8fb89f6 Implement media rule management and enhance proxy handling
- Added methods to `IMediaStore` for loading, saving, and removing media rules, allowing users to manage rules for media items effectively.
- Updated `MediaFetcher` to utilize the new rule management system, integrating rule checks into the fetching process to handle geo-blocks and previously ruled items.
- Enhanced `ProxyPool` to support exclusion of proxies from specific countries during acquisition, improving the handling of geo-blocked content.
- Adjusted `FetchOptions` to include rules instead of tombstones, streamlining the decision-making process during media fetching.
- Updated UI components to support rule editing, providing users with a more interactive experience when managing media rules.

These changes improve the overall media collection process by allowing users to define rules for handling media items and enhancing the proxy management system for better content accessibility.
2026-08-15 15:10:55 +03:00

257 lines
8.8 KiB
C#

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<JsonSettingsService>.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());
}
/// <summary>
/// A settings file written before a setting existed must not zero that setting.
/// </summary>
/// <remarks>
/// 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 <c>default(T)</c> 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.
/// </remarks>
[Fact]
public void A_file_from_an_older_version_keeps_the_defaults_for_settings_it_predates()
{
WriteSettings(
"""
{
"theme": "System",
"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.MinimumLogLevel.ShouldBe("Information");
}
/// <summary>
/// The same guarantee, for the collector settings added afterwards.
/// </summary>
/// <remarks>
/// The failure this prevents is worse here than it was for the proxy pool: a zeroed
/// <c>MaxItemBytes</c> would refuse everything, and a zeroed concurrency would deadlock the
/// run outright.
/// </remarks>
[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_collector_defaults_to_proxy_only()
{
// Whether a source may go direct is that source's own setting now; what the app-wide
// options must never do is default to the permissive answer.
new AppSettings()
.ToCollectOptions()
.RequireProxy.ShouldBeTrue();
}
[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 The_probe_opens_a_tunnel_the_way_the_collector_does()
{
// A plain-HTTP probe is a forwarded GET; an https one is a CONNECT. Free proxies routinely
// do the first and refuse the second, and every address this app collects is https — so an
// http probe marked them live and they then failed every real request.
new ProxyOptions().ProbeUrl.Scheme.ShouldBe("https");
new AppSettings().ToProxyOptions().ProbeUrl.Scheme.ShouldBe("https");
}
[Fact]
public void A_settings_file_still_holding_the_old_http_probe_is_upgraded()
{
// Nobody would think to edit this by hand, and leaving it would keep confirming proxies that
// cannot do the one thing the collector needs.
var options = new AppSettings(ProxyProbeUrl: "http://www.gstatic.com/generate_204").ToProxyOptions();
options.ProbeUrl.ShouldBe(new ProxyOptions().ProbeUrl);
}
[Fact]
public void A_probe_url_the_user_chose_is_left_alone()
{
var options = new AppSettings(ProxyProbeUrl: "http://example.test/ping").ToProxyOptions();
options.ProbeUrl.AbsoluteUri.ShouldBe("http://example.test/ping");
}
[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();
}
}