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);
}
}