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()
{