using AvParser.Core.Collecting; using AvParser.Core.Collecting.Sources; using AvParser.Core.Parsing; namespace AvParser.Core.Tests.Collecting; public class UrlListSourceTests { private static readonly IMediaSource Source = new UrlListSource(); private static async Task>> RunAsync(string text, int limit = 0) { var results = new List>(); await foreach ( var outcome in Source.ParseAsync( new MediaQuery(text, Limit: limit), null, TestContext.Current.CancellationToken ) ) { results.Add(outcome); } return results; } [Fact] public async Task Each_line_becomes_a_candidate() { var results = await RunAsync("https://example.test/a.png\nhttps://example.test/b.gif"); results.Count.ShouldBe(2); results.ShouldAllBe(r => r.IsSuccess); results[0].Value!.Url.AbsoluteUri.ShouldBe("https://example.test/a.png"); results[1].Value!.Ordinal.ShouldBe(2); } [Fact] public async Task Blank_lines_and_comments_are_ignored() { var results = await RunAsync( """ # my list https://example.test/a.png # indented comment https://example.test/b.png """ ); results.Count.ShouldBe(2); } [Fact] public async Task Whitespace_around_an_address_is_forgiven() { var results = await RunAsync(" https://example.test/a.png "); results.ShouldHaveSingleItem().IsSuccess.ShouldBeTrue(); } [Fact] public async Task A_bad_line_is_named_rather_than_dropped() { // In a paste of two hundred addresses a silently skipped typo is unfindable. var results = await RunAsync("https://example.test/a.png\nnot an address\nhttps://example.test/b.png"); results.Count.ShouldBe(3); results[1].IsSuccess.ShouldBeFalse(); results[1].Error!.Code.ShouldBe("NotAnAddress"); results[1].Error!.Arguments.ShouldContain("not an address"); } [Theory] [InlineData("file:///etc/passwd")] [InlineData("data:image/png;base64,AAAA")] [InlineData("ftp://example.test/a.png")] [InlineData("javascript:alert(1)")] public async Task Only_http_addresses_are_accepted(string line) { // A pasted list is as likely to have come from somewhere else as to have been typed. var results = await RunAsync(line); results.ShouldHaveSingleItem().IsSuccess.ShouldBeFalse(); } [Fact] public async Task A_limit_stops_the_listing_early() { var text = string.Join('\n', Enumerable.Range(0, 50).Select(i => $"https://example.test/{i}.png")); var results = await RunAsync(text, limit: 5); results.Count(r => r.IsSuccess).ShouldBe(5); } [Fact] public void The_source_needs_no_network_of_its_own() { // It discovers nothing; downloading what it read is the fetcher's job and is gated there. Source.RequiresNetwork.ShouldBeFalse(); } [Theory] [InlineData("https://example.test/a.png", true)] [InlineData("# only a comment", false)] [InlineData("", false)] [InlineData("nonsense", false)] public void CanParse_answers_without_doing_any_work(string text, bool expected) => Source.CanParse(new MediaQuery(text)).ShouldBe(expected); [Fact] public async Task Cancellation_is_honoured() { using var cancellation = new CancellationTokenSource(); await cancellation.CancelAsync(); await Should.ThrowAsync(async () => { await foreach (var _ in Source.ParseAsync(new MediaQuery("https://a.test/x.png"), null, cancellation.Token)) { // Draining is the point; the first move must already throw. } }); } } public class MediaSourceCatalogTests { [Fact] public void Sources_are_ordered_by_display_name() { var catalog = new MediaSourceCatalog([new FakeSource("z", "Zebra"), new FakeSource("a", "Aardvark")]); catalog.Sources.Select(s => s.Id).ShouldBe(["a", "z"]); } [Fact] public void The_named_default_wins_over_alphabetical_order() { // Otherwise the landing page depends on a display name, and would open on the network // source — behind the proxy gate — before the user has asked for anything. var catalog = new MediaSourceCatalog([new FakeSource("a", "Aardvark"), new FakeSource("z", "Zebra")], "z"); catalog.DefaultSource.Id.ShouldBe("z"); } [Fact] public void An_unknown_default_falls_back_rather_than_throwing() { var catalog = new MediaSourceCatalog([new FakeSource("a", "Aardvark")], "removed-in-a-past-version"); catalog.DefaultSource.Id.ShouldBe("a"); } [Fact] public void Lookup_ignores_case_and_reports_a_miss() { IMediaSourceCatalog catalog = new MediaSourceCatalog([new FakeSource("url-list", "URL list")]); catalog.Find("URL-LIST").ShouldNotBeNull(); catalog.Find("nope").ShouldBeNull(); catalog.FindOrDefault("nope").Id.ShouldBe("url-list"); } [Fact] public void An_empty_registration_is_rejected() => Should.Throw(() => new MediaSourceCatalog([])); [Fact] public void Two_sources_sharing_an_id_are_rejected() => Should.Throw(() => new MediaSourceCatalog([new FakeSource("same", "One"), new FakeSource("same", "Two")]) ); private sealed class FakeSource(string id, string name) : IMediaSource { public string Id => id; public string DisplayName => name; public string Description => string.Empty; public bool CanParse(MediaQuery input) => true; public async IAsyncEnumerable> ParseAsync( MediaQuery input, IProgress? progress, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken ) { await Task.Yield(); yield break; } } }