From 880fd06a2a0c277b892ffda705a3bd4fdba1937c Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 27 Jul 2026 17:37:40 +1200 Subject: [PATCH 1/3] chore(deps): WolverineFx 6 + ServiceDiscovery/Resilience 10.8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the version sweep with the runtime majors held back from the previous PRs: - WolverineFx + WolverineFx.Postgresql 5.18.0 -> 6.22.0 - Microsoft.Extensions.ServiceDiscovery 9.3.1 -> 10.8.0 - Microsoft.Extensions.Http.Resilience 9.4.0 -> 10.8.0 The Extensions pair were the last packages still on 9.x while everything else had moved to 10.x. Wolverine 6 compiled with zero source changes and the whole unit suite stayed green — but the fleet was completely broken, in two separate ways that only appear at runtime. Both were found by running an agent against a real Postgres and watching a crawl actually happen, not by building. 1. Core no longer ships the runtime Roslyn compiler (GH-2876). Handler code is generated at runtime under TypeLoadMode.Dynamic, so startup died with "no IAssemblyGenerator (Roslyn) is registered". Added the WolverineFx.RuntimeCompilation package to the two hosts that call UseWolverine (the ARD and ZDF agents); it self-registers. The alternative is pre-generated code plus TypeLoadMode.Static, which trades a build step and a regeneration discipline for faster cold start — worth considering for the container images later, but not something to fold into a dependency bump. 2. ServiceLocationPolicy now defaults to NotAllowed (5.x was AllowedButWarn), so codegen refused to build a handler that needs container resolution. CrawlShowHandler needs it twice over: IEnumerable is registered through an opaque lambda factory, and IEpisodeRepository's graph reaches EF Core's own DbContextOptions factory, which we do not control. Set ServiceLocationPolicy.AllowedButWarn to restore exactly the 5.x behaviour — deliberately not Allowed, so Wolverine keeps warning and the nudge to make these inline-constructible stays visible. Verified end to end rather than by compilation: - Migrator applied all migrations against Postgres 18.3. - The ZDF agent started, the scheduler dispatched CrawlShowCommand over the durable Postgres transport, the handler received it, crawled the real ZDF API and reported "Crawled 18 episode(s) for 'heute-show' on zdf". - 18 rows landed in Episodes; Wolverine created its 8 message-store tables. - No errors in the agent log. - ./build.sh Test green (Infra 67 / App 50 / Arch 8), 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) --- Directory.Packages.props | 13 +++++++++---- .../Agents/Ard/Krautwatch.Agents.Ard.csproj | 1 + src/Presentation/Agents/Ard/Program.cs | 7 +++++++ .../Agents/Zdf/Krautwatch.Agents.Zdf.csproj | 1 + src/Presentation/Agents/Zdf/Program.cs | 7 +++++++ 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 2eb42a0..6d0a8af 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,8 +4,13 @@ - - + + + + @@ -53,8 +58,8 @@ - - + + diff --git a/src/Presentation/Agents/Ard/Krautwatch.Agents.Ard.csproj b/src/Presentation/Agents/Ard/Krautwatch.Agents.Ard.csproj index a9f263a..6bc44b4 100644 --- a/src/Presentation/Agents/Ard/Krautwatch.Agents.Ard.csproj +++ b/src/Presentation/Agents/Ard/Krautwatch.Agents.Ard.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Presentation/Agents/Ard/Program.cs b/src/Presentation/Agents/Ard/Program.cs index 1124ffe..82c7653 100644 --- a/src/Presentation/Agents/Ard/Program.cs +++ b/src/Presentation/Agents/Ard/Program.cs @@ -1,6 +1,7 @@ using Krautwatch.Application; using Krautwatch.Application.Crawling; using Krautwatch.Infrastructure; +using JasperFx.CodeGeneration.Model; using Wolverine; using Wolverine.Postgresql; @@ -42,6 +43,12 @@ { opts.PersistMessagesWithPostgresql(connectionString); opts.Policies.UseDurableLocalQueues(); + // Wolverine 6 changed the default ServiceLocationPolicy to NotAllowed (5.x was AllowedButWarn), + // which refuses to generate a handler needing container resolution. CrawlShowHandler needs it: + // IEnumerable is an opaque lambda registration, and IEpisodeRepository's + // graph reaches EF's own DbContextOptions factory — not something we control. Restore the 5.x + // behaviour: allowed, but keep Wolverine's warning so the nudge to inline stays visible. + opts.ServiceLocationPolicy = ServiceLocationPolicy.AllowedButWarn; // Discover the Crawling Action (CrawlShowHandler) in the Application assembly. opts.Discovery.IncludeAssembly(typeof(CrawlShowCommand).Assembly); }); diff --git a/src/Presentation/Agents/Zdf/Krautwatch.Agents.Zdf.csproj b/src/Presentation/Agents/Zdf/Krautwatch.Agents.Zdf.csproj index a9f263a..6bc44b4 100644 --- a/src/Presentation/Agents/Zdf/Krautwatch.Agents.Zdf.csproj +++ b/src/Presentation/Agents/Zdf/Krautwatch.Agents.Zdf.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Presentation/Agents/Zdf/Program.cs b/src/Presentation/Agents/Zdf/Program.cs index bda7683..d932c99 100644 --- a/src/Presentation/Agents/Zdf/Program.cs +++ b/src/Presentation/Agents/Zdf/Program.cs @@ -1,6 +1,7 @@ using Krautwatch.Application; using Krautwatch.Application.Crawling; using Krautwatch.Infrastructure; +using JasperFx.CodeGeneration.Model; using Wolverine; using Wolverine.Postgresql; @@ -38,6 +39,12 @@ { opts.PersistMessagesWithPostgresql(connectionString); opts.Policies.UseDurableLocalQueues(); + // Wolverine 6 changed the default ServiceLocationPolicy to NotAllowed (5.x was AllowedButWarn), + // which refuses to generate a handler needing container resolution. CrawlShowHandler needs it: + // IEnumerable is an opaque lambda registration, and IEpisodeRepository's + // graph reaches EF's own DbContextOptions factory — not something we control. Restore the 5.x + // behaviour: allowed, but keep Wolverine's warning so the nudge to inline stays visible. + opts.ServiceLocationPolicy = ServiceLocationPolicy.AllowedButWarn; // Discover the Crawling Action (CrawlShowHandler) in the Application assembly. opts.Discovery.IncludeAssembly(typeof(CrawlShowCommand).Assembly); }); From 18029505cc0e3d0549725245d792f2ea2ee3534c Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 27 Jul 2026 17:37:56 +1200 Subject: [PATCH 2/3] test: migrate to xunit v3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves all five test projects from xunit 2.9.3 to xunit.v3 3.2.2. The xunit.runner.visualstudio 3.1.5 and Microsoft.NET.Test.Sdk 18.8.1 bumped in the previous PR already support v3, so no runner change was needed. Source changes the migration forced: - xunit v3's IAsyncLifetime returns ValueTask (was Task) and extends IAsyncDisposable, so PostgresFixture and the three repository fixtures that adopted it during the SQLite removal needed new signatures. - Test projects are executables in v3 — added Exe. - TngTech.ArchUnitNET.xUnit had to go: it still depends on xunit.assert 2.x, which cannot coexist with v3, and 0.13.3 is the latest release. Swapped it for the core TngTech.ArchUnitNET package plus a local ArchRuleAssert.Check() that evaluates the rule and fails through Xunit's Assert.Fail, listing every violation. Because that replaced the assertion mechanism, the architecture tests were checked for false-passing rather than assumed green: inverting one rule (asserting Infrastructure does NOT depend on Domain, which it plainly does) produced a proper failure carrying both the rule description and its "because" reason, and reverting restored 8/8. The layer rules still genuinely enforce. Verified: - ./build.sh Test -> 125 tests, Live correctly EXCLUDED - ./build.sh TestLive -> all 16 Live tests discovered, so the Category trait filter works under v3 in both directions Note: v3 ships analyzer rule xUnit1051 (calls accepting a CancellationToken should pass TestContext.Current.CancellationToken), which does not exist in v2, so this commit introduces 258 warning instances across 129 call sites. They are adopted in the follow-up commit rather than suppressed. Noted while migrating, not addressed here: tests/Domain.Tests is referenced by the solution but contains zero test files, which is why it never appears in any test output. Either give it tests or drop it. Co-Authored-By: Claude Opus 5 (1M context) --- Directory.Packages.props | 6 +++-- .../Krautwatch.Application.Tests.csproj | 7 ++++- .../ApplicationSliceSpecs.cs | 2 +- tests/Architecture.Tests/ArchRuleAssert.cs | 27 +++++++++++++++++++ .../HexagonalArchitectureSpecs.cs | 2 +- .../Krautwatch.Architecture.Tests.csproj | 9 +++++-- .../Krautwatch.Domain.Tests.csproj | 7 ++++- .../DownloadJobRepositoryTests.cs | 4 +-- .../Infrastructure.Tests/EgressProxyTests.cs | 4 +-- .../EpisodeRepositoryTests.cs | 6 ++--- .../Krautwatch.Infrastructure.Tests.csproj | 7 ++++- tests/Infrastructure.Tests/PostgresFixture.cs | 4 +-- tests/Live.Tests/Krautwatch.Live.Tests.csproj | 7 ++++- 13 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 tests/Architecture.Tests/ArchRuleAssert.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 6d0a8af..f44376c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -65,11 +65,13 @@ - + - + + diff --git a/tests/Application.Tests/Krautwatch.Application.Tests.csproj b/tests/Application.Tests/Krautwatch.Application.Tests.csproj index 4e33c92..615c20e 100644 --- a/tests/Application.Tests/Krautwatch.Application.Tests.csproj +++ b/tests/Application.Tests/Krautwatch.Application.Tests.csproj @@ -1,5 +1,10 @@ + + + Exe + + @@ -7,7 +12,7 @@ - + diff --git a/tests/Architecture.Tests/ApplicationSliceSpecs.cs b/tests/Architecture.Tests/ApplicationSliceSpecs.cs index b11a5a2..4b85f2b 100644 --- a/tests/Architecture.Tests/ApplicationSliceSpecs.cs +++ b/tests/Architecture.Tests/ApplicationSliceSpecs.cs @@ -1,5 +1,5 @@ using ArchUnitNET.Loader; -using ArchUnitNET.xUnit; +using ArchUnitNET.Fluent; using Xunit; using static ArchUnitNET.Fluent.ArchRuleDefinition; diff --git a/tests/Architecture.Tests/ArchRuleAssert.cs b/tests/Architecture.Tests/ArchRuleAssert.cs new file mode 100644 index 0000000..cb5e0e5 --- /dev/null +++ b/tests/Architecture.Tests/ArchRuleAssert.cs @@ -0,0 +1,27 @@ +using ArchUnitNET.Domain; +using ArchUnitNET.Fluent; +using Xunit; + +namespace Krautwatch.Architecture.Tests; + +/// +/// Replaces TngTech.ArchUnitNET.xUnit's Check() extension. That package still depends on +/// xunit.assert 2.x, which cannot coexist with xunit v3, so we evaluate the rule against the +/// core ArchUnitNET API and fail through xunit ourselves. +/// +internal static class ArchRuleAssert +{ + /// Evaluates the rule and fails the test with every violation listed if it does not hold. + public static void Check(this IArchRule rule, ArchUnitNET.Domain.Architecture architecture) + { + if (rule.HasNoViolations(architecture)) return; + + var violations = rule.Evaluate(architecture) + .Where(result => !result.Passed) + .Select(result => $" - {result.Description}") + .ToList(); + + Assert.Fail($"Architecture rule violated: {rule.Description}{Environment.NewLine}" + + string.Join(Environment.NewLine, violations)); + } +} diff --git a/tests/Architecture.Tests/HexagonalArchitectureSpecs.cs b/tests/Architecture.Tests/HexagonalArchitectureSpecs.cs index e765647..bd2ccfb 100644 --- a/tests/Architecture.Tests/HexagonalArchitectureSpecs.cs +++ b/tests/Architecture.Tests/HexagonalArchitectureSpecs.cs @@ -1,6 +1,6 @@ using ArchUnitNET.Domain; using ArchUnitNET.Loader; -using ArchUnitNET.xUnit; +using ArchUnitNET.Fluent; using Xunit; using static ArchUnitNET.Fluent.ArchRuleDefinition; diff --git a/tests/Architecture.Tests/Krautwatch.Architecture.Tests.csproj b/tests/Architecture.Tests/Krautwatch.Architecture.Tests.csproj index 8eef37d..11e0fa8 100644 --- a/tests/Architecture.Tests/Krautwatch.Architecture.Tests.csproj +++ b/tests/Architecture.Tests/Krautwatch.Architecture.Tests.csproj @@ -1,5 +1,10 @@ + + + Exe + + @@ -8,9 +13,9 @@ - + - + diff --git a/tests/Domain.Tests/Krautwatch.Domain.Tests.csproj b/tests/Domain.Tests/Krautwatch.Domain.Tests.csproj index 8598ffd..c3eaf43 100644 --- a/tests/Domain.Tests/Krautwatch.Domain.Tests.csproj +++ b/tests/Domain.Tests/Krautwatch.Domain.Tests.csproj @@ -1,12 +1,17 @@ + + + Exe + + - + diff --git a/tests/Infrastructure.Tests/DownloadJobRepositoryTests.cs b/tests/Infrastructure.Tests/DownloadJobRepositoryTests.cs index e0d67ec..0900bc5 100644 --- a/tests/Infrastructure.Tests/DownloadJobRepositoryTests.cs +++ b/tests/Infrastructure.Tests/DownloadJobRepositoryTests.cs @@ -15,7 +15,7 @@ public class DownloadJobRepositoryTests(PostgresFixture postgres) : IAsyncLifeti // ExecuteUpdate + a re-read on the same context returns a stale tracked entity. private DbContextOptions _options = null!; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { _options = await postgres.CreateDatabaseAsync(); @@ -30,7 +30,7 @@ public async Task InitializeAsync() await db.SaveChangesAsync(); } - public Task DisposeAsync() => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; private DownloadJobRepository Repo() => new(new AppDbContext(_options)); diff --git a/tests/Infrastructure.Tests/EgressProxyTests.cs b/tests/Infrastructure.Tests/EgressProxyTests.cs index 9e10d44..b5b6b17 100644 --- a/tests/Infrastructure.Tests/EgressProxyTests.cs +++ b/tests/Infrastructure.Tests/EgressProxyTests.cs @@ -20,9 +20,9 @@ public class ProxyRepositoryTests(PostgresFixture postgres) : IAsyncLifetime { private DbContextOptions _options = null!; - public async Task InitializeAsync() => _options = await postgres.CreateDatabaseAsync(); + public async ValueTask InitializeAsync() => _options = await postgres.CreateDatabaseAsync(); - public Task DisposeAsync() => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; private ProxyRepository Repo() => new(new AppDbContext(_options)); diff --git a/tests/Infrastructure.Tests/EpisodeRepositoryTests.cs b/tests/Infrastructure.Tests/EpisodeRepositoryTests.cs index b335319..a5015cf 100644 --- a/tests/Infrastructure.Tests/EpisodeRepositoryTests.cs +++ b/tests/Infrastructure.Tests/EpisodeRepositoryTests.cs @@ -15,7 +15,7 @@ public class EpisodeRepositoryTests(PostgresFixture postgres) : IAsyncLifetime private AppDbContext _db = null!; private EpisodeRepository _sut = null!; - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { _db = new AppDbContext(await postgres.CreateDatabaseAsync()); _sut = new EpisodeRepository(_db); @@ -23,10 +23,10 @@ public async Task InitializeAsync() SeedTestData(); } - public Task DisposeAsync() + public ValueTask DisposeAsync() { _db.Dispose(); - return Task.CompletedTask; + return ValueTask.CompletedTask; } private void SeedTestData() diff --git a/tests/Infrastructure.Tests/Krautwatch.Infrastructure.Tests.csproj b/tests/Infrastructure.Tests/Krautwatch.Infrastructure.Tests.csproj index d58b725..0ea2a9a 100644 --- a/tests/Infrastructure.Tests/Krautwatch.Infrastructure.Tests.csproj +++ b/tests/Infrastructure.Tests/Krautwatch.Infrastructure.Tests.csproj @@ -1,5 +1,10 @@ + + + Exe + + @@ -7,7 +12,7 @@ - + diff --git a/tests/Infrastructure.Tests/PostgresFixture.cs b/tests/Infrastructure.Tests/PostgresFixture.cs index 5916bff..4186fdc 100644 --- a/tests/Infrastructure.Tests/PostgresFixture.cs +++ b/tests/Infrastructure.Tests/PostgresFixture.cs @@ -20,9 +20,9 @@ public sealed class PostgresFixture : IAsyncLifetime private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("postgres:17-alpine").Build(); - public Task InitializeAsync() => _container.StartAsync(); + public async ValueTask InitializeAsync() => await _container.StartAsync(); - public Task DisposeAsync() => _container.DisposeAsync().AsTask(); + public ValueTask DisposeAsync() => _container.DisposeAsync(); /// /// Creates an isolated database with the schema applied, and returns options bound to it. diff --git a/tests/Live.Tests/Krautwatch.Live.Tests.csproj b/tests/Live.Tests/Krautwatch.Live.Tests.csproj index 191fa07..b6a0498 100644 --- a/tests/Live.Tests/Krautwatch.Live.Tests.csproj +++ b/tests/Live.Tests/Krautwatch.Live.Tests.csproj @@ -1,5 +1,10 @@ + + + Exe + + @@ -7,7 +12,7 @@ - + From c00725cde577f00cf32eb4742818a48284811c31 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 27 Jul 2026 19:23:26 +1200 Subject: [PATCH 3/3] =?UTF-8?q?test:=20adopt=20xUnit1051=20=E2=80=94=20pas?= =?UTF-8?q?s=20TestContext.Current.CancellationToken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xunit v3 ships analyzer rule xUnit1051: calls that accept a CancellationToken should pass TestContext.Current.CancellationToken so the runner can cancel tests. The rule does not exist in v2, so the migration introduced 258 warning instances across 129 call sites. Adopted rather than suppressed — it earns its place in Live.Tests, where a single test can spend six minutes downloading from a CDN and previously could not be cancelled at all. The build is back to 0 warnings / 0 errors. Three distinct cases, because "append an argument" is wrong more often than not: - Token slot omitted -> append the token. - Token slot already filled with `default` -> replace it. NSubstitute *setups* (`GetByIdAsync("ep-1", default).Returns(...)`) become `Arg.Any()`, matching the convention the verification calls in this suite already used. Binding a setup to one specific token would silently stop matching once the handler forwards a different one, and a substitute that stops matching returns null rather than failing loudly. - Methods with other optional parameters before the token (`FindShowAsync(query, client = "ard", ct = default)`) need a NAMED argument, since positional insertion lands in the wrong slot. Verification calls moved from `default` to the test's own token, e.g. `Received(1).AddAsync(Arg.Any(), TestContext.Current.CancellationToken)`. That is a slightly stronger assertion than before: it now proves the token is actually propagated from the test through the handler to the repository. Framework calls the automation could not classify were done by hand: FluentValidation ValidateAsync, HttpContent.ReadAsStreamAsync, Stream.WriteAsync, and EF's FindAsync (whose cancellable overload takes the key values as an object[], so the call shape changes rather than just gaining an argument). Verified: - Build: 0 warnings, 0 errors (was 258 xUnit1051 instances) - ./build.sh Test: 125 green — which also confirms the NSubstitute rewrites still match, since a broken setup would return null and fail the assertions - ./build.sh TestLive: real ARD/ZDF network suite green Co-Authored-By: Claude Opus 5 (1M context) --- .../CrawlShowHandlerTests.cs | 8 +-- .../Application.Tests/DownloadHandlerTests.cs | 63 +++++++++---------- tests/Application.Tests/IndexingTests.cs | 6 +- .../RefreshProxyListHandlerTests.cs | 4 +- .../RunDownloadHandlerTests.cs | 6 +- .../Application.Tests/SabnzbdDownloadTests.cs | 8 +-- .../SearchCatalogQueryHandlerTests.cs | 6 +- .../SearchCatalogValidatorTests.cs | 8 +-- .../DownloadJobRepositoryTests.cs | 22 +++---- .../Infrastructure.Tests/EgressProxyTests.cs | 22 +++---- .../EpisodeRepositoryTests.cs | 34 +++++----- tests/Live.Tests/ArdLiveTests.cs | 18 +++--- .../Live.Tests/BroadcasterCrawlerLiveTests.cs | 4 +- tests/Live.Tests/FullDownloadTests.cs | 16 ++--- tests/Live.Tests/FullEpisodeFetchTests.cs | 16 ++--- tests/Live.Tests/ZdfLiveTests.cs | 18 +++--- 16 files changed, 129 insertions(+), 130 deletions(-) diff --git a/tests/Application.Tests/CrawlShowHandlerTests.cs b/tests/Application.Tests/CrawlShowHandlerTests.cs index 003789d..010d9be 100644 --- a/tests/Application.Tests/CrawlShowHandlerTests.cs +++ b/tests/Application.Tests/CrawlShowHandlerTests.cs @@ -41,7 +41,7 @@ public async Task Handle_selects_crawler_by_provider_and_upserts_the_crawled_epi var repo = Substitute.For(); var handler = new CrawlShowHandler([otherProvider, zdf], repo, NullLogger.Instance); - await handler.HandleAsync(new CrawlShowCommand("zdf", "heute-show")); + await handler.HandleAsync(new CrawlShowCommand("zdf", "heute-show"), TestContext.Current.CancellationToken); zdf.LastQuery.ShouldBe("heute-show"); otherProvider.LastQuery.ShouldBeNull(); // the ARD crawler must not be invoked @@ -56,7 +56,7 @@ public async Task Handle_is_case_insensitive_on_provider_key() var repo = Substitute.For(); var handler = new CrawlShowHandler([zdf], repo, NullLogger.Instance); - await handler.HandleAsync(new CrawlShowCommand("ZDF", "heute-show")); + await handler.HandleAsync(new CrawlShowCommand("ZDF", "heute-show"), TestContext.Current.CancellationToken); zdf.LastQuery.ShouldBe("heute-show"); await repo.Received(1).UpsertManyAsync(Arg.Any>(), Arg.Any()); @@ -68,7 +68,7 @@ public async Task Handle_unknown_provider_is_a_no_op() var repo = Substitute.For(); var handler = new CrawlShowHandler([], repo, NullLogger.Instance); - await handler.HandleAsync(new CrawlShowCommand("kika", "Biene Maja")); + await handler.HandleAsync(new CrawlShowCommand("kika", "Biene Maja"), TestContext.Current.CancellationToken); await repo.DidNotReceive().UpsertManyAsync(Arg.Any>(), Arg.Any()); } @@ -80,7 +80,7 @@ public async Task Handle_empty_crawl_result_does_not_upsert() var repo = Substitute.For(); var handler = new CrawlShowHandler([ard], repo, NullLogger.Instance); - await handler.HandleAsync(new CrawlShowCommand("ard", "Nonexistent Show")); + await handler.HandleAsync(new CrawlShowCommand("ard", "Nonexistent Show"), TestContext.Current.CancellationToken); await repo.DidNotReceive().UpsertManyAsync(Arg.Any>(), Arg.Any()); } diff --git a/tests/Application.Tests/DownloadHandlerTests.cs b/tests/Application.Tests/DownloadHandlerTests.cs index dabedaa..5090cd5 100644 --- a/tests/Application.Tests/DownloadHandlerTests.cs +++ b/tests/Application.Tests/DownloadHandlerTests.cs @@ -67,43 +67,42 @@ public class StartDownloadHandlerTests public async Task ValidRequest_CreatesJobAndEnqueues() { var episode = Fixtures.MakeEpisode(); - _episodes.GetByIdAsync("ep-1", default).Returns(episode); + _episodes.GetByIdAsync("ep-1", Arg.Any()).Returns(episode); - var result = await Handler().HandleAsync(new StartDownloadRequest("ep-1", "stream-1")); + var result = await Handler().HandleAsync(new StartDownloadRequest("ep-1", "stream-1"), TestContext.Current.CancellationToken); result.ShouldNotBeNull(); result!.EpisodeId.ShouldBe("ep-1"); result.Status.ShouldBe(nameof(DownloadStatus.Queued)); - await _jobs.Received(1).AddAsync(Arg.Any(), default); + await _jobs.Received(1).AddAsync(Arg.Any(), TestContext.Current.CancellationToken); await _queue.Received(1).EnqueueAsync( Arg.Any(), - "https://example.com/ep.mp4", - default); + "https://example.com/ep.mp4", TestContext.Current.CancellationToken); } [Fact] public async Task EpisodeNotFound_ReturnsNull_NoJobCreated() { - _episodes.GetByIdAsync("bad-id", default).Returns((Episode?)null); + _episodes.GetByIdAsync("bad-id", Arg.Any()).Returns((Episode?)null); - var result = await Handler().HandleAsync(new StartDownloadRequest("bad-id", "stream-1")); + var result = await Handler().HandleAsync(new StartDownloadRequest("bad-id", "stream-1"), TestContext.Current.CancellationToken); result.ShouldBeNull(); - await _jobs.DidNotReceive().AddAsync(Arg.Any(), default); - await _queue.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), default); + await _jobs.DidNotReceive().AddAsync(Arg.Any(), TestContext.Current.CancellationToken); + await _queue.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), TestContext.Current.CancellationToken); } [Fact] public async Task StreamNotFound_ReturnsNull_NoJobCreated() { var episode = Fixtures.MakeEpisode("real-stream"); - _episodes.GetByIdAsync("ep-1", default).Returns(episode); + _episodes.GetByIdAsync("ep-1", Arg.Any()).Returns(episode); - var result = await Handler().HandleAsync(new StartDownloadRequest("ep-1", "wrong-stream")); + var result = await Handler().HandleAsync(new StartDownloadRequest("ep-1", "wrong-stream"), TestContext.Current.CancellationToken); result.ShouldBeNull(); - await _jobs.DidNotReceive().AddAsync(Arg.Any(), default); + await _jobs.DidNotReceive().AddAsync(Arg.Any(), TestContext.Current.CancellationToken); } } @@ -125,13 +124,13 @@ public async Task ActiveStatus_CancelsAndReturnsTrue(DownloadStatus status) { var job = Fixtures.MakeJob(); ApplyStatus(job, status); - _jobs.GetByIdAsync(job.Id, default).Returns(job); + _jobs.GetByIdAsync(job.Id, Arg.Any()).Returns(job); - var result = await Handler().HandleAsync(job.Id); + var result = await Handler().HandleAsync(job.Id, TestContext.Current.CancellationToken); result.ShouldBeTrue(); job.Status.ShouldBe(DownloadStatus.Cancelled); - await _jobs.Received(1).UpdateAsync(job, default); + await _jobs.Received(1).UpdateAsync(job, TestContext.Current.CancellationToken); } [Theory] @@ -144,20 +143,20 @@ public async Task TerminalStatus_ReturnsFalse_NoUpdate(DownloadStatus status) { var job = Fixtures.MakeJob(); ApplyStatus(job, status); - _jobs.GetByIdAsync(job.Id, default).Returns(job); + _jobs.GetByIdAsync(job.Id, Arg.Any()).Returns(job); - var result = await Handler().HandleAsync(job.Id); + var result = await Handler().HandleAsync(job.Id, TestContext.Current.CancellationToken); result.ShouldBeFalse(); - await _jobs.DidNotReceive().UpdateAsync(Arg.Any(), default); + await _jobs.DidNotReceive().UpdateAsync(Arg.Any(), TestContext.Current.CancellationToken); } [Fact] public async Task JobNotFound_ReturnsFalse() { - _jobs.GetByIdAsync(Arg.Any(), default).Returns((DownloadJob?)null); + _jobs.GetByIdAsync(Arg.Any(), Arg.Any()).Returns((DownloadJob?)null); - var result = await Handler().HandleAsync(Guid.NewGuid()); + var result = await Handler().HandleAsync(Guid.NewGuid(), TestContext.Current.CancellationToken); result.ShouldBeFalse(); } @@ -197,17 +196,17 @@ public async Task FailedOrCancelledJob_CreatesNewJobAndEnqueues(DownloadStatus s { var original = Fixtures.MakeJob(); ApplyTerminal(original, status); - _jobs.GetByIdAsync(original.Id, default).Returns(original); + _jobs.GetByIdAsync(original.Id, Arg.Any()).Returns(original); - var result = await Handler().HandleAsync(original.Id); + var result = await Handler().HandleAsync(original.Id, TestContext.Current.CancellationToken); result.ShouldNotBeNull(); result!.Status.ShouldBe(nameof(DownloadStatus.Queued)); // New job created — not the original id result.JobId.ShouldNotBe(original.Id); - await _jobs.Received(1).AddAsync(Arg.Any(), default); - await _queue.Received(1).RequeueAsync(Arg.Any(), original.StreamUrl, default); + await _jobs.Received(1).AddAsync(Arg.Any(), TestContext.Current.CancellationToken); + await _queue.Received(1).RequeueAsync(Arg.Any(), original.StreamUrl, TestContext.Current.CancellationToken); } [Fact] @@ -215,32 +214,32 @@ public async Task CompletedJob_ReturnsNull_NothingEnqueued() { var job = Fixtures.MakeJob(); job.MarkCompleted("/out/file.mp4", 1024); - _jobs.GetByIdAsync(job.Id, default).Returns(job); + _jobs.GetByIdAsync(job.Id, Arg.Any()).Returns(job); - var result = await Handler().HandleAsync(job.Id); + var result = await Handler().HandleAsync(job.Id, TestContext.Current.CancellationToken); result.ShouldBeNull(); - await _queue.DidNotReceive().RequeueAsync(Arg.Any(), Arg.Any(), default); + await _queue.DidNotReceive().RequeueAsync(Arg.Any(), Arg.Any(), TestContext.Current.CancellationToken); } [Fact] public async Task ActiveJob_ReturnsNull_NothingEnqueued() { var job = Fixtures.MakeJob(); // Queued — IsTerminal = false - _jobs.GetByIdAsync(job.Id, default).Returns(job); + _jobs.GetByIdAsync(job.Id, Arg.Any()).Returns(job); - var result = await Handler().HandleAsync(job.Id); + var result = await Handler().HandleAsync(job.Id, TestContext.Current.CancellationToken); result.ShouldBeNull(); - await _queue.DidNotReceive().RequeueAsync(Arg.Any(), Arg.Any(), default); + await _queue.DidNotReceive().RequeueAsync(Arg.Any(), Arg.Any(), TestContext.Current.CancellationToken); } [Fact] public async Task JobNotFound_ReturnsNull() { - _jobs.GetByIdAsync(Arg.Any(), default).Returns((DownloadJob?)null); + _jobs.GetByIdAsync(Arg.Any(), Arg.Any()).Returns((DownloadJob?)null); - var result = await Handler().HandleAsync(Guid.NewGuid()); + var result = await Handler().HandleAsync(Guid.NewGuid(), TestContext.Current.CancellationToken); result.ShouldBeNull(); } diff --git a/tests/Application.Tests/IndexingTests.cs b/tests/Application.Tests/IndexingTests.cs index 6bd7edb..ff64b92 100644 --- a/tests/Application.Tests/IndexingTests.cs +++ b/tests/Application.Tests/IndexingTests.cs @@ -60,7 +60,7 @@ public async Task Query_maps_episodes_to_releases_with_stable_guid_and_token() repo.SearchAsync("heute-show", Arg.Any()) .Returns(new[] { Ep("zdf:doc-1", SeriesType.Daily, null, null) }); - var releases = await new SearchReleasesHandler(repo).HandleAsync(new SearchReleasesQuery("heute-show")); + var releases = await new SearchReleasesHandler(repo).HandleAsync(new SearchReleasesQuery("heute-show"), TestContext.Current.CancellationToken); var release = releases.ShouldHaveSingleItem(); release.Guid.ShouldBe("zdf:doc-1"); @@ -76,7 +76,7 @@ public async Task Empty_query_reads_the_recent_feed() repo.GetRecentAsync(Arg.Any(), Arg.Any()) .Returns(new[] { Ep("zdf:doc-9", SeriesType.Daily, null, null) }); - var releases = await new SearchReleasesHandler(repo).HandleAsync(new SearchReleasesQuery(Q: null)); + var releases = await new SearchReleasesHandler(repo).HandleAsync(new SearchReleasesQuery(Q: null), TestContext.Current.CancellationToken); releases.ShouldHaveSingleItem().Guid.ShouldBe("zdf:doc-9"); await repo.DidNotReceive().SearchAsync(Arg.Any(), Arg.Any()); @@ -93,7 +93,7 @@ public async Task Season_and_episode_filter_a_standard_series() }); var releases = await new SearchReleasesHandler(repo) - .HandleAsync(new SearchReleasesQuery("Die Biene Maja", Season: 2, Episode: 52)); + .HandleAsync(new SearchReleasesQuery("Die Biene Maja", Season: 2, Episode: 52), TestContext.Current.CancellationToken); var release = releases.ShouldHaveSingleItem(); release.Guid.ShouldBe("kika:2"); diff --git a/tests/Application.Tests/RefreshProxyListHandlerTests.cs b/tests/Application.Tests/RefreshProxyListHandlerTests.cs index 5c2c3d7..e578ca7 100644 --- a/tests/Application.Tests/RefreshProxyListHandlerTests.cs +++ b/tests/Application.Tests/RefreshProxyListHandlerTests.cs @@ -22,7 +22,7 @@ public async Task Upserts_the_fetched_candidates() source.FetchAsync(Arg.Any()).Returns([P("1.1.1.1"), P("2.2.2.2")]); var repo = Substitute.For(); - await new RefreshProxyListHandler(source, repo, NullLogger.Instance).HandleAsync(); + await new RefreshProxyListHandler(source, repo, NullLogger.Instance).HandleAsync(TestContext.Current.CancellationToken); await repo.Received(1).UpsertBatchAsync( Arg.Is>(ps => ps != null && ps.Count() == 2), Arg.Any()); @@ -35,7 +35,7 @@ public async Task An_empty_fetch_keeps_the_cached_rows_untouched() source.FetchAsync(Arg.Any()).Returns([]); var repo = Substitute.For(); - await new RefreshProxyListHandler(source, repo, NullLogger.Instance).HandleAsync(); + await new RefreshProxyListHandler(source, repo, NullLogger.Instance).HandleAsync(TestContext.Current.CancellationToken); await repo.DidNotReceive().UpsertBatchAsync(Arg.Any>(), Arg.Any()); } diff --git a/tests/Application.Tests/RunDownloadHandlerTests.cs b/tests/Application.Tests/RunDownloadHandlerTests.cs index 4c37394..3343129 100644 --- a/tests/Application.Tests/RunDownloadHandlerTests.cs +++ b/tests/Application.Tests/RunDownloadHandlerTests.cs @@ -52,7 +52,7 @@ public async Task Downloads_and_marks_the_job_completed() provider.DownloadAsync(job, "/downloads", Arg.Any>(), Arg.Any()) .Returns(new DownloadResult("/downloads/ZDF/heute-show/x.mp4", 123_456)); - await Sut(jobs, provider, settings).HandleAsync(job); + await Sut(jobs, provider, settings).HandleAsync(job, TestContext.Current.CancellationToken); job.Status.ShouldBe(DownloadStatus.Completed); job.OutputPath.ShouldBe("/downloads/ZDF/heute-show/x.mp4"); @@ -68,7 +68,7 @@ public async Task Provider_failure_marks_the_job_download_failed() provider.DownloadAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) .ThrowsAsync(new HttpRequestException("stream 403")); - await Sut(jobs, provider, settings).HandleAsync(job); + await Sut(jobs, provider, settings).HandleAsync(job, TestContext.Current.CancellationToken); job.Status.ShouldBe(DownloadStatus.DownloadFailed); job.ErrorMessage.ShouldBe("stream 403"); @@ -80,7 +80,7 @@ public async Task A_job_missing_episode_metadata_fails_without_downloading() var job = new DownloadJob { Id = Guid.NewGuid(), EpisodeId = "x", StreamUrl = "https://cdn/x.mp4", Quality = VideoQuality.High }; var (jobs, provider, settings) = Deps(); - await Sut(jobs, provider, settings).HandleAsync(job); + await Sut(jobs, provider, settings).HandleAsync(job, TestContext.Current.CancellationToken); job.Status.ShouldBe(DownloadStatus.DownloadFailed); await provider.DidNotReceive().DownloadAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()); diff --git a/tests/Application.Tests/SabnzbdDownloadTests.cs b/tests/Application.Tests/SabnzbdDownloadTests.cs index a33611e..e97a42b 100644 --- a/tests/Application.Tests/SabnzbdDownloadTests.cs +++ b/tests/Application.Tests/SabnzbdDownloadTests.cs @@ -64,7 +64,7 @@ public async Task Creates_and_enqueues_a_job_for_a_known_token() var jobs = Substitute.For(); var queue = Substitute.For(); - var jobId = await new AddDownloadByTokenHandler(episodes, jobs, queue).HandleAsync("zdf:1"); + var jobId = await new AddDownloadByTokenHandler(episodes, jobs, queue).HandleAsync("zdf:1", TestContext.Current.CancellationToken); jobId.ShouldNotBeNull(); await jobs.Received(1).AddAsync(Arg.Is(j => j != null && j.EpisodeId == "zdf:1" && j.StreamUrl == "https://cdn/x.mp4"), Arg.Any()); @@ -80,7 +80,7 @@ public async Task Snapshots_the_episodes_geo_restriction_onto_the_job() var jobs = Substitute.For(); var queue = Substitute.For(); - await new AddDownloadByTokenHandler(episodes, jobs, queue).HandleAsync("kika:1"); + await new AddDownloadByTokenHandler(episodes, jobs, queue).HandleAsync("kika:1", TestContext.Current.CancellationToken); await jobs.Received(1).AddAsync(Arg.Is(j => j != null && j.GeoRestricted), Arg.Any()); } @@ -93,7 +93,7 @@ public async Task Unknown_token_returns_null_and_enqueues_nothing() var jobs = Substitute.For(); var queue = Substitute.For(); - var jobId = await new AddDownloadByTokenHandler(episodes, jobs, queue).HandleAsync("nope"); + var jobId = await new AddDownloadByTokenHandler(episodes, jobs, queue).HandleAsync("nope", TestContext.Current.CancellationToken); jobId.ShouldBeNull(); await queue.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); @@ -110,7 +110,7 @@ public async Task Episode_without_a_stream_returns_null() var jobs = Substitute.For(); var queue = Substitute.For(); - var jobId = await new AddDownloadByTokenHandler(episodes, jobs, queue).HandleAsync("zdf:2"); + var jobId = await new AddDownloadByTokenHandler(episodes, jobs, queue).HandleAsync("zdf:2", TestContext.Current.CancellationToken); jobId.ShouldBeNull(); await jobs.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); diff --git a/tests/Application.Tests/SearchCatalogQueryHandlerTests.cs b/tests/Application.Tests/SearchCatalogQueryHandlerTests.cs index a1e95f4..ef1c714 100644 --- a/tests/Application.Tests/SearchCatalogQueryHandlerTests.cs +++ b/tests/Application.Tests/SearchCatalogQueryHandlerTests.cs @@ -56,7 +56,7 @@ public async Task HandleAsync_WithResults_ReturnsMappedResponses() .Returns(episodes); // Act - var results = await _sut.HandleAsync(new SearchCatalogQuery("tagesschau")); + var results = await _sut.HandleAsync(new SearchCatalogQuery("tagesschau"), TestContext.Current.CancellationToken); // Assert results.Count.ShouldBe(1); @@ -80,7 +80,7 @@ public async Task HandleAsync_NoResults_ReturnsEmptyList() .SearchAsync(Arg.Any(), Arg.Any()) .Returns(new List()); - var results = await _sut.HandleAsync(new SearchCatalogQuery("noresults")); + var results = await _sut.HandleAsync(new SearchCatalogQuery("noresults"), TestContext.Current.CancellationToken); results.ShouldBeEmpty(); } @@ -92,7 +92,7 @@ public async Task HandleAsync_CallsRepositoryWithCorrectQuery() .SearchAsync(Arg.Any(), Arg.Any()) .Returns(new List()); - await _sut.HandleAsync(new SearchCatalogQuery("tagesthemen")); + await _sut.HandleAsync(new SearchCatalogQuery("tagesthemen"), TestContext.Current.CancellationToken); await _repository .Received(1) diff --git a/tests/Application.Tests/SearchCatalogValidatorTests.cs b/tests/Application.Tests/SearchCatalogValidatorTests.cs index 6681905..2215eae 100644 --- a/tests/Application.Tests/SearchCatalogValidatorTests.cs +++ b/tests/Application.Tests/SearchCatalogValidatorTests.cs @@ -11,7 +11,7 @@ public class SearchCatalogQueryValidatorTests [Fact] public async Task Validate_ValidQuery_PassesValidation() { - var result = await _sut.ValidateAsync(new SearchCatalogQuery("tagesschau")); + var result = await _sut.ValidateAsync(new SearchCatalogQuery("tagesschau"), TestContext.Current.CancellationToken); result.IsValid.ShouldBeTrue(); } @@ -20,7 +20,7 @@ public async Task Validate_ValidQuery_PassesValidation() [InlineData(" ")] public async Task Validate_EmptyQuery_FailsValidation(string query) { - var result = await _sut.ValidateAsync(new SearchCatalogQuery(query)); + var result = await _sut.ValidateAsync(new SearchCatalogQuery(query), TestContext.Current.CancellationToken); result.IsValid.ShouldBeFalse(); result.Errors.ShouldContain(e => e.PropertyName == "Query"); } @@ -28,7 +28,7 @@ public async Task Validate_EmptyQuery_FailsValidation(string query) [Fact] public async Task Validate_SingleCharQuery_FailsValidation() { - var result = await _sut.ValidateAsync(new SearchCatalogQuery("a")); + var result = await _sut.ValidateAsync(new SearchCatalogQuery("a"), TestContext.Current.CancellationToken); result.IsValid.ShouldBeFalse(); result.Errors.ShouldContain(e => e.ErrorMessage.Contains("2 characters")); } @@ -37,7 +37,7 @@ public async Task Validate_SingleCharQuery_FailsValidation() public async Task Validate_QueryExceeding200Chars_FailsValidation() { var longQuery = new string('a', 201); - var result = await _sut.ValidateAsync(new SearchCatalogQuery(longQuery)); + var result = await _sut.ValidateAsync(new SearchCatalogQuery(longQuery), TestContext.Current.CancellationToken); result.IsValid.ShouldBeFalse(); result.Errors.ShouldContain(e => e.ErrorMessage.Contains("200 characters")); } diff --git a/tests/Infrastructure.Tests/DownloadJobRepositoryTests.cs b/tests/Infrastructure.Tests/DownloadJobRepositoryTests.cs index 0900bc5..b7cfa63 100644 --- a/tests/Infrastructure.Tests/DownloadJobRepositoryTests.cs +++ b/tests/Infrastructure.Tests/DownloadJobRepositoryTests.cs @@ -56,7 +56,7 @@ public async Task TryClaimNext_claims_the_oldest_queued_job_with_its_episode() var older = await AddQueuedAsync(DateTimeOffset.UtcNow.AddMinutes(-10)); await AddQueuedAsync(DateTimeOffset.UtcNow.AddMinutes(-1)); - var claimed = await Repo().TryClaimNextAsync("worker-1"); + var claimed = await Repo().TryClaimNextAsync("worker-1", TestContext.Current.CancellationToken); claimed.ShouldNotBeNull(); claimed!.Id.ShouldBe(older); @@ -68,7 +68,7 @@ public async Task TryClaimNext_claims_the_oldest_queued_job_with_its_episode() [Fact] public async Task TryClaimNext_returns_null_when_nothing_is_queued() { - (await Repo().TryClaimNextAsync("worker-1")).ShouldBeNull(); + (await Repo().TryClaimNextAsync("worker-1", TestContext.Current.CancellationToken)).ShouldBeNull(); } [Fact] @@ -76,20 +76,20 @@ public async Task TryClaimNext_will_not_hand_the_same_job_to_two_workers() { await AddQueuedAsync(DateTimeOffset.UtcNow.AddMinutes(-5)); - (await Repo().TryClaimNextAsync("worker-1")).ShouldNotBeNull(); - (await Repo().TryClaimNextAsync("worker-2")).ShouldBeNull(); // the only job is already claimed + (await Repo().TryClaimNextAsync("worker-1", TestContext.Current.CancellationToken)).ShouldNotBeNull(); + (await Repo().TryClaimNextAsync("worker-2", TestContext.Current.CancellationToken)).ShouldBeNull(); // the only job is already claimed } [Fact] public async Task ReclaimStale_requeues_this_workers_downloading_jobs() { var id = await AddQueuedAsync(DateTimeOffset.UtcNow.AddMinutes(-5)); - await Repo().TryClaimNextAsync("worker-1"); // → Downloading, worker-1 + await Repo().TryClaimNextAsync("worker-1", TestContext.Current.CancellationToken); // → Downloading, worker-1 - var reclaimed = await Repo().ReclaimStaleAsync("worker-1"); + var reclaimed = await Repo().ReclaimStaleAsync("worker-1", TestContext.Current.CancellationToken); reclaimed.ShouldBe(1); - var job = await Repo().GetByIdAsync(id); + var job = await Repo().GetByIdAsync(id, TestContext.Current.CancellationToken); job!.Status.ShouldBe(DownloadStatus.Queued); job.WorkerId.ShouldBeNull(); } @@ -98,9 +98,9 @@ public async Task ReclaimStale_requeues_this_workers_downloading_jobs() public async Task ReclaimStale_leaves_other_workers_jobs_alone() { await AddQueuedAsync(DateTimeOffset.UtcNow.AddMinutes(-5)); - await Repo().TryClaimNextAsync("worker-1"); + await Repo().TryClaimNextAsync("worker-1", TestContext.Current.CancellationToken); - (await Repo().ReclaimStaleAsync("worker-2")).ShouldBe(0); + (await Repo().ReclaimStaleAsync("worker-2", TestContext.Current.CancellationToken)).ShouldBe(0); } [Fact] @@ -108,9 +108,9 @@ public async Task Delete_removes_the_job() { var id = await AddQueuedAsync(DateTimeOffset.UtcNow); - await Repo().DeleteAsync(id); + await Repo().DeleteAsync(id, TestContext.Current.CancellationToken); - (await Repo().GetByIdAsync(id)).ShouldBeNull(); + (await Repo().GetByIdAsync(id, TestContext.Current.CancellationToken)).ShouldBeNull(); } } diff --git a/tests/Infrastructure.Tests/EgressProxyTests.cs b/tests/Infrastructure.Tests/EgressProxyTests.cs index b5b6b17..427400a 100644 --- a/tests/Infrastructure.Tests/EgressProxyTests.cs +++ b/tests/Infrastructure.Tests/EgressProxyTests.cs @@ -35,13 +35,13 @@ public class ProxyRepositoryTests(PostgresFixture postgres) : IAsyncLifetime [Fact] public async Task Upsert_adds_new_rows_then_refreshes_metrics_but_keeps_our_feedback() { - await Repo().UpsertBatchAsync([P("1.1.1.1", 3128, upTime: 40)]); - await Repo().RecordProbeResultAsync("http://1.1.1.1:3128", ok: true); + await Repo().UpsertBatchAsync([P("1.1.1.1", 3128, upTime: 40)], TestContext.Current.CancellationToken); + await Repo().RecordProbeResultAsync("http://1.1.1.1:3128", ok: true, TestContext.Current.CancellationToken); // A refresh brings new source metrics for the same host:port. - await Repo().UpsertBatchAsync([P("1.1.1.1", 3128, upTime: 90)]); + await Repo().UpsertBatchAsync([P("1.1.1.1", 3128, upTime: 90)], TestContext.Current.CancellationToken); - var row = (await Repo().GetRankedAsync("DE", 10)).ShouldHaveSingleItem(); + var row = (await Repo().GetRankedAsync("DE", 10, TestContext.Current.CancellationToken)).ShouldHaveSingleItem(); row.UpTime.ShouldBe(90); // source metric refreshed row.LastProbeOk.ShouldBe(true); // our feedback preserved across the refresh } @@ -54,9 +54,9 @@ await Repo().UpsertBatchAsync( P("bad", 1, upTime: 99, probeOk: false), // known-bad sinks despite high uptime P("unknown", 2, upTime: 60), // untested P("good", 3, upTime: 10, probeOk: true), // known-good floats up despite low uptime - ]); + ], TestContext.Current.CancellationToken); - var ranked = await Repo().GetRankedAsync("DE", 10); + var ranked = await Repo().GetRankedAsync("DE", 10, TestContext.Current.CancellationToken); ranked.Select(p => p.Host).ShouldBe(["good", "unknown", "bad"]); } @@ -66,9 +66,9 @@ public async Task GetRanked_only_returns_the_requested_country() { var de = P("de", 1); var other = new Proxy { Id = "ch:2", Host = "ch", Port = 2, Protocol = "http", Source = "geonode", Country = "CH" }; - await Repo().UpsertBatchAsync([de, other]); + await Repo().UpsertBatchAsync([de, other], TestContext.Current.CancellationToken); - (await Repo().GetRankedAsync("DE", 10)).ShouldHaveSingleItem().Host.ShouldBe("de"); + (await Repo().GetRankedAsync("DE", 10, TestContext.Current.CancellationToken)).ShouldHaveSingleItem().Host.ShouldBe("de"); } } @@ -96,7 +96,7 @@ public async Task Parses_the_geonode_shape_and_skips_malformed_rows() var http = new HttpClient(new StubHandler(Json)); var source = new GeoNodeProxyListSource(http, new ProxyListOptions(), NullLogger.Instance); - var proxies = await source.FetchAsync(); + var proxies = await source.FetchAsync(TestContext.Current.CancellationToken); var p = proxies.ShouldHaveSingleItem(); // the port-less row is dropped p.Id.ShouldBe("1.2.3.4:3128"); @@ -116,7 +116,7 @@ public async Task Byo_proxy_is_offered_when_configured() var opts = new EgressProxyOptions { ProxyUrl = "http://10.0.0.9:3128" }; var sut = new EgressProxyProvider(opts, Substitute.For()); - (await sut.GetCandidatesAsync()).ShouldBe(["http://10.0.0.9:3128"]); + (await sut.GetCandidatesAsync(TestContext.Current.CancellationToken)).ShouldBe(["http://10.0.0.9:3128"]); } [Fact] @@ -124,7 +124,7 @@ public async Task No_candidates_when_nothing_is_configured() { var sut = new EgressProxyProvider(new EgressProxyOptions(), Substitute.For()); - (await sut.GetCandidatesAsync()).ShouldBeEmpty(); + (await sut.GetCandidatesAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty(); } } diff --git a/tests/Infrastructure.Tests/EpisodeRepositoryTests.cs b/tests/Infrastructure.Tests/EpisodeRepositoryTests.cs index a5015cf..832f620 100644 --- a/tests/Infrastructure.Tests/EpisodeRepositoryTests.cs +++ b/tests/Infrastructure.Tests/EpisodeRepositoryTests.cs @@ -77,7 +77,7 @@ private void SeedTestData() [Fact] public async Task GetByIdAsync_ExistingEpisode_ReturnsEpisodeWithStreams() { - var result = await _sut.GetByIdAsync("ep-1"); + var result = await _sut.GetByIdAsync("ep-1", TestContext.Current.CancellationToken); result.ShouldNotBeNull(); result.Title.ShouldBe("Tagesschau 20 Uhr"); @@ -88,14 +88,14 @@ public async Task GetByIdAsync_ExistingEpisode_ReturnsEpisodeWithStreams() [Fact] public async Task GetByIdAsync_NonExistentId_ReturnsNull() { - var result = await _sut.GetByIdAsync("does-not-exist"); + var result = await _sut.GetByIdAsync("does-not-exist", TestContext.Current.CancellationToken); result.ShouldBeNull(); } [Fact] public async Task SearchAsync_MatchingTitle_ReturnsResults() { - var results = await _sut.SearchAsync("tagesschau"); + var results = await _sut.SearchAsync("tagesschau", TestContext.Current.CancellationToken); results.ShouldNotBeEmpty(); results.ShouldContain(e => e.Title == "Tagesschau 20 Uhr"); @@ -104,7 +104,7 @@ public async Task SearchAsync_MatchingTitle_ReturnsResults() [Fact] public async Task SearchAsync_MatchingDescription_ReturnsResults() { - var results = await _sut.SearchAsync("nachrichten"); + var results = await _sut.SearchAsync("nachrichten", TestContext.Current.CancellationToken); results.ShouldNotBeEmpty(); results.ShouldContain(e => e.Id == "ep-1"); @@ -113,14 +113,14 @@ public async Task SearchAsync_MatchingDescription_ReturnsResults() [Fact] public async Task SearchAsync_NoMatch_ReturnsEmpty() { - var results = await _sut.SearchAsync("zdfmediathek-xyz-nomatch"); + var results = await _sut.SearchAsync("zdfmediathek-xyz-nomatch", TestContext.Current.CancellationToken); results.ShouldBeEmpty(); } [Fact] public async Task GetByChannelAsync_KnownChannel_ReturnsEpisodes() { - var results = await _sut.GetByChannelAsync("ard"); + var results = await _sut.GetByChannelAsync("ard", ct: TestContext.Current.CancellationToken); results.Count.ShouldBe(2); results.ShouldAllBe(e => e.Show.Channel.Id == "ard"); @@ -129,8 +129,8 @@ public async Task GetByChannelAsync_KnownChannel_ReturnsEpisodes() [Fact] public async Task UpsertManyAsync_NewEpisodes_AreInserted() { - var channel = await _db.Channels.FindAsync("ard"); - var show = await _db.Shows.FindAsync("show-1"); + var channel = await _db.Channels.FindAsync(new object[] { "ard" }, TestContext.Current.CancellationToken); + var show = await _db.Shows.FindAsync(new object[] { "show-1" }, TestContext.Current.CancellationToken); var newEpisodes = new[] { @@ -146,9 +146,9 @@ public async Task UpsertManyAsync_NewEpisodes_AreInserted() } }; - await _sut.UpsertManyAsync(newEpisodes); + await _sut.UpsertManyAsync(newEpisodes, TestContext.Current.CancellationToken); - var result = await _sut.GetByIdAsync("ep-new-1"); + var result = await _sut.GetByIdAsync("ep-new-1", TestContext.Current.CancellationToken); result.ShouldNotBeNull(); result.Title.ShouldBe("New Episode"); } @@ -182,12 +182,12 @@ public async Task UpsertManyAsync_FreshCrawlGraph_InsertsChannelShowEpisodeAndSt ] }).ToList(); - await _sut.UpsertManyAsync(episodes); + await _sut.UpsertManyAsync(episodes, TestContext.Current.CancellationToken); - (await _db.Channels.FindAsync("zdf")).ShouldNotBeNull(); - (await _db.Shows.FindAsync("zdf:heute-show")).ShouldNotBeNull(); + (await _db.Channels.FindAsync(new object[] { "zdf" }, TestContext.Current.CancellationToken)).ShouldNotBeNull(); + (await _db.Shows.FindAsync(new object[] { "zdf:heute-show" }, TestContext.Current.CancellationToken)).ShouldNotBeNull(); - var persisted = await _sut.GetByIdAsync("zdf:doc-1"); + var persisted = await _sut.GetByIdAsync("zdf:doc-1", TestContext.Current.CancellationToken); persisted.ShouldNotBeNull(); persisted.Show.Channel.Name.ShouldBe("ZDF"); persisted.Streams.ShouldHaveSingleItem().Url.ShouldBe("https://cdn.zdf.de/doc-1.mp4"); @@ -212,10 +212,10 @@ public async Task UpsertManyAsync_RecrawlSameEpisode_UpdatesInPlaceWithoutDuplic ] }; - await _sut.UpsertManyAsync([Build("first title")]); - await _sut.UpsertManyAsync([Build("updated title")]); + await _sut.UpsertManyAsync([Build("first title")], TestContext.Current.CancellationToken); + await _sut.UpsertManyAsync([Build("updated title")], TestContext.Current.CancellationToken); - var all = await _sut.GetByShowAsync("ardx:extra-3"); + var all = await _sut.GetByShowAsync("ardx:extra-3", TestContext.Current.CancellationToken); all.ShouldHaveSingleItem().Title.ShouldBe("updated title"); } diff --git a/tests/Live.Tests/ArdLiveTests.cs b/tests/Live.Tests/ArdLiveTests.cs index a24e31a..f515a7c 100644 --- a/tests/Live.Tests/ArdLiveTests.cs +++ b/tests/Live.Tests/ArdLiveTests.cs @@ -25,7 +25,7 @@ private static HttpClient CreateHttp() [Fact] public async Task Search_finds_Extra3_on_ARD() { - var show = await Client.FindShowAsync("Extra 3"); + var show = await Client.FindShowAsync("Extra 3", ct: TestContext.Current.CancellationToken); show.ShouldNotBeNull(); show!.Title.ShouldContain("extra 3", Case.Insensitive); @@ -35,10 +35,10 @@ public async Task Search_finds_Extra3_on_ARD() [Fact] public async Task Fetches_Extra3_full_episodes_with_sane_metadata() { - var show = await Client.FindShowAsync("Extra 3"); + var show = await Client.FindShowAsync("Extra 3", ct: TestContext.Current.CancellationToken); show.ShouldNotBeNull(); - var episodes = await Client.GetFullEpisodesAsync(show!); + var episodes = await Client.GetFullEpisodesAsync(show!, TestContext.Current.CancellationToken); episodes.ShouldNotBeEmpty(); // Full "extra 3" episodes follow the "extra 3 vom " pattern and run ~30-50 min. @@ -52,11 +52,11 @@ public async Task Fetches_Extra3_full_episodes_with_sane_metadata() [Fact] public async Task Downloads_a_full_Extra3_episode() { - var show = await Client.FindShowAsync("Extra 3"); + var show = await Client.FindShowAsync("Extra 3", ct: TestContext.Current.CancellationToken); show.ShouldNotBeNull(); - var episodes = await Client.GetFullEpisodesAsync(show!); + var episodes = await Client.GetFullEpisodesAsync(show!, TestContext.Current.CancellationToken); var full = episodes.First(e => e.Title.Contains("extra 3 vom", StringComparison.OrdinalIgnoreCase)); - var detail = await Client.FetchEpisodeDetailAsync(full); + var detail = await Client.FetchEpisodeDetailAsync(full, TestContext.Current.CancellationToken); detail.ShouldNotBeNull(); detail!.StreamUrl.ShouldNotBeNull(); @@ -77,7 +77,7 @@ static ArdKikaLiveTests() => [Fact] public async Task Search_finds_Biene_Maja_on_KiKA() { - var show = await Client.FindShowAsync("Biene Maja", client: "kika"); + var show = await Client.FindShowAsync("Biene Maja", client: "kika", TestContext.Current.CancellationToken); show.ShouldNotBeNull(); show!.Title.ShouldContain("Biene Maja", Case.Insensitive); @@ -88,9 +88,9 @@ public async Task Search_finds_Biene_Maja_on_KiKA() [Fact] public async Task Fetches_Biene_Maja_episodes() { - var show = await Client.FindShowAsync("Biene Maja", client: "kika"); + var show = await Client.FindShowAsync("Biene Maja", client: "kika", TestContext.Current.CancellationToken); show.ShouldNotBeNull(); - var episodes = await Client.GetFullEpisodesAsync(show!); + var episodes = await Client.GetFullEpisodesAsync(show!, TestContext.Current.CancellationToken); episodes.ShouldNotBeEmpty(); } } diff --git a/tests/Live.Tests/BroadcasterCrawlerLiveTests.cs b/tests/Live.Tests/BroadcasterCrawlerLiveTests.cs index f540c2d..29e931d 100644 --- a/tests/Live.Tests/BroadcasterCrawlerLiveTests.cs +++ b/tests/Live.Tests/BroadcasterCrawlerLiveTests.cs @@ -29,7 +29,7 @@ public async Task Ard_crawler_maps_Extra3_to_domain_episodes_with_streams() { var crawler = new ArdBroadcasterCrawler(new ArdCatalogClient(Http), "ard", "ard", "ARD"); - var episodes = await crawler.CrawlShowAsync("Extra 3"); + var episodes = await crawler.CrawlShowAsync("Extra 3", TestContext.Current.CancellationToken); episodes.ShouldNotBeEmpty(); var episode = episodes[0]; @@ -48,7 +48,7 @@ public async Task Zdf_crawler_maps_HeuteShow_to_domain_episodes_with_streams() { var crawler = new ZdfBroadcasterCrawler(new ZdfCatalogClient(Http)); - var episodes = await crawler.CrawlShowAsync("heute-show"); + var episodes = await crawler.CrawlShowAsync("heute-show", TestContext.Current.CancellationToken); episodes.ShouldNotBeEmpty(); episodes.ShouldAllBe(e => e.Id.StartsWith("zdf:")); diff --git a/tests/Live.Tests/FullDownloadTests.cs b/tests/Live.Tests/FullDownloadTests.cs index f9eeb52..affb2e5 100644 --- a/tests/Live.Tests/FullDownloadTests.cs +++ b/tests/Live.Tests/FullDownloadTests.cs @@ -33,10 +33,10 @@ static FullDownloadTests() => public async Task Downloads_a_full_Extra3_episode_from_ARD() { var ard = new ArdCatalogClient(Http); - var show = await ard.FindShowAsync("Extra 3"); - var full = (await ard.GetFullEpisodesAsync(show!)) + var show = await ard.FindShowAsync("Extra 3", ct: TestContext.Current.CancellationToken); + var full = (await ard.GetFullEpisodesAsync(show!, TestContext.Current.CancellationToken)) .First(e => e.Title.Contains("extra 3 vom", StringComparison.OrdinalIgnoreCase)); - await DownloadAndVerifyAsync(await ard.FetchEpisodeDetailAsync(full)); + await DownloadAndVerifyAsync(await ard.FetchEpisodeDetailAsync(full, TestContext.Current.CancellationToken)); } // A DE egress proxy for local runs (#45). Set it to do the REAL geo-restricted download; leave it @@ -48,11 +48,11 @@ public async Task Downloads_a_full_Extra3_episode_from_ARD() public async Task Downloads_a_full_BieneMaja_episode_from_KiKA() { var ard = new ArdCatalogClient(Http); - var show = await ard.FindShowAsync("Biene Maja", client: "kika"); - var full = (await ard.GetFullEpisodesAsync(show!)).First(); + var show = await ard.FindShowAsync("Biene Maja", client: "kika", TestContext.Current.CancellationToken); + var full = (await ard.GetFullEpisodesAsync(show!, TestContext.Current.CancellationToken)).First(); // Biene Maja is DACH geo-fenced. With a DE egress (KRAUTWATCH_TEST_PROXY) it downloads for real; // without one, the provider fails fast (geo-restricted + no egress) — tolerated here. - await DownloadAndVerifyAsync(await ard.FetchEpisodeDetailAsync(full), + await DownloadAndVerifyAsync(await ard.FetchEpisodeDetailAsync(full, TestContext.Current.CancellationToken), tolerateGeoBlock: string.IsNullOrWhiteSpace(TestProxy)); } @@ -60,9 +60,9 @@ await DownloadAndVerifyAsync(await ard.FetchEpisodeDetailAsync(full), public async Task Downloads_a_full_HeuteShow_episode_from_ZDF() { var zdf = new ZdfCatalogClient(Http); - var full = (await zdf.SearchEpisodesAsync("Heute Show")) + var full = (await zdf.SearchEpisodesAsync("Heute Show", TestContext.Current.CancellationToken)) .First(e => e.Title.Contains("heute-show vom", StringComparison.OrdinalIgnoreCase)); - await DownloadAndVerifyAsync(await zdf.FetchEpisodeDetailAsync(full)); + await DownloadAndVerifyAsync(await zdf.FetchEpisodeDetailAsync(full, TestContext.Current.CancellationToken)); } // ── shared: run the real provider, assert a genuine MP4 landed, then clean up ── diff --git a/tests/Live.Tests/FullEpisodeFetchTests.cs b/tests/Live.Tests/FullEpisodeFetchTests.cs index 6ce73e5..bce3adb 100644 --- a/tests/Live.Tests/FullEpisodeFetchTests.cs +++ b/tests/Live.Tests/FullEpisodeFetchTests.cs @@ -21,12 +21,12 @@ static FullEpisodeFetchTests() => public async Task Fetches_a_full_Extra3_episode_from_ARD() { var ard = new ArdCatalogClient(Http); - var show = await ard.FindShowAsync("Extra 3"); + var show = await ard.FindShowAsync("Extra 3", ct: TestContext.Current.CancellationToken); show.ShouldNotBeNull(); - var episodes = await ard.GetFullEpisodesAsync(show!); + var episodes = await ard.GetFullEpisodesAsync(show!, TestContext.Current.CancellationToken); var full = episodes.First(e => e.Title.Contains("extra 3 vom", StringComparison.OrdinalIgnoreCase)); - var detail = await ard.FetchEpisodeDetailAsync(full); + var detail = await ard.FetchEpisodeDetailAsync(full, TestContext.Current.CancellationToken); detail.ShouldNotBeNull(); detail!.Title.ShouldContain("extra 3 vom", Case.Insensitive); @@ -45,12 +45,12 @@ public async Task Fetches_a_full_Extra3_episode_from_ARD() public async Task Fetches_a_full_BieneMaja_episode_from_KiKA() { var ard = new ArdCatalogClient(Http); - var show = await ard.FindShowAsync("Biene Maja", client: "kika"); + var show = await ard.FindShowAsync("Biene Maja", client: "kika", TestContext.Current.CancellationToken); show.ShouldNotBeNull(); - var episodes = await ard.GetFullEpisodesAsync(show!); + var episodes = await ard.GetFullEpisodesAsync(show!, TestContext.Current.CancellationToken); var full = episodes.First(); - var detail = await ard.FetchEpisodeDetailAsync(full); + var detail = await ard.FetchEpisodeDetailAsync(full, TestContext.Current.CancellationToken); detail.ShouldNotBeNull(); detail!.Title.ShouldNotBeNullOrWhiteSpace(); @@ -66,10 +66,10 @@ public async Task Fetches_a_full_BieneMaja_episode_from_KiKA() public async Task Fetches_a_full_HeuteShow_episode_from_ZDF() { var zdf = new ZdfCatalogClient(Http); - var episodes = await zdf.SearchEpisodesAsync("Heute Show"); + var episodes = await zdf.SearchEpisodesAsync("Heute Show", TestContext.Current.CancellationToken); var full = episodes.First(e => e.Title.Contains("heute-show vom", StringComparison.OrdinalIgnoreCase)); - var detail = await zdf.FetchEpisodeDetailAsync(full); + var detail = await zdf.FetchEpisodeDetailAsync(full, TestContext.Current.CancellationToken); detail.ShouldNotBeNull(); detail!.Title.ShouldContain("heute-show vom", Case.Insensitive); diff --git a/tests/Live.Tests/ZdfLiveTests.cs b/tests/Live.Tests/ZdfLiveTests.cs index c37b224..462d485 100644 --- a/tests/Live.Tests/ZdfLiveTests.cs +++ b/tests/Live.Tests/ZdfLiveTests.cs @@ -21,7 +21,7 @@ static ZdfLiveTests() => [Fact] public async Task Search_finds_HeuteShow_episodes() { - var episodes = await Client.SearchEpisodesAsync("Heute Show"); + var episodes = await Client.SearchEpisodesAsync("Heute Show", TestContext.Current.CancellationToken); episodes.ShouldNotBeEmpty(); episodes.ShouldContain(e => e.Title.Contains("heute-show vom", StringComparison.OrdinalIgnoreCase)); @@ -31,10 +31,10 @@ public async Task Search_finds_HeuteShow_episodes() [Fact] public async Task Resolves_a_HeuteShow_progressive_MP4() { - var episodes = await Client.SearchEpisodesAsync("Heute Show"); + var episodes = await Client.SearchEpisodesAsync("Heute Show", TestContext.Current.CancellationToken); var episode = episodes.First(e => e.Title.Contains("heute-show vom", StringComparison.OrdinalIgnoreCase)); - var stream = await Client.ResolveBestMp4Async(episode.Canonical); + var stream = await Client.ResolveBestMp4Async(episode.Canonical, TestContext.Current.CancellationToken); stream.ShouldNotBeNull(); stream!.MimeType.ShouldContain("mp4"); @@ -47,26 +47,26 @@ public async Task Downloads_a_HeuteShow_episode() { // Real download of a real ZDF stream, bounded to ~5 MB so it's fast + small. // The production Downloader agent streams the whole file; this proves the pipeline. - var episodes = await Client.SearchEpisodesAsync("Heute Show"); + var episodes = await Client.SearchEpisodesAsync("Heute Show", TestContext.Current.CancellationToken); var episode = episodes.First(e => e.Title.Contains("heute-show vom", StringComparison.OrdinalIgnoreCase)); - var stream = await Client.ResolveBestMp4Async(episode.Canonical); + var stream = await Client.ResolveBestMp4Async(episode.Canonical, TestContext.Current.CancellationToken); stream.ShouldNotBeNull(); var path = Path.Combine(Path.GetTempPath(), $"krautwatch-heuteshow-{Guid.NewGuid():N}.mp4"); try { const int cap = 5 * 1024 * 1024; - using var resp = await Http.GetAsync(stream!.Url, HttpCompletionOption.ResponseHeadersRead); + using var resp = await Http.GetAsync(stream!.Url, HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken); resp.EnsureSuccessStatusCode(); - await using (var source = await resp.Content.ReadAsStreamAsync()) + await using (var source = await resp.Content.ReadAsStreamAsync(TestContext.Current.CancellationToken)) await using (var file = File.Create(path)) { var buffer = new byte[81920]; long total = 0; int read; - while (total < cap && (read = await source.ReadAsync(buffer)) > 0) + while (total < cap && (read = await source.ReadAsync(buffer, TestContext.Current.CancellationToken)) > 0) { - await file.WriteAsync(buffer.AsMemory(0, read)); + await file.WriteAsync(buffer.AsMemory(0, read), TestContext.Current.CancellationToken); total += read; } }