using System.Net; using System.Net.Sockets; using System.Security.Authentication; using AvParser.Infrastructure.Collecting; namespace AvParser.Infrastructure.Tests.Collecting; /// /// The verdict table, tested as a pure function. /// /// /// It has to be a pure function to be testable at all: ProxyLease's constructor is internal /// to AvParser.Core, which declares no InternalsVisibleTo, so nothing outside the /// domain assembly can fabricate one to assert against. /// public class LeaseVerdictTests { [Theory] [InlineData(200)] [InlineData(204)] public void A_delivered_response_is_a_working_proxy(int status) => Decide(status).Verdict.ShouldBe(LeaseVerdict.Success); [Theory] [InlineData(400)] [InlineData(403)] [InlineData(404)] [InlineData(410)] [InlineData(451)] [InlineData(500)] [InlineData(503)] public void The_resource_being_broken_is_not_the_proxy_being_broken(int status) { // A dead link is the normal case on a free list. Recording it against the proxy would // quarantine working proxies at exactly the rate that dead links appear. Decide(status).Verdict.ShouldBe(LeaseVerdict.Success); } [Fact] public void A_rate_limit_is_never_the_proxy_s_fault() { // And treating it as one would make the pool rotate away from a working address in // response to being asked politely to slow down. Decide(429).Verdict.ShouldBe(LeaseVerdict.Success); } [Theory] [InlineData(407, "proxy auth")] [InlineData(502, "bad gateway")] [InlineData(504, "gateway timeout")] public void Statuses_the_proxy_itself_produces_count_against_it(int status, string reason) { var outcome = Decide(status); outcome.Verdict.ShouldBe(LeaseVerdict.Failure); outcome.Reason.ShouldBe(reason); } [Fact] public void Cancellation_teaches_the_pool_nothing() { // The deliberate neutral case: pressing Stop must not quarantine healthy proxies. LeaseVerdicts.Decide(null, null, bytesReceived: true, cancelled: true).Verdict.ShouldBe(LeaseVerdict.Neutral); LeaseVerdicts .Decide(HttpStatusCode.OK, new OperationCanceledException(), true, false) .Verdict.ShouldBe(LeaseVerdict.Neutral); } [Fact] public void No_response_at_all_is_a_failure() => LeaseVerdicts.Decide(null, null, false, false).Verdict.ShouldBe(LeaseVerdict.Failure); [Fact] public void A_refused_connection_is_a_failure() { var failure = new HttpRequestException("boom", new SocketException((int)SocketError.ConnectionRefused)); var outcome = LeaseVerdicts.Decide(null, failure, false, false); outcome.Verdict.ShouldBe(LeaseVerdict.Failure); outcome.Reason.ShouldBe("refused"); } [Fact] public void A_tls_handshake_failure_names_itself() { var outcome = LeaseVerdicts.Decide(null, new AuthenticationException("no"), false, false); outcome.Verdict.ShouldBe(LeaseVerdict.Failure); outcome.Reason.ShouldBe("tls"); } [Fact] public void A_timeout_before_any_bytes_reads_differently_from_one_after() { LeaseVerdicts.Decide(null, new TimeoutException(), false, false).Reason.ShouldBe("timeout"); LeaseVerdicts.Decide(null, new TimeoutException(), true, false).Reason.ShouldBe("stalled"); } [Fact] public void A_body_cut_short_counts_against_the_proxy() { var outcome = LeaseVerdicts.Decide(HttpStatusCode.OK, new IOException("short"), true, false); outcome.Verdict.ShouldBe(LeaseVerdict.Failure); outcome.Reason.ShouldBe("truncated"); } private static LeaseOutcome Decide(int status) => LeaseVerdicts.Decide((HttpStatusCode)status, null, bytesReceived: true, cancelled: false); } public class RetryAfterTests { private static readonly DateTimeOffset Now = new(2026, 8, 13, 12, 0, 0, TimeSpan.Zero); [Fact] public void Delta_seconds_are_read() { RetryAfter.Parse("30", Now, out var clamped).ShouldBe(TimeSpan.FromSeconds(30)); clamped.ShouldBeFalse(); } [Fact] public void An_http_date_is_read_relative_to_now() => RetryAfter.Parse(Now.AddSeconds(45).ToString("r"), Now, out _).ShouldBe(TimeSpan.FromSeconds(45)); [Fact] public void An_absurd_wait_is_pulled_back_into_range() { // A date measured against a skewed server clock otherwise turns a short pause into // something indistinguishable from a hang. RetryAfter.Parse("86400", Now, out var clamped).ShouldBe(RetryAfter.Maximum); clamped.ShouldBeTrue(); } [Fact] public void A_date_already_in_the_past_still_pauses_briefly() { RetryAfter.Parse(Now.AddMinutes(-5).ToString("r"), Now, out _).ShouldBe(RetryAfter.Minimum); RetryAfter.Parse("0", Now, out _).ShouldBe(RetryAfter.Minimum); RetryAfter.Parse("-30", Now, out _).ShouldBe(RetryAfter.Minimum); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("soon")] public void Nonsense_reads_as_absent(string? value) => RetryAfter.Parse(value, Now, out _).ShouldBeNull(); [Fact] public void Backoff_grows_and_then_stops_growing() { RetryAfter.Backoff(1).ShouldBe(TimeSpan.FromSeconds(2)); RetryAfter.Backoff(2).ShouldBe(TimeSpan.FromSeconds(4)); RetryAfter.Backoff(3).ShouldBe(TimeSpan.FromSeconds(8)); RetryAfter.Backoff(20).ShouldBe(RetryAfter.Maximum); } }