Files
av-parser/tests/AvParser.Infrastructure.Tests/Collecting/LeaseVerdictTests.cs
T
Leonid PershinandClaude Opus 5 1742c094e9 Add the download pipeline: sniffing, redirects, throttling, verdicts
Second half of the collector foundation. Still nothing in the app references
it; the pipeline is tested end to end against a deliberately badly behaved
loopback server before anything depends on it.

Types come from the bytes, never from the URL, the extension or Content-Type -
two of those three are chosen by whoever serves the file, and a host must not
get to pick the extension of a file written to the user's disk. Animation is a
separate question from kind: GIF89a proves nothing without a second image
descriptor, and a PNG is an APNG only if acTL precedes the first IDAT, so both
are walked properly rather than guessed.

Timeouts are split three ways because HttpClient.Timeout covers the whole
response: any value large enough for a 30 MB file is also large enough for a
dead connection to hang on. Connect, headers and a per-read idle deadline let
both be strict. Redirects are followed by hand since the shared proxy handler
disables them, which is what allows a hop cap, loop detection and refusing a
jump to a data: URL.

The lease verdict is a pure function, because ProxyLease's constructor is
internal to the domain and no test can fabricate one. Its rule is that the
verdict describes the transport, not the resource: a 404 is a working proxy,
and so is a 429 - blaming the proxy for an origin's rate limit would make the
pool rotate away from a good address in response to being asked to slow down.
Cancellation reports nothing at all.

Throttling exists to be obeyed. It is raised only by the host's own 429 and 503,
and never by rotating to another proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:06:15 +03:00

160 lines
5.5 KiB
C#

using System.Net;
using System.Net.Sockets;
using System.Security.Authentication;
using AvParser.Infrastructure.Collecting;
namespace AvParser.Infrastructure.Tests.Collecting;
/// <summary>
/// The verdict table, tested as a pure function.
/// </summary>
/// <remarks>
/// It has to be a pure function to be testable at all: <c>ProxyLease</c>'s constructor is internal
/// to <c>AvParser.Core</c>, which declares no <c>InternalsVisibleTo</c>, so nothing outside the
/// domain assembly can fabricate one to assert against.
/// </remarks>
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);
}
}