diff --git a/AGENTS.md b/AGENTS.md index 545c51f6..3f1fd7a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ Anything else is replaced by an explicit command that downloads the official rel **IstarCI is recommended but optional, and runs from its package.** The CI is the GitHub Actions pipeline, which IstarCI only runs locally first. `task ci:setup`, `task ci` and `task ci:logs` use the installed `@devpro/istarci` package, and a clone of IstarCI is for developing IstarCI only (`task ci:from-clone`). +A run starts by itself on every commit, so an agent never starts one: after committing it reads the result with `task ci` and, on a failure, `task ci:logs`. **The harness is Node.js and bash only.** Python scripts or code is not allowed. diff --git a/Directory.Build.props b/Directory.Build.props index ba483fa5..c48b73e2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ - 2.4.3 + 2.4.4 diff --git a/docs/reference-data.md b/docs/reference-data.md index 1167f0bc..6d13ff04 100644 --- a/docs/reference-data.md +++ b/docs/reference-data.md @@ -93,7 +93,7 @@ Each tick tries `ILeaseRepository.TryAcquireAsync("reference-sync", Environment. `ReferenceSyncService.SyncStaleReferencesAsync` is the single algorithm behind the loop and `POST /api/reference-data/sync-now`. The two differ only by the staleness windows, declared once in `ReferenceSyncWindows` (`Periodic`: 3 days for references, 7 for Explore; `Forced`: zero), guarded by `ReferenceSyncWindowsTest`. -`sync-now?force=true` rechecks everything, and without it runs exactly the background tick. +`sync-now?force=true` rechecks everything in full, skipping TMDB's change check (`ReferenceSyncWindows.FullFetch`), and without it runs exactly the background tick. One failing document never aborts the run, and one generic loop covers five one-line domain arms (`SyncDomainAsync`). **`IReferenceRepository.FindStaleAsync(cutoff, limit)` picks and orders the work** (`ReferenceStalenessQueries.cs`): never enriched first, then least recently enriched, @@ -102,7 +102,9 @@ capped at `MaxDocumentsPerDomainPerPass` (500), so what a pass misses leads the **Gotcha: "never enriched" cannot come from the date comparison**, since `Lte(LastEnrichedAt, cutoff)` matches neither null nor missing. `Eq(field, null)` matches both and BSON sorts null first, proven only by the real-MongoDB `ReferenceStalenessRepositoryTest`, and `last_enriched_at` is indexed on all five collections. -TV and movie refreshes pre-check TMDB's `/changes?start_date=...` and only bump `LastEnrichedAt` when nothing changed. +TV and movie refreshes pre-check TMDB's `/changes?start_date=...&end_date=...` and only bump `LastEnrichedAt` when nothing changed. +**Gotcha:** without `end_date` TMDB answers the changes of the `start_date` day only, and it rejects a range over 14 days, +so a reference last enriched longer ago counts as changed and takes the full fetch (`TmdbClientTest`). IGDB, RAWG, Discogs and the book providers have no equivalent, so they always fetch in full past the cutoff. **Only the default video game provider is called on refresh**, unlike books, where a refresh checks every registered provider's key in `ExternalIds` (see [Providers](providers.md#multi-provider-domains)). diff --git a/src/BlazorApp/Dockerfile b/src/BlazorApp/Dockerfile index b6ab2652..200c8fa0 100644 --- a/src/BlazorApp/Dockerfile +++ b/src/BlazorApp/Dockerfile @@ -1,10 +1,10 @@ -FROM registry.suse.com/bci/dotnet-aspnet:10.0.13 AS base +FROM registry.suse.com/bci/dotnet-aspnet:10.0.14 AS base USER $APP_UID WORKDIR /app EXPOSE 8080 EXPOSE 8081 -FROM registry.suse.com/bci/dotnet-sdk:10.0.13 AS build +FROM registry.suse.com/bci/dotnet-sdk:10.0.14 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ./Directory.*.props . diff --git a/src/WebApi/Dockerfile b/src/WebApi/Dockerfile index a684ec23..e7b98820 100644 --- a/src/WebApi/Dockerfile +++ b/src/WebApi/Dockerfile @@ -1,10 +1,10 @@ -FROM registry.suse.com/bci/dotnet-aspnet:10.0.13 AS base +FROM registry.suse.com/bci/dotnet-aspnet:10.0.14 AS base USER $APP_UID WORKDIR /app EXPOSE 8080 EXPOSE 8081 -FROM registry.suse.com/bci/dotnet-sdk:10.0.13 AS build +FROM registry.suse.com/bci/dotnet-sdk:10.0.14 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ./Directory.*.props . diff --git a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs index 22e2131d..7787f619 100644 --- a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs +++ b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs @@ -265,7 +265,7 @@ private static async Task> ReadJsonEntryAsync(ZipArchive archive, str /// since a re-check across five domains easily exceeds a request's timeout. /// /// - /// true re-checks every reference document and rebuilds every Explore ranking, however recently enriched. + /// true re-checks every reference document in full and rebuilds every Explore ranking, however recently enriched. /// The default runs exactly what the background tick would (see ), so a collection synced within the window reports zero checked. /// It is the cheap option, spending no provider calls on fresh documents. /// @@ -331,7 +331,7 @@ private async Task RunSyncJobAsync(Guid jobId, ReferenceSyncWindows windows, boo var scopedReconciliationService = scope.ServiceProvider.GetRequiredService(); result = await scopedSyncService.SyncStaleReferencesAsync( - windows.References, stage => scopedJobStore.UpdateStageAsync(jobId, stage), cancellationToken); + windows.References, stage => scopedJobStore.UpdateStageAsync(jobId, stage), windows.FullFetch, cancellationToken); // an on-demand "sync now" reconciles finished-show status too, so its result matches the periodic pass's. result.FinishedShowsReopened = await scopedReconciliationService.ReconcileFinishedShowsAsync(); await scopedJobStore.UpdateStageAsync(jobId, ReferenceSyncStage.RefreshingExplore); diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs index bb96a1cf..68f20092 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs @@ -531,13 +531,14 @@ private async Task WriteMovieLinkAsync(MovieReferenceModel reference, ReferenceL /// skipping the per-season fetches otherwise, and stamps LastEnrichedAt so the sync moves on. /// Unchanged for a reference without a TMDB id or one TMDB no longer holds. /// - public async Task<(TvShowReferenceModel Model, bool DataChanged)> RefreshTvShowReferenceAsync(TvShowReferenceModel reference, CancellationToken cancellationToken = default) + public async Task<(TvShowReferenceModel Model, bool DataChanged)> RefreshTvShowReferenceAsync( + TvShowReferenceModel reference, bool fullFetch = false, CancellationToken cancellationToken = default) { var tmdbId = reference.ExternalIds.GetValueOrDefault("tmdb"); if (string.IsNullOrEmpty(tmdbId)) return (reference, false); // a full fetch while the reference has no ratings, so it backfills one (see RefreshMovieReferenceAsync) - if (reference.LastEnrichedAt is not null && reference.Ratings.Count > 0) + if (!fullFetch && reference.LastEnrichedAt is not null && reference.Ratings.Count > 0) { var changed = await tmdbClient.HasTvShowChangedSinceAsync(tmdbId, reference.LastEnrichedAt.Value, cancellationToken); if (!changed) @@ -585,14 +586,15 @@ private async Task WriteMovieLinkAsync(MovieReferenceModel reference, ReferenceL /// /// Movie equivalent of . /// - public async Task<(MovieReferenceModel Model, bool DataChanged)> RefreshMovieReferenceAsync(MovieReferenceModel reference, CancellationToken cancellationToken = default) + public async Task<(MovieReferenceModel Model, bool DataChanged)> RefreshMovieReferenceAsync( + MovieReferenceModel reference, bool fullFetch = false, CancellationToken cancellationToken = default) { var tmdbId = reference.ExternalIds.GetValueOrDefault("tmdb"); if (string.IsNullOrEmpty(tmdbId)) return (reference, false); // A reference without ratings does the full fetch even when TMDB reports no change, or the change check would skip its backfill forever. - // A rated reference takes the cheap no-change shortcut. - if (reference.LastEnrichedAt is not null && reference.Ratings.Count > 0) + // A rated reference takes the cheap no-change shortcut, unless asked for a full fetch (see ReferenceSyncWindows.FullFetch). + if (!fullFetch && reference.LastEnrichedAt is not null && reference.Ratings.Count > 0) { var changed = await tmdbClient.HasMovieChangedSinceAsync(tmdbId, reference.LastEnrichedAt.Value, cancellationToken); if (!changed) diff --git a/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs b/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs index 0a579ef7..a9b2d647 100644 --- a/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs +++ b/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs @@ -56,7 +56,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) jobId = await jobStore.CreateAsync(SystemOwnerId, ReferenceSyncStage.SyncingTvShows); var syncService = scope.ServiceProvider.GetRequiredService(); - var result = await syncService.SyncStaleReferencesAsync(s_windows.References, cancellationToken: stoppingToken); + var result = await syncService.SyncStaleReferencesAsync(s_windows.References, fullFetch: s_windows.FullFetch, cancellationToken: stoppingToken); // finished shows reconciled against the refreshed episode guides, in the same tick under the lease var reconciliationService = scope.ServiceProvider.GetRequiredService(); diff --git a/src/WebApi/ReferenceData/ReferenceSyncService.cs b/src/WebApi/ReferenceData/ReferenceSyncService.cs index 1a974e28..6127eaf7 100644 --- a/src/WebApi/ReferenceData/ReferenceSyncService.cs +++ b/src/WebApi/ReferenceData/ReferenceSyncService.cs @@ -27,20 +27,21 @@ public class ReferenceSyncService( /// Refreshes the references whose LastEnrichedAt is unset or older than , /// oldest first, up to per domain. /// A failing document is logged and skipped, never aborting the run. + /// skips TMDB's change check (see ), the other providers always fetching in full. /// public async Task SyncStaleReferencesAsync(TimeSpan staleAfter, Func? onStageChanged = null, - CancellationToken cancellationToken = default) + bool fullFetch = false, CancellationToken cancellationToken = default) { var cutoff = DateTime.UtcNow - staleAfter; var result = new ReferenceSyncResultDto(); // one loop for five domains, each arm supplying only which collection to read and which refresh to run await SyncDomainAsync(onStageChanged, ReferenceSyncStage.SyncingTvShows, cutoff, - tvShowReferenceRepository.FindStaleAsync, enrichmentService.RefreshTvShowReferenceAsync, r => r.Id, + tvShowReferenceRepository.FindStaleAsync, (r, ct) => enrichmentService.RefreshTvShowReferenceAsync(r, fullFetch, ct), r => r.Id, (checkedCount, updated) => (result.TvShowsChecked, result.TvShowsUpdated) = (checkedCount, updated), cancellationToken); await SyncDomainAsync(onStageChanged, ReferenceSyncStage.SyncingMovies, cutoff, - movieReferenceRepository.FindStaleAsync, enrichmentService.RefreshMovieReferenceAsync, r => r.Id, + movieReferenceRepository.FindStaleAsync, (r, ct) => enrichmentService.RefreshMovieReferenceAsync(r, fullFetch, ct), r => r.Id, (checkedCount, updated) => (result.MoviesChecked, result.MoviesUpdated) = (checkedCount, updated), cancellationToken); await SyncDomainAsync(onStageChanged, ReferenceSyncStage.SyncingBooks, cutoff, diff --git a/src/WebApi/ReferenceData/ReferenceSyncWindows.cs b/src/WebApi/ReferenceData/ReferenceSyncWindows.cs index 5125f0d8..1c76512c 100644 --- a/src/WebApi/ReferenceData/ReferenceSyncWindows.cs +++ b/src/WebApi/ReferenceData/ReferenceSyncWindows.cs @@ -9,7 +9,10 @@ namespace Keeptrack.WebApi.ReferenceData; /// /// Applied to . /// Applied to . -public readonly record struct ReferenceSyncWindows(TimeSpan References, TimeSpan Explore) +/// +/// Skips TMDB's change check, which only sees changes after a reference's last enrichment, so forcing is the one way to recover a change it missed. +/// +public readonly record struct ReferenceSyncWindows(TimeSpan References, TimeSpan Explore, bool FullFetch = false) { /// /// The windows of the 24h background tick. @@ -21,9 +24,9 @@ public readonly record struct ReferenceSyncWindows(TimeSpan References, TimeSpan public static readonly ReferenceSyncWindows Periodic = new(TimeSpan.FromDays(3), TimeSpan.FromDays(7)); /// - /// Checks everything again, however recently enriched, for the admin's "force" option. + /// Checks everything again in full, however recently enriched, for the admin's "force" option. /// - public static readonly ReferenceSyncWindows Forced = new(TimeSpan.Zero, TimeSpan.Zero); + public static readonly ReferenceSyncWindows Forced = new(TimeSpan.Zero, TimeSpan.Zero, FullFetch: true); /// /// when , else the windows of the background pass. diff --git a/src/WebApi/ReferenceData/TmdbClient.cs b/src/WebApi/ReferenceData/TmdbClient.cs index 9a9ea2a2..bbb4b7f8 100644 --- a/src/WebApi/ReferenceData/TmdbClient.cs +++ b/src/WebApi/ReferenceData/TmdbClient.cs @@ -150,14 +150,25 @@ public Task HasMovieChangedSinceAsync(string tmdbId, DateTime since, Cance } /// - /// TMDB's "changes" endpoint per id, unlike the bulk ones limited to the last days, - /// reports changes since any date, one cheap call rather than fetching the details and every season again. + /// TMDB rejects a /changes range longer than this, with status 20. + /// + private const int MaxChangesRangeDays = 14; + + /// + /// TMDB's "changes" endpoint per id, one cheap call rather than fetching the details and every season again. + /// + /// Without an end_date it answers the changes of the start_date day only, so the range is always closed at today. + /// A range TMDB would reject counts as changed, since a full fetch is the only safe answer. + /// /// private async Task HasChangedSinceAsync(string resourceType, string tmdbId, DateTime since, CancellationToken cancellationToken) { - var startDate = since.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + var start = DateOnly.FromDateTime(since); + var end = DateOnly.FromDateTime(DateTime.UtcNow); + if (end.DayNumber - start.DayNumber > MaxChangesRangeDays) return true; + var response = await http.GetFromJsonAsync( - $"{resourceType}/{tmdbId}/changes?api_key={ApiKey}&start_date={startDate}", cancellationToken); + $"{resourceType}/{tmdbId}/changes?api_key={ApiKey}&start_date={FormatDate(start)}&end_date={FormatDate(end)}", cancellationToken); return response?.Changes.Count > 0; } @@ -168,6 +179,8 @@ private async Task HasChangedSinceAsync(string resourceType, string tmdbId private static string Encode(string value) => HttpUtility.UrlEncode(value); + private static string FormatDate(DateOnly date) => date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + private static int? ParseYear(string? date) => ParseDate(date)?.Year; private static DateOnly? ParseDate(string? date) => diff --git a/test/WebApi.IntegrationTests/Resources/TvShowRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/TvShowRepositoryTest.cs new file mode 100644 index 00000000..7577e194 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/TvShowRepositoryTest.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Bson; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises against real MongoDB, the query deciding which shows the status reconciliation may reopen. +/// Null storage and the empty-string sentinel are MongoDB semantics a mocked repository cannot prove. +/// +public class TvShowRepositoryTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) +{ + [Fact] + public async Task FindFinishedLinkedShowsAsync_ReturnsOnlyFinishedShowsWithARealReference_AcrossOwners() + { + using var scope = Factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var ownerId = $"owner-{Guid.NewGuid()}"; + var otherOwnerId = $"owner-{Guid.NewGuid()}"; + var referenceId = ObjectId.GenerateNewId().ToString(); + + var finished = await CreateShowAsync(repository, ownerId, TvShowStatus.Finished, referenceId); + var otherOwnersFinished = await CreateShowAsync(repository, otherOwnerId, TvShowStatus.Finished, referenceId); + var current = await CreateShowAsync(repository, ownerId, TvShowStatus.Current, referenceId); + var stopped = await CreateShowAsync(repository, ownerId, TvShowStatus.Stopped, referenceId); + var unset = await CreateShowAsync(repository, ownerId, null, referenceId); + var unlinked = await CreateShowAsync(repository, ownerId, TvShowStatus.Finished, null); + var emptySentinel = await CreateShowAsync(repository, ownerId, TvShowStatus.Finished, string.Empty); + + var found = (await repository.FindFinishedLinkedShowsAsync()).Select(s => s.Id).ToList(); + + found.Should().Contain([finished.Id, otherOwnersFinished.Id]); + found.Should().NotContain([current.Id, stopped.Id, unset.Id, unlinked.Id, emptySentinel.Id]); + } + + private async Task CreateShowAsync(ITvShowRepository repository, string ownerId, TvShowStatus? state, string? referenceId) + { + var created = await repository.CreateAsync(new TvShowModel + { + OwnerId = ownerId, + Title = $"The Diplomat {Guid.NewGuid()}", + State = state, + ReferenceId = referenceId + }); + TrackCleanup(() => repository.DeleteAsync(created.Id!, ownerId)); + return created; + } +} diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs index 9337cbbe..6fed0d20 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs @@ -633,7 +633,7 @@ public async Task RefreshMovieReferenceAsync_ForcesAFullFetch_WhenReferenceHasNo }; var service = CreateService(tmdbClient); - var (result, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, changed) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeTrue(); tmdbClient.MovieDetailsRequested.Should().Contain("42"); @@ -662,7 +662,7 @@ public async Task RefreshMovieReferenceAsync_TakesTheNoChangeShortCircuit_WhenAl }; var service = CreateService(tmdbClient); - var (_, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (_, changed) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeFalse(); tmdbClient.MovieDetailsRequested.Should().NotContain("42"); @@ -783,7 +783,7 @@ public async Task RefreshMovieReferenceAsync_BackfillsImdbCheaply_OnTheNoChangeS }; var service = CreateService(tmdbClient); - var (result, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, changed) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeTrue(); result.Ratings["imdb"].Value.Should().Be(8.9); @@ -814,7 +814,7 @@ public async Task RefreshMovieReferenceAsync_ResolvesTheImdbIdCheaply_WhenBackfi }; var service = CreateService(tmdbClient); - var (result, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, changed) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeTrue(); result.Ratings["imdb"].Value.Should().Be(8.9); @@ -847,7 +847,7 @@ public async Task RefreshMovieReferenceAsync_DoesNotCallOmdb_OnTheShortCircuit_W }; var service = CreateService(tmdbClient); - var (_, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (_, changed) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeFalse(); _omdbClient.Requested.Should().BeEmpty(); @@ -867,7 +867,7 @@ public async Task RefreshMovieReferenceAsync_CallsNoProviderAtAll_OnTheShortCirc var reference = MovieReferenceAwaitingImdb(attemptedAt: DateTime.UtcNow.AddDays(-10)); var service = CreateService(tmdbClient); - var (_, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (_, changed) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeFalse(); _omdbClient.Requested.Should().BeEmpty(); @@ -887,7 +887,7 @@ public async Task RefreshMovieReferenceAsync_ReAttemptsImdb_WhenTheLastAttemptFe reference.ExternalIds["imdb"] = "tt0042"; var service = CreateService(tmdbClient); - var (result, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, changed) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeTrue(); result.Ratings["imdb"].Value.Should().Be(8.9); @@ -903,7 +903,7 @@ public async Task RefreshMovieReferenceAsync_StampsTheAttempt_WhenOmdbAnswersWit reference.ExternalIds["imdb"] = "tt0042"; // OMDb has no rating seeded for it var service = CreateService(tmdbClient); - var (result, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, changed) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); // "OMDb answered, and has nothing" is a real answer worth remembering, which the next pass reads changed.Should().BeFalse(); @@ -925,7 +925,7 @@ public async Task RefreshMovieReferenceAsync_LeavesNoAttemptStamp_WhenOmdbWasNev reference.ExternalIds["imdb"] = "tt0042"; var service = CreateService(tmdbClient); - var (result, _) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, _) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); result.RatingsCheckedAt.Should().BeEmpty(); } @@ -946,7 +946,7 @@ public async Task RefreshMovieReferenceAsync_LeavesLastEnrichedAtAlone_WhenTheOm reference.LastEnrichedAt = lastEnrichedAt; var service = CreateService(tmdbClient); - var (result, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, changed) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeFalse(); // the point: still stale, so the next pass takes it again as soon as the allowance renews @@ -971,7 +971,7 @@ public async Task RefreshMovieReferenceAsync_LeavesLastEnrichedAtAlone_WhenTheBu reference.LastEnrichedAt = lastEnrichedAt; var service = CreateService(tmdbClient); - var (result, _) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, _) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); result.LastEnrichedAt.Should().Be(lastEnrichedAt); _omdbCallBudget.LimitReported.Should().BeTrue(); @@ -994,7 +994,7 @@ public async Task RefreshMovieReferenceAsync_StampsLastEnrichedAt_WhenOmdbIsUnre reference.LastEnrichedAt = lastEnrichedAt; var service = CreateService(tmdbClient); - var (result, _) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, _) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); result.LastEnrichedAt.Should().BeAfter(lastEnrichedAt); } @@ -1014,7 +1014,7 @@ public async Task RefreshMovieReferenceAsync_StampsLastEnrichedAt_WhenTheBudgetI reference.LastEnrichedAt = lastEnrichedAt; var service = CreateService(tmdbClient); - var (result, _) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, _) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); result.LastEnrichedAt.Should().BeAfter(lastEnrichedAt); } @@ -1039,7 +1039,7 @@ public async Task RefreshTvShowReferenceAsync_LeavesLastEnrichedAtAlone_WhenTheO }; var service = CreateService(tmdbClient); - var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeFalse(); result.LastEnrichedAt.Should().Be(lastEnrichedAt); @@ -1059,7 +1059,7 @@ public async Task RefreshMovieReferenceAsync_KeepsTheKnownImdbRating_WhenAFullFe reference.Ratings["imdb"] = new ReferenceRatingModel { Value = 8.9, Scale = 10, Count = 500_000 }; var service = CreateService(tmdbClient); - var (result, _) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, _) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); result.Ratings["tmdb"].Value.Should().Be(7.9); // TMDB's half was refreshed result.Ratings["imdb"].Value.Should().Be(8.9); // IMDb's half survived @@ -1077,7 +1077,7 @@ public async Task RefreshMovieReferenceAsync_DropsTheKnownImdbRating_WhenAFullFe reference.Ratings["imdb"] = new ReferenceRatingModel { Value = 8.9, Scale = 10, Count = 500_000 }; var service = CreateService(tmdbClient); - var (result, _) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, _) = await service.RefreshMovieReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); result.Ratings.Should().NotContainKey("imdb"); result.RatingsCheckedAt.Should().ContainKey("imdb"); @@ -1152,7 +1152,7 @@ public async Task RefreshTvShowReferenceAsync_ReturnsUnchanged_WhenReferenceHasN var service = CreateService(tmdbClient); var reference = new TvShowReferenceModel { Id = "reference-1", Title = "Some Show", TitleNormalized = "some show", ExternalIds = [] }; - var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeFalse(); result.Should().BeSameAs(reference); @@ -1178,7 +1178,7 @@ public async Task RefreshTvShowReferenceAsync_OnlyBumpsLastEnrichedAt_WhenTmdbRe _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); var service = CreateService(tmdbClient); - var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeFalse(); result.LastEnrichedAt.Should().BeAfter(lastEnrichedAt); @@ -1203,7 +1203,7 @@ public async Task RefreshTvShowReferenceAsync_RefetchesDetails_WhenTmdbReportsCh _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); var service = CreateService(tmdbClient); - var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, TestContext.Current.CancellationToken); + var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeTrue(); result.Title.Should().Be("Some Show - Updated"); @@ -1211,6 +1211,84 @@ public async Task RefreshTvShowReferenceAsync_RefetchesDetails_WhenTmdbReportsCh result.Genres.Should().Contain("Drama"); } + [Fact] + public async Task RefreshTvShowReferenceAsync_StoresTheEpisodesOfANewSeason_WhenTmdbReportsChanges() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.ChangedSince["42"] = true; + tmdbClient.TvShowDetails["42"] = new TmdbTvShowDetails("42", "Some Show", 2023, "Synopsis", + [new TmdbEpisode(3, 8, "S3E8", new DateOnly(2025, 10, 16)), new TmdbEpisode(4, 1, "S4E1", new DateOnly(2026, 10, 15))], [], null); + var reference = new TvShowReferenceModel + { + Id = "reference-1", + Title = "Some Show", + TitleNormalized = "some show", + ExternalIds = new Dictionary { ["tmdb"] = "42" }, + Ratings = new Dictionary { ["tmdb"] = new() { Value = 8.0, Scale = 10, Count = 100 } }, + Episodes = [new ReferenceEpisodeModel { SeasonNumber = 3, EpisodeNumber = 8, Title = "S3E8", AirDate = new DateOnly(2025, 10, 16) }], + LastEnrichedAt = DateTime.UtcNow.AddDays(-20) + }; + _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); + var service = CreateService(tmdbClient); + + var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + result.Episodes.Select(e => (e.SeasonNumber, e.EpisodeNumber, e.AirDate)).Should().Equal( + (3, 8, new DateOnly(2025, 10, 16)), (4, 1, new DateOnly(2026, 10, 15))); + } + + [Fact] + public async Task RefreshTvShowReferenceAsync_FetchesInFull_WhenAskedTo_EvenIfTmdbReportsNoChanges() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.ChangedSince["42"] = false; + tmdbClient.TvShowDetails["42"] = new TmdbTvShowDetails("42", "Some Show", 2023, "Synopsis", [new TmdbEpisode(4, 1, "S4E1", new DateOnly(2026, 10, 15))], [], null); + var reference = new TvShowReferenceModel + { + Id = "reference-1", + Title = "Some Show", + TitleNormalized = "some show", + ExternalIds = new Dictionary { ["tmdb"] = "42" }, + Ratings = new Dictionary { ["tmdb"] = new() { Value = 8.0, Scale = 10, Count = 100 } }, + LastEnrichedAt = DateTime.UtcNow + }; + _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); + var service = CreateService(tmdbClient); + + var (result, changed) = await service.RefreshTvShowReferenceAsync(reference, fullFetch: true, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + tmdbClient.ChangesRequested.Should().BeEmpty(); + tmdbClient.TvShowDetailsRequested.Should().Contain("42"); + result.Episodes.Should().ContainSingle(e => e.SeasonNumber == 4 && e.EpisodeNumber == 1); + } + + [Fact] + public async Task RefreshMovieReferenceAsync_FetchesInFull_WhenAskedTo_EvenIfTmdbReportsNoChanges() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.ChangedSince["603"] = false; + tmdbClient.MovieDetails["603"] = new TmdbMovieDetails("603", "Some Movie - Updated", 1999, "Synopsis", [], null, 8.2, 100); + var reference = new MovieReferenceModel + { + Id = "reference-1", + Title = "Some Movie", + TitleNormalized = "some movie", + ExternalIds = new Dictionary { ["tmdb"] = "603" }, + Ratings = new Dictionary { ["tmdb"] = new() { Value = 8.0, Scale = 10, Count = 100 } }, + LastEnrichedAt = DateTime.UtcNow + }; + _movieReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((MovieReferenceModel m) => m); + var service = CreateService(tmdbClient); + + var (result, changed) = await service.RefreshMovieReferenceAsync(reference, fullFetch: true, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + tmdbClient.ChangesRequested.Should().BeEmpty(); + result.Title.Should().Be("Some Movie - Updated"); + } + [Fact] public async Task RefreshTvShowReferenceAsync_RefetchesDetails_WhenNeverPreviouslyEnriched() { @@ -1228,7 +1306,7 @@ public async Task RefreshTvShowReferenceAsync_RefetchesDetails_WhenNeverPrevious _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); var service = CreateService(tmdbClient); - var (_, changed) = await service.RefreshTvShowReferenceAsync(reference, TestContext.Current.CancellationToken); + var (_, changed) = await service.RefreshTvShowReferenceAsync(reference, cancellationToken: TestContext.Current.CancellationToken); changed.Should().BeTrue(); tmdbClient.ChangesRequested.Should().NotContain("42"); diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs index 135f4e9a..bd1e280f 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs @@ -101,6 +101,66 @@ public async Task SyncStaleReferencesAsync_Refreshes_ReferenceOlderThanStaleAfte result.TvShowsUpdated.Should().Be(1); } + /// + /// A rated reference enriched today, which TMDB reports unchanged since: the periodic pass trusts the change check, a forced one fetches anyway. + /// The change check only sees changes after LastEnrichedAt, so one it missed is recovered only by forcing. + /// + private FakeTmdbClient SetupRecentlyEnrichedTvShowAndMovieReportedUnchanged() + { + var tmdbClient = FakeTmdbClient.Empty(); + tmdbClient.ChangedSince["42"] = false; + tmdbClient.ChangedSince["603"] = false; + tmdbClient.TvShowDetails["42"] = new TmdbTvShowDetails("42", "Some Show", 2023, "Synopsis", [new TmdbEpisode(4, 1, "S4E1", new DateOnly(2026, 10, 15))], [], null); + tmdbClient.MovieDetails["603"] = new TmdbMovieDetails("603", "Some Movie", 1999, "Synopsis", [], null, 8.2, 100); + var ratings = new Dictionary { ["tmdb"] = new() { Value = 8.0, Scale = 10, Count = 100 } }; + _tvShowReferenceRepository.Setup(r => r.FindStaleAsync(It.IsAny(), It.IsAny())).ReturnsAsync([ + new TvShowReferenceModel + { + Id = "reference-1", Title = "Some Show", TitleNormalized = "some show", + ExternalIds = new Dictionary { ["tmdb"] = "42" }, Ratings = new(ratings), LastEnrichedAt = DateTime.UtcNow + } + ]); + _movieReferenceRepository.Setup(r => r.FindStaleAsync(It.IsAny(), It.IsAny())).ReturnsAsync([ + new MovieReferenceModel + { + Id = "reference-2", Title = "Some Movie", TitleNormalized = "some movie", + ExternalIds = new Dictionary { ["tmdb"] = "603" }, Ratings = new(ratings), LastEnrichedAt = DateTime.UtcNow + } + ]); + _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); + _movieReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((MovieReferenceModel m) => m); + return tmdbClient; + } + + [Fact] + public async Task SyncStaleReferencesAsync_FetchesTvShowsAndMoviesInFull_WhenForced_EvenIfTmdbReportsNoChange() + { + var tmdbClient = SetupRecentlyEnrichedTvShowAndMovieReportedUnchanged(); + var service = CreateService(tmdbClient); + + var result = await service.SyncStaleReferencesAsync(ReferenceSyncWindows.Forced.References, fullFetch: ReferenceSyncWindows.Forced.FullFetch, + cancellationToken: TestContext.Current.CancellationToken); + + result.TvShowsUpdated.Should().Be(1); + result.MoviesUpdated.Should().Be(1); + tmdbClient.ChangesRequested.Should().BeEmpty(); + _tvShowReferenceRepository.Verify(r => r.UpsertAsync(It.Is(m => m.Episodes.Exists(e => e.SeasonNumber == 4))), Times.Once); + } + + [Fact] + public async Task SyncStaleReferencesAsync_TrustsTmdbsChangeCheck_OnThePeriodicPass() + { + var tmdbClient = SetupRecentlyEnrichedTvShowAndMovieReportedUnchanged(); + var service = CreateService(tmdbClient); + + var result = await service.SyncStaleReferencesAsync(ReferenceSyncWindows.Periodic.References, fullFetch: ReferenceSyncWindows.Periodic.FullFetch, + cancellationToken: TestContext.Current.CancellationToken); + + result.TvShowsUpdated.Should().Be(0); + result.MoviesUpdated.Should().Be(0); + tmdbClient.ChangesRequested.Should().BeEquivalentTo(["42", "603"]); + } + [Fact] public async Task SyncStaleReferencesAsync_ContinuesPastAFailedReference_AndStillProcessesTheRest() { @@ -148,7 +208,7 @@ await service.SyncStaleReferencesAsync( stages.Add(stage); return Task.CompletedTask; }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); stages.Should().ContainInOrder( ReferenceSyncStage.SyncingTvShows, @@ -166,6 +226,11 @@ private sealed class FakeTmdbClient : ITmdbClient public string? ThrowForTmdbId { get; set; } + /// The answer of TMDB's change check, keyed by tmdb id, a change by default. + public Dictionary ChangedSince { get; } = new(); + + public List ChangesRequested { get; } = []; + public static FakeTmdbClient Empty() => new(); public Task> GetTopRatedMoviesAsync(int page, CancellationToken cancellationToken = default) => @@ -198,11 +263,17 @@ public Task> GetTvShowCastAsync(string tmdbId, Can public Task> GetMovieCastAsync(string tmdbId, CancellationToken cancellationToken = default) => Task.FromResult>([]); - public Task HasTvShowChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default) => - Task.FromResult(true); + public Task HasTvShowChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default) + { + ChangesRequested.Add(tmdbId); + return Task.FromResult(ChangedSince.GetValueOrDefault(tmdbId, true)); + } - public Task HasMovieChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default) => - Task.FromResult(true); + public Task HasMovieChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default) + { + ChangesRequested.Add(tmdbId); + return Task.FromResult(ChangedSince.GetValueOrDefault(tmdbId, true)); + } public Task GetTvShowImdbIdAsync(string tmdbId, CancellationToken cancellationToken = default) => Task.FromResult(null); diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncWindowsTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncWindowsTest.cs index beb45d7b..12a87b8f 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncWindowsTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncWindowsTest.cs @@ -26,6 +26,16 @@ public void For_WithForce_TakesEveryDocumentAndRanking() windows.Explore.Should().Be(TimeSpan.Zero); } + /// + /// TMDB's change check only sees changes after the last enrichment, so forcing is the one way to recover a change it missed. + /// + [Fact] + public void For_WithForce_FetchesInFull_WhereThePeriodicPassTrustsTheChangeCheck() + { + ReferenceSyncWindows.For(true).FullFetch.Should().BeTrue(); + ReferenceSyncWindows.Periodic.FullFetch.Should().BeFalse(); + } + /// /// Pins the documented cadence: reference documents are re-checked far more often than the discovery rankings, which barely move week to week. /// diff --git a/test/WebApi.UnitTests/ReferenceData/TmdbClientTest.cs b/test/WebApi.UnitTests/ReferenceData/TmdbClientTest.cs new file mode 100644 index 00000000..57136a16 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/TmdbClientTest.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.WebApi.ReferenceData; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +/// +/// TMDB's /changes with only a start_date answers the changes of that one day, not the changes since it, and rejects a range over 14 days. +/// A check that ignored either missed every season aired after the day a reference was last enriched, so the sync never fetched it. +/// +[Trait("Category", "UnitTests")] +public class TmdbClientTest +{ + private static TmdbClient BuildClient(StubHttpMessageHandler handler) => + new(new HttpClient(handler) { BaseAddress = new Uri("https://api.themoviedb.org/3/") }, new TmdbSettings { ApiKey = "k" }); + + private sealed class StubHttpMessageHandler(string body) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request.RequestUri!); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }); + } + } + + [Fact] + public async Task HasTvShowChangedSinceAsync_AsksForTheWholeRangeUpToToday() + { + var handler = new StubHttpMessageHandler("""{"changes":[{"key":"season","items":[]}]}"""); + var since = DateTime.UtcNow.AddDays(-3); + + var changed = await BuildClient(handler).HasTvShowChangedSinceAsync("203857", since, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + handler.Requests.Should().ContainSingle(); + var query = handler.Requests[0].Query; + query.Should().Contain($"start_date={since:yyyy-MM-dd}"); + query.Should().Contain($"end_date={DateTime.UtcNow:yyyy-MM-dd}"); + } + + [Fact] + public async Task HasTvShowChangedSinceAsync_ReportsNoChange_WhenTheRangeHasNone() + { + var handler = new StubHttpMessageHandler("""{"changes":[]}"""); + + var changed = await BuildClient(handler).HasTvShowChangedSinceAsync("203857", DateTime.UtcNow.AddDays(-3), TestContext.Current.CancellationToken); + + changed.Should().BeFalse(); + } + + [Fact] + public async Task HasTvShowChangedSinceAsync_ReportsAChangeWithoutCalling_WhenTheRangeExceedsWhatTmdbAccepts() + { + var handler = new StubHttpMessageHandler("""{"changes":[]}"""); + + var changed = await BuildClient(handler).HasTvShowChangedSinceAsync("203857", DateTime.UtcNow.AddDays(-30), TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + handler.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task HasTvShowChangedSinceAsync_StillAsksTmdb_AtExactlyTheLongestRangeItAccepts() + { + var handler = new StubHttpMessageHandler("""{"changes":[]}"""); + var since = DateTime.UtcNow.AddDays(-14); + + var changed = await BuildClient(handler).HasTvShowChangedSinceAsync("203857", since, TestContext.Current.CancellationToken); + + changed.Should().BeFalse(); + handler.Requests.Should().ContainSingle().Which.Query.Should().Contain($"start_date={since:yyyy-MM-dd}"); + } + + [Fact] + public async Task HasTvShowChangedSinceAsync_ReportsAChangeWithoutCalling_OneDayPastTheLongestRange() + { + var handler = new StubHttpMessageHandler("""{"changes":[]}"""); + + var changed = await BuildClient(handler).HasTvShowChangedSinceAsync("203857", DateTime.UtcNow.AddDays(-15), TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + handler.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task HasMovieChangedSinceAsync_AsksForTheWholeRangeUpToToday() + { + var handler = new StubHttpMessageHandler("""{"changes":[{"key":"images","items":[]}]}"""); + var since = DateTime.UtcNow.AddDays(-3); + + var changed = await BuildClient(handler).HasMovieChangedSinceAsync("603", since, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + var request = handler.Requests.Should().ContainSingle().Subject; + request.AbsolutePath.Should().EndWith("/movie/603/changes"); + request.Query.Should().Contain($"start_date={since:yyyy-MM-dd}").And.Contain($"end_date={DateTime.UtcNow:yyyy-MM-dd}"); + } + + [Fact] + public async Task HasMovieChangedSinceAsync_ReportsAChangeWithoutCalling_WhenTheRangeExceedsWhatTmdbAccepts() + { + var handler = new StubHttpMessageHandler("""{"changes":[]}"""); + + var changed = await BuildClient(handler).HasMovieChangedSinceAsync("603", DateTime.UtcNow.AddDays(-30), TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + handler.Requests.Should().BeEmpty(); + } +} diff --git a/test/WebApi.UnitTests/ReferenceData/TvShowStatusReconciliationServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/TvShowStatusReconciliationServiceTest.cs index be94dfe4..16d52224 100644 --- a/test/WebApi.UnitTests/ReferenceData/TvShowStatusReconciliationServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/TvShowStatusReconciliationServiceTest.cs @@ -74,6 +74,69 @@ public async Task Reopens_FinishedShow_WhenReferenceHasAnAiredEpisodeAfterTheLas "show-1", It.Is(s => s.State == TvShowStatus.Current), "owner"), Times.Once); } + [Fact] + public async Task Reopens_FinishedShow_WhenANewSeasonAiredAfterTheLastWatchedSeasonFinale() + { + var today = DateOnly.FromDateTime(DateTime.Today); + Setup( + [FinishedShow("show-1")], + [Episode("show-1", 3, 7), Episode("show-1", 3, 8)], + Reference("ref-1", + RefEpisode(3, 7, today.AddYears(-1)), RefEpisode(3, 8, today.AddYears(-1)), + RefEpisode(4, 1, today.AddDays(-12)), RefEpisode(4, 2, today.AddDays(-12)))); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(1); + _tvShowRepository.Verify(r => r.UpdateAsync( + "show-1", It.Is(s => s.State == TvShowStatus.Current), "owner"), Times.Once); + } + + [Fact] + public async Task Reopens_FinishedShow_WhenTheNewEpisodeAirsToday() + { + var today = DateOnly.FromDateTime(DateTime.Today); + Setup( + [FinishedShow("show-1")], + [Episode("show-1", 3, 8)], + Reference("ref-1", RefEpisode(3, 8, today.AddYears(-1)), RefEpisode(4, 1, today))); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(1); + } + + [Fact] + public async Task Reopens_FinishedShow_WhenAnEarlierEpisodeWasSkippedButANewerOneAired() + { + // the comparison is against the last watched episode, so an unwatched gap before it never reopens a show on its own + Setup( + [FinishedShow("show-1")], + [Episode("show-1", 1, 1), Episode("show-1", 1, 3)], + Reference("ref-1", RefEpisode(1, 1), RefEpisode(1, 2), RefEpisode(1, 3), RefEpisode(1, 4, new DateOnly(2024, 1, 1)))); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(1); + } + + [Fact] + public async Task LeavesShowAlone_WhenTheOnlyUnwatchedEpisodeIsBeforeTheLastWatched() + { + Setup( + [FinishedShow("show-1")], + [Episode("show-1", 1, 1), Episode("show-1", 1, 3)], + Reference("ref-1", RefEpisode(1, 1), RefEpisode(1, 2), RefEpisode(1, 3))); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(0); + } + [Fact] public async Task LeavesShowAlone_WhenAlreadyCaughtUpWithTheReferenceGuide() {