using AvParser.Core.Proxies; using AvParser.Infrastructure.Proxies; using Microsoft.Extensions.Logging.Abstractions; namespace AvParser.Infrastructure.Tests; public class ProxyPoolLoaderTests { private static ProxyPoolLoader Build(out CountingSource source, out ProxyPool pool) => Build(out source, out pool, new MemoryStateStore()); private static ProxyPoolLoader Build(out CountingSource source, out ProxyPool pool, IProxyStateStore stateStore) { source = new CountingSource(); pool = new ProxyPool([source], new NeverProbe(), new ProxyOptions()); return new ProxyPoolLoader(pool, stateStore, NullLogger.Instance); } [Fact] public async Task Loading_fills_the_pool() { var loader = Build(out _, out var pool); (await loader.EnsureLoadedAsync()).Total.ShouldBe(2); pool.Entries.Count.ShouldBe(2); loader.IsLoaded.ShouldBeTrue(); } [Fact] public async Task What_the_previous_run_learned_is_restored_onto_the_fresh_list() { // The feed republishes the same addresses every few minutes; the point of remembering is // that a proxy known to work is not re-discovered from scratch on every launch. var stateStore = new MemoryStateStore { State = { ["http://1.2.3.4:8080"] = new ProxyStateRecord("http://1.2.3.4:8080", Alive: true, LatencyMs: 120), }, }; var loader = Build(out _, out _, stateStore); var result = await loader.EnsureLoadedAsync(); result.Restored.ShouldBe(1); } [Fact] public async Task The_pool_state_is_written_back_after_a_load() { var stateStore = new MemoryStateStore(); var loader = Build(out _, out _, stateStore); await loader.EnsureLoadedAsync(); stateStore.Saves.ShouldBeGreaterThan(0); } [Fact] public async Task The_feed_is_fetched_once_however_many_callers_there_are() { var loader = Build(out var source, out _); // Startup and the proxy page both ask; a second download of a 600 KB list would be waste. await Task.WhenAll(loader.EnsureLoadedAsync(), loader.EnsureLoadedAsync(), loader.EnsureLoadedAsync()); await loader.EnsureLoadedAsync(); source.Calls.ShouldBe(1); } [Fact] public async Task Concurrent_callers_share_one_operation() { var loader = Build(out _, out _); var first = loader.EnsureLoadedAsync(); var second = loader.EnsureLoadedAsync(); first.ShouldBeSameAs(second); await first; } [Fact] public async Task A_source_that_throws_does_not_take_startup_down() { var pool = new ProxyPool([new ThrowingSource()], new NeverProbe(), new ProxyOptions()); var loader = new ProxyPoolLoader(pool, new MemoryStateStore(), NullLogger.Instance); // The app has to start whether or not a public list is reachable. (await loader.EnsureLoadedAsync()).Total.ShouldBe(0); } private sealed class MemoryStateStore : IProxyStateStore { public Dictionary State { get; } = new(StringComparer.Ordinal); public int Saves { get; private set; } public Task> LoadAsync( CancellationToken cancellationToken = default ) => Task.FromResult>(State); public Task SaveAsync(IEnumerable entries, CancellationToken cancellationToken = default) { Saves++; return Task.CompletedTask; } } private sealed class CountingSource : IProxySource { public int Calls { get; private set; } public string Id => "counting"; public string DisplayName => "Counting source"; public ProxySourceKind Kind => ProxySourceKind.Feed; public Task> GetProxiesAsync(CancellationToken cancellationToken = default) { Calls++; return Task.FromResult>([ new ProxyEndpoint(ProxyProtocol.Http, "1.2.3.4", 8080), new ProxyEndpoint(ProxyProtocol.Socks5, "5.6.7.8", 1080), ]); } } private sealed class ThrowingSource : IProxySource { public string Id => "throwing"; public string DisplayName => "Throwing source"; public ProxySourceKind Kind => ProxySourceKind.Feed; public Task> GetProxiesAsync(CancellationToken cancellationToken = default) => throw new HttpRequestException("upstream is down"); } private sealed class NeverProbe : IProxyProbe { public Task ProbeAsync( ProxyEndpoint endpoint, ProxyOptions options, CancellationToken cancellationToken = default ) => Task.FromResult(ProxyProbeResult.Failure("not used")); } }