using System.Net;
using System.Text;
using AvParser.Core.Collecting;
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Collecting;
using AvParser.Infrastructure.Media;
using AvParser.Infrastructure.Proxies;
using AvParser.Infrastructure.Storage;
using Microsoft.Extensions.Logging.Abstractions;
namespace AvParser.Infrastructure.Tests.Collecting;
///
/// A factory that never uses a proxy but mirrors the real handler's configuration.
///
///
/// Matching AllowAutoRedirect = false and AutomaticDecompression = All matters: with
/// redirects handled by the BCL these tests would exercise its logic rather than the fetcher's.
///
internal sealed class DirectHttpClientFactory : IProxiedHttpClientFactory
{
public HttpClient Create(ProxyEndpoint? endpoint, TimeSpan? timeout = null) =>
Create(endpoint, HttpClientTimeouts.Default);
public HttpClient Create(ProxyEndpoint? endpoint, HttpClientTimeouts timeouts) =>
new(
new SocketsHttpHandler
{
AllowAutoRedirect = false,
ConnectTimeout = timeouts.Connect,
AutomaticDecompression = DecompressionMethods.All,
},
disposeHandler: true
)
{
Timeout = Timeout.InfiniteTimeSpan,
};
public Task<(HttpClient Client, ProxyLease? Lease)> CreateFromPoolAsync(
TimeSpan? timeout = null,
CancellationToken cancellationToken = default
) => Task.FromResult<(HttpClient, ProxyLease?)>((Create(null, timeout), null));
public Task LeaseAsync(
HttpClientTimeouts timeouts,
bool requireProxy,
CancellationToken cancellationToken = default
) =>
requireProxy
? throw new ProxyUnavailableException()
: Task.FromResult(new LeasedHttpClient(Create(null, timeouts), null));
}
public sealed class MediaFetcherTests : IAsyncLifetime
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "AvParserTests", Guid.NewGuid().ToString("N"));
private LoopbackServer _server = null!;
private AppPaths _paths = null!;
private BlobStore _blobs = null!;
private HostThrottle _throttle = null!;
private MediaFetcher _fetcher = null!;
public ValueTask InitializeAsync()
{
_paths = new AppPaths(_root);
_paths.EnsureCreated();
_server = new LoopbackServer();
_blobs = new BlobStore(_paths, NullLogger.Instance);
_throttle = Throttle();
_fetcher = new MediaFetcher(
new DirectHttpClientFactory(),
_blobs,
_throttle,
NullLogger.Instance
);
return ValueTask.CompletedTask;
}
public async ValueTask DisposeAsync()
{
await _server.DisposeAsync();
_throttle.Dispose();
if (Directory.Exists(_root))
{
try
{
Directory.Delete(_root, recursive: true);
}
catch (IOException)
{
// Not worth failing a green test over.
}
}
}
private HostThrottle Throttle(int concurrent = 4, int intervalMs = 0) =>
new(concurrent, TimeSpan.FromMilliseconds(intervalMs), NullLogger.Instance);
/// Options with the floor lowered, since the sample files are deliberately tiny.
private static FetchOptions Options(params (string Key, object Value)[] _) =>
new() { MinItemBytes = 1, Timeouts = HttpClientTimeouts.Default };
private Task FetchAsync(Uri url, FetchOptions? options = null) =>
_fetcher.FetchAsync(
new MediaCandidate(url) { SourceId = "test" },
options ?? Options(),
TestContext.Current.CancellationToken
);
/// Waits for the server's own bookkeeping to catch up with the client.
private async Task> WaitForRequestsAsync(int count, Func match)
{
for (var attempt = 0; attempt < 100; attempt++)
{
var matched = _server.Requests.Where(match).OrderBy(r => r.ArrivedUtc).ToList();
if (matched.Count >= count)
{
return matched;
}
await Task.Delay(20, TestContext.Current.CancellationToken);
}
var found = _server.Requests.Where(match).OrderBy(r => r.ArrivedUtc).ToList();
found.Count.ShouldBe(count);
return found;
}
private int StagedFileCount() =>
Directory.Exists(_paths.MediaTempDirectory) ? Directory.EnumerateFiles(_paths.MediaTempDirectory).Count() : 0;
[Fact]
public async Task A_valid_image_is_downloaded_and_hashed()
{
var url = _server.MapBody("/a.png", Samples.Png(), "image/png");
var result = await FetchAsync(url);
result.Outcome.ShouldBe(SeenOutcome.Stored);
result.Blob!.Kind.ShouldBe(MediaKind.Png);
result.Blob.Sha256.Length.ShouldBe(64);
result.TempPath.ShouldNotBeNull();
File.Exists(result.TempPath).ShouldBeTrue();
}
[Fact]
public async Task The_extension_comes_from_the_signature_not_from_the_content_type()
{
// The origin says PNG and serves a GIF. Believing the header would write a .png that no
// viewer opens, and would let a host dictate the extension of a file on the user's disk.
var url = _server.MapBody("/liar.png", Samples.Gif(), "image/png");
var result = await FetchAsync(url);
result.Blob!.Kind.ShouldBe(MediaKind.Gif);
result.Blob.Extension.ShouldBe(".gif");
result.ContentType.ShouldBe("image/png");
}
[Fact]
public async Task An_error_page_behind_a_200_is_refused()
{
var url = _server.MapBody("/gone.jpg", Samples.Html(), "image/jpeg");
var result = await FetchAsync(url);
result.Outcome.ShouldBe(SeenOutcome.NotMedia);
result.TempPath.ShouldBeNull();
StagedFileCount().ShouldBe(0);
}
[Fact]
public async Task A_kind_the_user_excluded_is_refused()
{
var url = _server.MapBody("/clip.mp4", Samples.Mp4());
var options = Options() with { AllowedKinds = new HashSet { MediaKind.Png } };
(await FetchAsync(url, options)).Outcome.ShouldBe(SeenOutcome.UnsupportedType);
}
[Theory]
[InlineData(403, SeenOutcome.Failed)]
[InlineData(404, SeenOutcome.Gone)]
[InlineData(410, SeenOutcome.Gone)]
[InlineData(500, SeenOutcome.Failed)]
public async Task Error_statuses_are_reported_without_staging_anything(int status, SeenOutcome expected)
{
var url = _server.Map($"/e{status}", new Reply { Status = status, Body = "no"u8.ToArray() });
var result = await FetchAsync(url);
result.Outcome.ShouldBe(expected);
result.HttpStatus.ShouldBe(status);
StagedFileCount().ShouldBe(0);
}
[Fact]
public async Task A_rate_limit_is_honoured_rather_than_worked_around()
{
var url = _server.Map(
"/busy",
new Reply
{
Status = 429,
Headers = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Retry-After"] = "3" },
}
);
var result = await FetchAsync(url);
result.Outcome.ShouldBe(SeenOutcome.RateLimited);
result.RetryAfter.ShouldBe(TimeSpan.FromSeconds(3));
// The host is now cooling: the collector slows down instead of switching proxy.
_throttle.CooldownRemaining(url).ShouldBeGreaterThan(TimeSpan.Zero);
}
[Fact]
public async Task A_service_unavailable_without_a_hint_still_backs_off()
{
var url = _server.Map("/down", new Reply { Status = 503 });
(await FetchAsync(url)).Outcome.ShouldBe(SeenOutcome.RateLimited);
_throttle.CooldownRemaining(url).ShouldBeGreaterThan(TimeSpan.Zero);
}
[Fact]
public async Task Headers_that_never_arrive_time_out()
{
var url = _server.Map("/slowhead", new Reply { Body = Samples.Png(), HeaderDelay = TimeSpan.FromSeconds(3) });
var options = Options() with
{
Timeouts = HttpClientTimeouts.Default with { Headers = TimeSpan.FromMilliseconds(300) },
};
(await FetchAsync(url, options)).Outcome.ShouldBe(SeenOutcome.Timeout);
StagedFileCount().ShouldBe(0);
}
[Fact]
public async Task A_body_that_stops_arriving_is_abandoned_and_leaves_nothing_behind()
{
var url = _server.Map(
"/drip",
new Reply
{
Body = Encoding.ASCII.GetBytes(new string('x', 4096)),
BodyChunkSize = 8,
BodyDelay = TimeSpan.FromSeconds(2),
}
);
var options = Options() with
{
Timeouts = HttpClientTimeouts.Default with { Idle = TimeSpan.FromMilliseconds(300) },
};
var result = await FetchAsync(url, options);
result.Outcome.ShouldBe(SeenOutcome.Timeout);
StagedFileCount().ShouldBe(0);
}
[Fact]
public async Task A_body_shorter_than_declared_is_a_truncated_transfer_not_a_small_file()
{
// The single most important rejection: a partial image promoted into the blob store would
// be indistinguishable from a real one for ever afterwards.
var body = Samples.Png();
var url = _server.Map(
"/short",
new Reply
{
Body = body,
DeclaredLength = body.Length + 5000,
TruncateAfter = body.Length,
}
);
var result = await FetchAsync(url);
result.Outcome.ShouldBe(SeenOutcome.Failed);
result.ErrorCode.ShouldBe("Truncated");
result.TempPath.ShouldBeNull();
StagedFileCount().ShouldBe(0);
}
[Fact]
public async Task A_declared_length_over_the_cap_is_refused_before_the_body_is_read()
{
var body = Encoding.ASCII.GetBytes(new string('x', 8192));
var url = _server.Map("/huge", new Reply { Body = body });
var options = Options() with { MaxItemBytes = 1024 };
var result = await FetchAsync(url, options);
result.Outcome.ShouldBe(SeenOutcome.TooLarge);
StagedFileCount().ShouldBe(0);
}
[Fact]
public async Task A_chunked_body_that_grows_past_the_cap_is_cut_off()
{
// No Content-Length to check against, so the cap has to be enforced as bytes arrive.
var body = Encoding.ASCII.GetBytes(new string('x', 8192));
var url = _server.Map(
"/huge-chunked",
new Reply
{
Body = body,
Chunked = true,
BodyChunkSize = 256,
}
);
var options = Options() with { MaxItemBytes = 1024 };
(await FetchAsync(url, options)).Outcome.ShouldBe(SeenOutcome.TooLarge);
StagedFileCount().ShouldBe(0);
}
[Fact]
public async Task A_well_formed_chunked_body_is_accepted()
{
var url = _server.Map(
"/chunked.png",
new Reply
{
Body = Samples.Png(),
Chunked = true,
BodyChunkSize = 7,
}
);
var result = await FetchAsync(url);
result.Outcome.ShouldBe(SeenOutcome.Stored);
result.Blob!.Length.ShouldBe(Samples.Png().Length);
}
[Fact]
public async Task The_same_image_served_gzipped_and_plain_is_one_blob()
{
// The handler decompresses transparently, so the hash must be over the decoded bytes.
var plain = _server.MapBody("/plain.png", Samples.Png());
var zipped = _server.Map("/zipped.png", new Reply { Body = Samples.Png(), Gzip = true });
var first = await FetchAsync(plain);
var second = await FetchAsync(zipped);
first.Blob!.Sha256.ShouldBe(second.Blob!.Sha256);
first.Blob.Length.ShouldBe(second.Blob.Length);
}
[Fact]
public async Task A_tracking_pixel_is_below_the_floor()
{
var url = _server.MapBody("/pixel.gif", Samples.Gif());
// The default floor, rather than the lowered one the other tests use.
var result = await FetchAsync(url, new FetchOptions());
result.Outcome.ShouldBe(SeenOutcome.TooSmall);
StagedFileCount().ShouldBe(0);
}
[Fact]
public async Task A_redirect_chain_is_followed_and_the_final_address_recorded()
{
var final = _server.MapBody("/final.png", Samples.Png());
_server.Map("/hop2", Redirect(302, "/final.png"));
var start = _server.Map("/hop1", Redirect(301, "/hop2"));
var result = await FetchAsync(start);
result.Outcome.ShouldBe(SeenOutcome.Stored);
result.FinalUrl.ShouldBe(final);
}
[Fact]
public async Task A_relative_location_is_resolved_against_the_current_address()
{
_server.MapBody("/other.png", Samples.Png());
var start = _server.Map("/rel", Redirect(302, "/other.png"));
(await FetchAsync(start)).Outcome.ShouldBe(SeenOutcome.Stored);
}
[Fact]
public async Task A_redirect_loop_is_broken()
{
_server.Map("/loopB", Redirect(302, "/loopA"));
var start = _server.Map("/loopA", Redirect(302, "/loopB"));
var result = await FetchAsync(start);
result.ErrorCode.ShouldBe("RedirectLoop");
}
[Fact]
public async Task A_chain_longer_than_the_cap_gives_up()
{
// Distinct hops, so this is the hop budget rather than the loop detector doing the work.
for (var hop = 0; hop < 10; hop++)
{
_server.Map($"/chain{hop}", Redirect(302, $"/chain{hop + 1}"));
}
var result = await FetchAsync(new Uri(_server.BaseAddress, "/chain0"), Options() with { MaxRedirects = 3 });
result.ErrorCode.ShouldBe("TooManyRedirects");
}
[Fact]
public async Task A_redirect_to_a_non_http_scheme_is_refused()
{
var start = _server.Map("/evil", Redirect(302, "data:image/png;base64,AAAA"));
(await FetchAsync(start)).ErrorCode.ShouldBe("BadRedirect");
}
[Fact]
public async Task A_known_placeholder_is_recognised_by_its_hash()
{
var url = _server.MapBody("/dead.png", Samples.Png());
var probe = await FetchAsync(url);
File.Delete(probe.TempPath!);
var options = Options() with
{
Tombstones = new HashSet(StringComparer.Ordinal) { probe.Blob!.Sha256 },
};
var result = await FetchAsync(url, options);
result.Outcome.ShouldBe(SeenOutcome.Placeholder);
StagedFileCount().ShouldBe(0);
}
[Fact]
public async Task Animation_is_detected_end_to_end()
{
var still = _server.MapBody("/still.gif", Samples.Gif(frames: 1));
var moving = _server.MapBody("/moving.gif", Samples.Gif(frames: 3));
(await FetchAsync(still)).Blob!.IsAnimated.ShouldBeFalse();
(await FetchAsync(moving)).Blob!.IsAnimated.ShouldBeTrue();
}
[Fact]
public async Task Dimensions_are_recorded_when_the_header_carries_them()
{
var url = _server.MapBody("/big.png", Samples.Png(1024, 768));
var blob = (await FetchAsync(url)).Blob!;
blob.Width.ShouldBe(1024);
blob.Height.ShouldBe(768);
}
[Fact]
public async Task A_referer_is_sent_when_the_candidate_carries_one()
{
var url = _server.MapBody("/ref.png", Samples.Png());
var referer = new Uri("https://example.test/page");
await _fetcher.FetchAsync(
new MediaCandidate(url) { SourceId = "test", Referer = referer },
Options(),
TestContext.Current.CancellationToken
);
var request = _server.Requests.Single(r => r.Path == "/ref.png");
request.Headers["Referer"].ShouldBe(referer.AbsoluteUri);
request.Headers["User-Agent"].ShouldContain("AvParser");
}
[Fact]
public async Task One_request_at_a_time_per_host_when_the_cap_says_so()
{
_throttle.Dispose();
_throttle = Throttle(concurrent: 1);
_fetcher = new MediaFetcher(
new DirectHttpClientFactory(),
_blobs,
_throttle,
NullLogger.Instance
);
// Real media padded out, so neither request is refused after 32 bytes and cut short —
// the point of the test is two full responses that could have overlapped.
var body = (byte[])[.. Samples.Png(), .. new byte[2048]];
var slow = new Reply
{
Body = body,
BodyChunkSize = 256,
BodyDelay = TimeSpan.FromMilliseconds(40),
};
_server.Map("/one", slow);
_server.Map("/two", slow);
await Task.WhenAll(
FetchAsync(new Uri(_server.BaseAddress, "/one")),
FetchAsync(new Uri(_server.BaseAddress, "/two"))
);
// The server records a request from its own task, which can lag the client by a moment.
var requests = await WaitForRequestsAsync(2, r => r.Path is "/one" or "/two");
// Serialised, the second request cannot arrive until the first body has been written:
// nine 256-byte writes at 40 ms apiece. Run concurrently the gap would be near zero.
// Compared against arrival rather than completion because the server records completion
// from its own task, which can lag the client by a few milliseconds.
var gap = requests[1].ArrivedUtc - requests[0].ArrivedUtc;
gap.ShouldBeGreaterThan(TimeSpan.FromMilliseconds(200));
}
[Fact]
public async Task Cancelling_mid_download_leaves_no_staged_file()
{
// Real media, or the fetcher rightly rejects it after 32 bytes and there is nothing left
// to cancel.
var url = _server.Map(
"/cancel",
new Reply
{
Body = [.. Samples.Png(), .. new byte[65536]],
BodyChunkSize = 128,
BodyDelay = TimeSpan.FromMilliseconds(30),
}
);
using var cancellation = new CancellationTokenSource();
var fetch = _fetcher.FetchAsync(new MediaCandidate(url) { SourceId = "test" }, Options(), cancellation.Token);
await Task.Delay(200, TestContext.Current.CancellationToken);
await cancellation.CancelAsync();
await Should.ThrowAsync(async () => await fetch);
StagedFileCount().ShouldBe(0);
}
[Fact]
public async Task Demanding_a_proxy_that_is_not_there_fails_the_item_rather_than_going_direct()
{
// Otherwise the request leaves from the user's own address at precisely the moment they
// asked for it not to.
var url = _server.MapBody("/direct.png", Samples.Png());
var options = Options() with { RequireProxy = true };
var result = await FetchAsync(url, options);
result.ErrorCode.ShouldBe("NoProxy");
_server.Requests.ShouldNotContain(r => r.Path == "/direct.png");
}
private static Reply Redirect(int status, string location) =>
new()
{
Status = status,
Headers = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Location"] = location },
};
}