using AvParser.Core.Proxies; using AvParser.Infrastructure.Proxies; using AvParser.Infrastructure.Storage; using Microsoft.Extensions.Logging.Abstractions; namespace AvParser.Infrastructure.Tests; public sealed class ProxyStateStoreTests : IDisposable { private readonly string _directory = Path.Combine( Path.GetTempPath(), "AvParserTests", Guid.NewGuid().ToString("N") ); private ProxyStateStore Create() => new(new AppPaths(_directory), NullLogger.Instance); public void Dispose() { if (Directory.Exists(_directory)) { Directory.Delete(_directory, recursive: true); } } private static ProxyEntry Entry(string address, ProxySourceKind kind = ProxySourceKind.Feed) { ProxyEndpoint.TryParse(address, out var endpoint).ShouldBeTrue(); return new ProxyEntry(endpoint!, kind); } [Fact] public async Task An_absent_file_reads_as_nothing_remembered() { var store = Create(); (await store.LoadAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty(); } [Fact] public async Task A_working_proxy_survives_a_round_trip() { var alive = Entry("http://1.2.3.4:8080"); alive.RecordSuccess(DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(250)); await Create().SaveAsync([alive], TestContext.Current.CancellationToken); var state = await Create().LoadAsync(TestContext.Current.CancellationToken); state.ShouldContainKey(alive.Endpoint.Key); state[alive.Endpoint.Key].Alive.ShouldBeTrue(); state[alive.Endpoint.Key].LatencyMs.ShouldBe(250d); } [Fact] public async Task Proxies_that_never_answered_are_not_remembered() { // The feed republishes a few thousand dead addresses every few minutes; carrying them over // would bloat the file to save re-testing entries whose staleness tells us nothing. var untried = Entry("http://1.2.3.4:8080"); var dead = Entry("http://5.6.7.8:3128"); dead.RecordProbe(DateTimeOffset.UtcNow, alive: false, latency: null, error: "timeout"); await Create().SaveAsync([untried, dead], TestContext.Current.CancellationToken); (await Create().LoadAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty(); } [Fact] public void Applying_remembered_state_restores_the_matching_entries_only() { var known = Entry("http://1.2.3.4:8080"); var unknown = Entry("http://9.9.9.9:8080"); var state = new Dictionary(StringComparer.Ordinal) { [known.Endpoint.Key] = new( known.Endpoint.ToString(), Alive: true, LatencyMs: 120, SuccessCount: 7, FailureCount: 2 ), }; ProxyStateStore.Apply([known, unknown], state).ShouldBe(1); known.WasAliveOnLastRun.ShouldBeTrue(); known.Latency.ShouldBe(TimeSpan.FromMilliseconds(120)); known.SuccessCount.ShouldBe(7); unknown.WasAliveOnLastRun.ShouldBeFalse(); } [Fact] public void A_remembered_proxy_is_not_reported_live_until_it_answers_again() { // Otherwise a launch a week later would open the parser gate on week-old evidence, and the // warm-up would skip the very proxies it was supposed to re-check. var entry = Entry("http://1.2.3.4:8080"); var state = new Dictionary(StringComparer.Ordinal) { [entry.Endpoint.Key] = new(entry.Endpoint.ToString(), Alive: true), }; ProxyStateStore.Apply([entry], state); entry.Health.ShouldBe(ProxyHealthState.Unknown); entry.IsBelievedAlive.ShouldBeTrue(); } [Fact] public async Task A_remembered_proxy_that_was_never_re_checked_is_still_remembered() { // The warm-up stops early, so most remembered entries end a session unprobed. Dropping // them on save would erode the remembered set to nothing over a few launches. var entry = Entry("http://1.2.3.4:8080"); ProxyStateStore.Apply( [entry], new Dictionary(StringComparer.Ordinal) { [entry.Endpoint.Key] = new(entry.Endpoint.ToString(), Alive: true), } ); await Create().SaveAsync([entry], TestContext.Current.CancellationToken); (await Create().LoadAsync(TestContext.Current.CancellationToken)).ShouldContainKey(entry.Endpoint.Key); } [Fact] public async Task A_remembered_proxy_that_fails_its_re_check_is_forgotten() { var entry = Entry("http://1.2.3.4:8080"); ProxyStateStore.Apply( [entry], new Dictionary(StringComparer.Ordinal) { [entry.Endpoint.Key] = new(entry.Endpoint.ToString(), Alive: true), } ); entry.RecordProbe(DateTimeOffset.UtcNow, alive: false, latency: null, error: "timeout"); await Create().SaveAsync([entry], TestContext.Current.CancellationToken); (await Create().LoadAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty(); } [Fact] public void A_remembered_proxy_is_never_restored_into_a_quarantine() { // The window is wall-clock; a restart may be days later, so an expired sideline must not // be resurrected — the whole point of remembering is to start from the good ones. var entry = Entry("http://1.2.3.4:8080"); entry.RecordFailure(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5), TimeSpan.FromHours(1), 1); entry.IsQuarantined(DateTimeOffset.UtcNow).ShouldBeTrue(); var restored = new Dictionary(StringComparer.Ordinal) { [entry.Endpoint.Key] = new(entry.Endpoint.ToString(), Alive: true), }; ProxyStateStore.Apply([entry], restored); entry.IsQuarantined(DateTimeOffset.UtcNow).ShouldBeFalse(); } }