From db5d7968bcb49bb870a6bfb25780bb2d6893f32f Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 26 Jul 2026 21:04:30 +0200 Subject: [PATCH 01/80] Add sonar issues in code quality findings docs --- docs/code-quality-findings.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/code-quality-findings.md b/docs/code-quality-findings.md index 20499cf6..1ae3005f 100644 --- a/docs/code-quality-findings.md +++ b/docs/code-quality-findings.md @@ -4,6 +4,36 @@ This document tracks a code review performed on 2026-07-06 against current .NET Each finding is classified as a confirmed bug, a confirmed by-design behavior, or a known gap that is not yet implemented. Update this file as items are fixed or as new reviews are performed. +## Sonar issues + +1. S8970 — null-forgiving operator (60 of 67 issues, MINOR) + + Verdict: false positive, don't touch the code. + + This rule fires when Sonar's engine believes nullable warnings are disabled at that point, making ! a no-op. + But BlazorApp.csproj has enable project-wide, and every flagged ! (e.g. context.User.Identity!.Name! in Manage.razor:8, (bool)e.Value! in several @onchange handlers) + is a genuine, meaningful suppression against a real nullable-annotated API (ClaimsPrincipal.Identity, ChangeEventArgs.Value). + + This is a known SonarC# limitation with Razor-generated code: the source generator's nullable-context pragmas don't map cleanly back onto markup-embedded lambdas/expressions, + so Sonar loses track of the enclosing #nullable enable region. + + Removing these ! would just reintroduce real CS8600/CS8602 build warnings. + + Recommendation: bulk-resolve rule S8970 as "False Positive" in the SonarCloud UI rather than editing 60 call sites. + +2. S107 — too many parameters (3 issues, MAJOR) — legitimate design smell + + `OwnedItemImportMergeService.ComputeCommitPlan` (8 params), `.MergeItem` (9 params), and `AmazonImportController.CommitAsync` (10 params) all carry the same six-delegate bundle + (getExistingTitle, getExistingReferences, getItemTitle, getItemReference, createNew, appendOwnedCopy) repeated across three call sites. + This is the intentional "generic engine over delegates instead of an interface" design documented in CLAUDE.md, but Sonar's flag is fair: + bundling those six delegates into one small record (e.g. ItemTypeAdapter) would cut each signature to 2–4 params with zero behavior change and no loss of genericity. + + Recommendation: worth doing, low risk. + +3. CA1859 (2 issues, INFO) + + Test-only, "return MemoryStream instead of Stream" in two fixture builder helpers. Harmless, informational, not worth spending time on. + ## Fixed ### Title-only fallback ignored a tenant-recorded year, so two same-titled but genuinely different items could be silently linked to the same reference document - or, worse, merged into one via `Resolve*Async` From 58686f8608226d99d26d97b3e3d78996ed3571db Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 26 Jul 2026 21:07:31 +0200 Subject: [PATCH 02/80] Bump version 2.4.1 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 0a5f0828..a12d3cb9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ - 2.4.0 + 2.4.1 From 372de1271644ac5e51f45ae17d039d8743996192 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 27 Jul 2026 19:58:23 +0200 Subject: [PATCH 03/80] =?UTF-8?q?Fix=20sonar=20warnings=20S2365,=20ASP0025?= =?UTF-8?q?=20=C3=972,=20CA1862=20=C3=972,=20CA1859=20=C3=973,=20JS=20S248?= =?UTF-8?q?6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ┌────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐ │ Fix │ Verification │ ├────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ S2365 — PlaylistDetail.razor rename │ PlaylistSmokeTest passed against a real browser + real Blazor Server circuit │ ├────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ ASP0025 ×2 — AddAuthorizationBuilder │ ReferenceDataAdminResourceTest (AdminOnly) + PlaylistResourceTest/BookResourceTest │ │ │ (MemberOnly) — 9/9 passed over real HTTP with real Firebase auth │ ├────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ CA1862 ×2 — │ AlbumReferenceRepositoryTest/BookReferenceRepositoryTest — 7/7 passed against real MongoDB │ │ StringComparison.OrdinalIgnoreCase │ │ ├────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ CA1859 ×3 — concrete return types │ 235/235 unit tests passed │ ├────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ JS S2486 — logged exception │ No test infra exists for this file — still an honest gap, noted in the docs │ └────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘ docker itself isn't on PATH in either shell here (confirmed again), but Test-NetConnection -Port 27017 proved Mongo was reachable, which was enough to run everything through it directly. Updated docs/code-quality-findings.md with the real test results (replacing the earlier "couldn't verify" caveat), and saved a memory note on the working recipe for WebApi.IntegrationTests (not just Playwright) via Local.runsettings env-var loading + --filter-class, since that'll save time next session. --- docs/code-quality-findings.md | 36 +++-- .../Inventory/Pages/PlaylistDetail.razor | 6 +- .../Components/Layout/ReconnectModal.razor.js | 1 + src/BlazorApp/Program.cs | 8 +- src/Domain/Models/OwnedItemImportAdapter.cs | 20 +++ .../Services/OwnedItemImportMergeService.cs | 45 +++--- .../Controllers/AmazonImportController.cs | 152 +++++++++--------- .../GenericVideoGameImportController.cs | 23 +-- src/WebApi/Program.cs | 8 +- .../Resources/AlbumReferenceRepositoryTest.cs | 2 +- .../Resources/BookReferenceRepositoryTest.cs | 2 +- .../ReferenceData/OpenLibraryClientTest.cs | 2 +- .../Services/AmazonOrderPreviewServiceTest.cs | 2 +- .../GenericVideoGameImportServiceTest.cs | 2 +- .../OwnedItemImportMergeServiceTest.cs | 32 ++-- 15 files changed, 186 insertions(+), 155 deletions(-) create mode 100644 src/Domain/Models/OwnedItemImportAdapter.cs diff --git a/docs/code-quality-findings.md b/docs/code-quality-findings.md index 1ae3005f..09d3e153 100644 --- a/docs/code-quality-findings.md +++ b/docs/code-quality-findings.md @@ -21,20 +21,38 @@ Update this file as items are fixed or as new reviews are performed. Recommendation: bulk-resolve rule S8970 as "False Positive" in the SonarCloud UI rather than editing 60 call sites. -2. S107 — too many parameters (3 issues, MAJOR) — legitimate design smell +## Fixed - `OwnedItemImportMergeService.ComputeCommitPlan` (8 params), `.MergeItem` (9 params), and `AmazonImportController.CommitAsync` (10 params) all carry the same six-delegate bundle - (getExistingTitle, getExistingReferences, getItemTitle, getItemReference, createNew, appendOwnedCopy) repeated across three call sites. - This is the intentional "generic engine over delegates instead of an interface" design documented in CLAUDE.md, but Sonar's flag is fair: - bundling those six delegates into one small record (e.g. ItemTypeAdapter) would cut each signature to 2–4 params with zero behavior change and no loss of genericity. +### Project-wide Sonar review (main branch, not PR-scoped): S2365, ASP0025 ×2, CA1862 ×2, CA1859 ×3, JS S2486 - Recommendation: worth doing, low risk. +Fixed on 2026-07-27, reviewed against `https://sonarcloud.io/project/issues?issueStatuses=OPEN&id=devpro_keeptrack` (28 open issues at the time, excluding 3 `S1135` "TODO" issues out of scope for this pass). -3. CA1859 (2 issues, INFO) +- **S2365** CRITICAL, `PlaylistDetail.razor:144` - `PlaylistSongs` was a property doing `.Select().Where().ToList()` on every access, read twice per render (`.Count` then `@foreach`). Renamed to a method, `GetPlaylistSongs()`, per convention (expensive/allocating work shouldn't look like a cheap property). No behavior change. +- **ASP0025** INFO × 2, `WebApi/Program.cs`, `BlazorApp/Program.cs` - both used the older `AddAuthorization(options => { options.AddPolicy(...); ... })` shape; switched to `AddAuthorizationBuilder().AddPolicy(...).AddPolicy(...)`, the modern .NET 8+ API. Same policies, same behavior. +- **CA1862** INFO × 2, `AlbumReferenceRepositoryTest.cs:103`, `BookReferenceRepositoryTest.cs:144` - test assertions did `m.Title == title.ToLowerInvariant()`; switched to `string.Equals(m.Title, title, StringComparison.OrdinalIgnoreCase)`. +- **CA1859** INFO × 3 - `AmazonOrderPreviewServiceTest.ToStream`/`GenericVideoGameImportServiceTest.ToStream` now return `MemoryStream` instead of `Stream`; `OpenLibraryClientTest.BuildClient` now returns `OpenLibraryClient` instead of `IBookReferenceClient` (checked call sites first - both members it exposes, `ProviderKey`/`GetBookDetailsAsync`, are public on the concrete class, not explicit interface implementations). +- **javascript S2486** MINOR, `ReconnectModal.razor.js:42` - `catch (err)` never used `err`, silently swallowing the exception. Added `console.error("Blazor reconnect failed:", err)`. - Test-only, "return MemoryStream instead of Stream" in two fixture builder helpers. Harmless, informational, not worth spending time on. +Investigated but confirmed **not** actionable during the same pass (left as-is, see "Sonar issues" note below for the rest of the 28): `S8969` in `TvTimeImportService.cs:469-470` looked like the same Razor-generated-code false positive as `S8970` above, but is a **different, real** finding - removing the `!` after `Dictionary.TryGetValue`'s `out model!` reintroduces genuine `CS8601` warnings on rebuild (confirmed by actually removing it and rebuilding), because `[MaybeNullWhen(false)]` doesn't flow cleanly through the open generic `TModel` parameter here. Don't conflate the two rule ids - `S8970` (Razor markup, nullable context lost) is a false positive; `S8969` (plain `.cs`, "the compiler already knows") needs checking case-by-case, this one isn't. -## Fixed +Verification: full solution build (0 warnings/errors), full `WebApi.UnitTests` run (235/235 passed, includes the three CA1859-touched test classes). +Once a local MongoDB became available, also ran directly against it (`Local.runsettings` env vars loaded into the process, real Firebase auth): +`AlbumReferenceRepositoryTest`/`BookReferenceRepositoryTest` (the two `CA1862` files) - 7/7 passed; +`ReferenceDataAdminResourceTest` (real HTTP call through `[Authorize(Policy="AdminOnly")]`) plus `PlaylistResourceTest`/`BookResourceTest` (real HTTP calls through `[Authorize(Policy="MemberOnly")]`) - 9/9 passed (1 self-skipped, the opt-in `SyncNow_PollingReachesACompletedResult`) - this is what actually proves the `ASP0025` `AddAuthorizationBuilder` switch still enforces both policies correctly end-to-end, not just that the attribute is present; +`BlazorApp.PlaywrightTests`' `PlaylistSmokeTest.AddAndDelete_PlaylistThroughTheList` (real browser, real Blazor Server circuit) - passed, proving `GetPlaylistSongs()` renders correctly post-rename. +That Playwright test still only exercises `GetPlaylistSongs()`'s empty-list branch (no song is ever added to the playlist in that test) - the populated-list/dangling-`SongId`-skip branch has no automated coverage before or after this change; adding it would need a synthetic album+tracklist fixture (a bigger addition than the rename itself), flagged here rather than silently left uncovered. + +Files: `src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor`, `src/WebApi/Program.cs`, `src/BlazorApp/Program.cs`, `test/WebApi.IntegrationTests/Resources/AlbumReferenceRepositoryTest.cs`, `test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs`, `test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs`, `test/WebApi.UnitTests/Services/GenericVideoGameImportServiceTest.cs`, `test/WebApi.UnitTests/ReferenceData/OpenLibraryClientTest.cs`, `src/BlazorApp/Components/Layout/ReconnectModal.razor.js` + +### S107 — too many parameters on `OwnedItemImportMergeService.ComputeCommitPlan`/`.MergeItem` and `AmazonImportController.CommitAsync` + +Fixed on 2026-07-26 (PR #467 Sonar review). All three methods carried the same six-delegate bundle +(`getExistingTitle`, `getExistingReferences`, `getItemTitle`, `getItemReference`, `createNew`, `appendOwnedCopy`) as separate parameters, pushing their signatures to 8/9/10 params. +This was the intentional "generic engine over delegates instead of an interface" design (still documented in CLAUDE.md), so the fix keeps that design - it just stops repeating the six delegates individually. +Bundled them into one new `OwnedItemImportAdapter` record (`Domain/Models/OwnedItemImportAdapter.cs`) and threaded that single value through instead, +cutting `ComputeCommitPlan` to 3 params, `MergeItem` to 6, and `CommitAsync` to 5 - no change in behavior or genericity, all 8 call sites (6 in `AmazonImportController`, 1 in `GenericVideoGameImportController`, 2 in `OwnedItemImportMergeServiceTest`) construct the adapter inline the same way they used to pass the delegates. + +Files: `src/Domain/Models/OwnedItemImportAdapter.cs`, `src/Domain/Services/OwnedItemImportMergeService.cs`, `src/WebApi/Controllers/AmazonImportController.cs`, `src/WebApi/Controllers/GenericVideoGameImportController.cs`, `test/WebApi.UnitTests/Services/OwnedItemImportMergeServiceTest.cs` ### Title-only fallback ignored a tenant-recorded year, so two same-titled but genuinely different items could be silently linked to the same reference document - or, worse, merged into one via `Resolve*Async` diff --git a/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor b/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor index d2e3c768..d89d91aa 100644 --- a/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor @@ -31,14 +31,14 @@ else

Songs

- @if (PlaylistSongs.Count == 0) + @if (GetPlaylistSongs().Count == 0) {

No songs yet - add one from an album below.

} else {
- @foreach (var (song, index) in PlaylistSongs) + @foreach (var (song, index) in GetPlaylistSongs()) {
@song.Title @@ -138,7 +138,7 @@ else /// deleted separately) is silently skipped rather than shown as a broken row, the same soft-reference /// tolerance already used for a missing reference-link elsewhere in the app. /// - private List<(SongDto Song, int Index)> PlaylistSongs => + private List<(SongDto Song, int Index)> GetPlaylistSongs() => Playlist is null ? [] : Playlist.SongIds diff --git a/src/BlazorApp/Components/Layout/ReconnectModal.razor.js b/src/BlazorApp/Components/Layout/ReconnectModal.razor.js index a44de78d..fc57dd02 100644 --- a/src/BlazorApp/Components/Layout/ReconnectModal.razor.js +++ b/src/BlazorApp/Components/Layout/ReconnectModal.razor.js @@ -41,6 +41,7 @@ async function retry() { } } catch (err) { // We got an exception, server is currently unavailable + console.error("Blazor reconnect failed:", err); document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); } } diff --git a/src/BlazorApp/Program.cs b/src/BlazorApp/Program.cs index 3a3c5f4b..b303966e 100644 --- a/src/BlazorApp/Program.cs +++ b/src/BlazorApp/Program.cs @@ -12,14 +12,12 @@ options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = true; }); -builder.Services.AddAuthorization(options => -{ - options.AddPolicy("AdminOnly", policy => policy.RequireClaim("role", "admin")); +builder.Services.AddAuthorizationBuilder() + .AddPolicy("AdminOnly", policy => policy.RequireClaim("role", "admin")) // mirrors WebApi's policy (the cookie principal carries the same Firebase "role" claim): members and // admins see the whole app; everyone else is the free preview tier (movies + TV shows). This only // drives what the UI shows - the API enforces the same rule on every request. - options.AddPolicy("MemberOnly", policy => policy.RequireClaim("role", "member", "admin")); -}); + .AddPolicy("MemberOnly", policy => policy.RequireClaim("role", "member", "admin")); // opt-in shared Data Protection key ring (see MongoDbXmlRepository) - required before running more than // one replica of this app, since the auth cookie and antiforgery tokens must decrypt on every replica. // Left unset (the default), the framework keeps its usual per-instance ephemeral keys. diff --git a/src/Domain/Models/OwnedItemImportAdapter.cs b/src/Domain/Models/OwnedItemImportAdapter.cs new file mode 100644 index 00000000..b2d7baa7 --- /dev/null +++ b/src/Domain/Models/OwnedItemImportAdapter.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace Keeptrack.Domain.Models; + +/// +/// The six per-type delegates +/// needs to run its generic merge algorithm against a specific trackable type (BookModel/MovieModel/ +/// TvShowModel/VideoGameModel/...) and its own request-item shape, bundled into one value instead of +/// threaded individually through every method in the call chain (was flagged by Sonar's S107 "too many parameters" +/// on three methods that all repeated the same six params). +/// +public sealed record OwnedItemImportAdapter( + Func GetExistingTitle, + Func> GetExistingReferences, + Func GetItemTitle, + Func GetItemReference, + Func CreateNew, + Action AppendOwnedCopy) + where TModel : class; diff --git a/src/Domain/Services/OwnedItemImportMergeService.cs b/src/Domain/Services/OwnedItemImportMergeService.cs index 89f70485..97a29b2c 100644 --- a/src/Domain/Services/OwnedItemImportMergeService.cs +++ b/src/Domain/Services/OwnedItemImportMergeService.cs @@ -15,9 +15,11 @@ namespace Keeptrack.Domain.Services; /// owned copies, even if neither existed before this commit. /// /// Generic over both the tracked model (BookModel/MovieModel/TvShowModel/VideoGameModel) -/// and its own request-item shape via delegates rather than a shared interface - Book/Movie/TvShow's -/// OwnedVersions and VideoGame's differently-shaped Platforms both flow through the exact same -/// algorithm this way, with no changes to any of those models. Extracted out of the originally Amazon-only +/// and its own request-item shape via an of delegates +/// rather than a shared interface - Book/Movie/TvShow's OwnedVersions and VideoGame's differently-shaped +/// Platforms both flow through the exact same algorithm this way, with no changes to any of those models. +/// The six delegates are bundled into one adapter value (rather than threaded individually) so the methods below +/// don't each repeat all six as separate parameters. Extracted out of the originally Amazon-only /// once a second importer (the generic video game transaction import) /// needed the exact same engine - the two members here were already fully generic (no Amazon-specific /// concept anywhere in their bodies), only and @@ -39,7 +41,7 @@ public static HashSet FindImportedReferences(IEnumerable existingItems.SelectMany(getReferences).Where(reference => reference is not null).ToHashSet()!; /// - /// / make the caller's own + /// 's GetExistingReferences/GetItemReference make the caller's own /// reference (see ) the *primary* dedup key, not just an /// advisory preview-time flag: a re-committed row whose reference already exists on some existing item - /// under any title, even one reference-data linking has since renamed - is skipped outright rather than @@ -53,19 +55,14 @@ public static HashSet FindImportedReferences(IEnumerable public static ImportCommitPlan ComputeCommitPlan( IReadOnlyCollection existingItems, IReadOnlyList items, - Func getExistingTitle, - Func> getExistingReferences, - Func getItemTitle, - Func getItemReference, - Func createNew, - Action appendOwnedCopy) + OwnedItemImportAdapter adapter) where TModel : class { var plan = new ImportCommitPlan(); var byNormalizedTitle = new Dictionary(); var byReference = new Dictionary(); - IndexExistingItems(existingItems, getExistingTitle, getExistingReferences, byNormalizedTitle, byReference); + IndexExistingItems(existingItems, adapter, byNormalizedTitle, byReference); // Items created earlier in this same batch are tracked separately from pre-existing ones: a later // row matching one must only get its owned copy appended (already reflected via ItemsToCreate), @@ -74,24 +71,23 @@ public static ImportCommitPlan ComputeCommitPlan( foreach (var item in items) { - MergeItem(item, plan, byNormalizedTitle, byReference, createdThisBatch, getItemTitle, getItemReference, createNew, appendOwnedCopy); + MergeItem(item, plan, byNormalizedTitle, byReference, createdThisBatch, adapter); } return plan; } - private static void IndexExistingItems( + private static void IndexExistingItems( IReadOnlyCollection existingItems, - Func getExistingTitle, - Func> getExistingReferences, + OwnedItemImportAdapter adapter, Dictionary byNormalizedTitle, Dictionary byReference) where TModel : class { foreach (var existing in existingItems) { - byNormalizedTitle.TryAdd(TitleNormalizer.Normalize(getExistingTitle(existing)), existing); - foreach (var reference in getExistingReferences(existing).OfType()) + byNormalizedTitle.TryAdd(TitleNormalizer.Normalize(adapter.GetExistingTitle(existing)), existing); + foreach (var reference in adapter.GetExistingReferences(existing).OfType()) { byReference.TryAdd(reference, existing); } @@ -104,26 +100,23 @@ private static void MergeItem( Dictionary byNormalizedTitle, Dictionary byReference, HashSet createdThisBatch, - Func getItemTitle, - Func getItemReference, - Func createNew, - Action appendOwnedCopy) + OwnedItemImportAdapter adapter) where TModel : class { - var reference = getItemReference(item); + var reference = adapter.GetItemReference(item); if (reference is not null && byReference.ContainsKey(reference)) { plan.OwnedCopiesSkipped++; - plan.SkippedTitles.Add(getItemTitle(item)); + plan.SkippedTitles.Add(adapter.GetItemTitle(item)); return; } - var key = TitleNormalizer.Normalize(getItemTitle(item)); + var key = TitleNormalizer.Normalize(adapter.GetItemTitle(item)); TModel target; if (byNormalizedTitle.TryGetValue(key, out var existing)) { - appendOwnedCopy(existing, item); + adapter.AppendOwnedCopy(existing, item); if (!createdThisBatch.Contains(existing) && !plan.ItemsToUpdate.Contains(existing)) { plan.ItemsToUpdate.Add(existing); @@ -133,7 +126,7 @@ private static void MergeItem( } else { - var created = createNew(item); + var created = adapter.CreateNew(item); plan.ItemsToCreate.Add(created); createdThisBatch.Add(created); diff --git a/src/WebApi/Controllers/AmazonImportController.cs b/src/WebApi/Controllers/AmazonImportController.cs index 0c88ba58..372072b7 100644 --- a/src/WebApi/Controllers/AmazonImportController.cs +++ b/src/WebApi/Controllers/AmazonImportController.cs @@ -110,19 +110,20 @@ public async Task> Commit(AmazonImport var existingBooks = await FindAllAsync(bookRepository, ownerId, new BookModel { OwnerId = ownerId, Title = string.Empty, Author = string.Empty }); var (created, mergedInto, skipped) = await CommitAsync( bookRepository, existingBooks, bookItems.Select(ToOwnedItemRequestItem).ToList(), - b => b.Title, b => b.OwnedVersions.Select(v => v.Reference), - i => i.Title, i => i.OwnedVersion.Reference, - item => new BookModel - { - OwnerId = ownerId, - Title = item.Title, - Author = string.Empty, - Year = item.Year, - Isbn = item.Isbn, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, item.Isbn), - OwnedVersions = [item.OwnedVersion] - }, - (book, item) => book.OwnedVersions.Add(item.OwnedVersion), ownerId); + new OwnedItemImportAdapter( + b => b.Title, b => b.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new BookModel + { + OwnerId = ownerId, + Title = item.Title, + Author = string.Empty, + Year = item.Year, + Isbn = item.Isbn, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, item.Isbn), + OwnedVersions = [item.OwnedVersion] + }, + (book, item) => book.OwnedVersions.Add(item.OwnedVersion)), ownerId); (result.BooksCreated, result.BooksMergedInto, result.BooksSkipped) = (created, mergedInto, skipped); } @@ -131,17 +132,18 @@ public async Task> Commit(AmazonImport var existingMovies = await FindAllAsync(movieRepository, ownerId, new MovieModel { OwnerId = ownerId, Title = string.Empty }); var (created, mergedInto, skipped) = await CommitAsync( movieRepository, existingMovies, movieItems.Select(ToOwnedItemRequestItem).ToList(), - m => m.Title, m => m.OwnedVersions.Select(v => v.Reference), - i => i.Title, i => i.OwnedVersion.Reference, - item => new MovieModel - { - OwnerId = ownerId, - Title = item.Title, - Year = item.Year, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), - OwnedVersions = [item.OwnedVersion] - }, - (movie, item) => movie.OwnedVersions.Add(item.OwnedVersion), ownerId); + new OwnedItemImportAdapter( + m => m.Title, m => m.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new MovieModel + { + OwnerId = ownerId, + Title = item.Title, + Year = item.Year, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), + OwnedVersions = [item.OwnedVersion] + }, + (movie, item) => movie.OwnedVersions.Add(item.OwnedVersion)), ownerId); (result.MoviesCreated, result.MoviesMergedInto, result.MoviesSkipped) = (created, mergedInto, skipped); } @@ -150,17 +152,18 @@ public async Task> Commit(AmazonImport var existingTvShows = await FindAllAsync(tvShowRepository, ownerId, new TvShowModel { OwnerId = ownerId, Title = string.Empty }); var (created, mergedInto, skipped) = await CommitAsync( tvShowRepository, existingTvShows, tvShowItems.Select(ToOwnedItemRequestItem).ToList(), - t => t.Title, t => t.OwnedVersions.Select(v => v.Reference), - i => i.Title, i => i.OwnedVersion.Reference, - item => new TvShowModel - { - OwnerId = ownerId, - Title = item.Title, - Year = item.Year, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), - OwnedVersions = [item.OwnedVersion] - }, - (tvShow, item) => tvShow.OwnedVersions.Add(item.OwnedVersion), ownerId); + new OwnedItemImportAdapter( + t => t.Title, t => t.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new TvShowModel + { + OwnerId = ownerId, + Title = item.Title, + Year = item.Year, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), + OwnedVersions = [item.OwnedVersion] + }, + (tvShow, item) => tvShow.OwnedVersions.Add(item.OwnedVersion)), ownerId); (result.TvShowsCreated, result.TvShowsMergedInto, result.TvShowsSkipped) = (created, mergedInto, skipped); } @@ -169,17 +172,18 @@ public async Task> Commit(AmazonImport var existingVideoGames = await FindAllAsync(videoGameRepository, ownerId, new VideoGameModel { OwnerId = ownerId, Title = string.Empty }); var (created, mergedInto, skipped) = await CommitAsync( videoGameRepository, existingVideoGames, videoGameItems.Select(ToVideoGameRequestItem).ToList(), - g => g.Title, g => g.Platforms.Select(p => p.Reference), - i => i.Title, i => i.Platform.Reference, - item => new VideoGameModel - { - OwnerId = ownerId, - Title = item.Title, - Year = item.Year, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), - Platforms = [item.Platform] - }, - (game, item) => game.Platforms.Add(item.Platform), ownerId); + new OwnedItemImportAdapter( + g => g.Title, g => g.Platforms.Select(p => p.Reference), + i => i.Title, i => i.Platform.Reference, + item => new VideoGameModel + { + OwnerId = ownerId, + Title = item.Title, + Year = item.Year, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), + Platforms = [item.Platform] + }, + (game, item) => game.Platforms.Add(item.Platform)), ownerId); (result.VideoGamesCreated, result.VideoGamesMergedInto, result.VideoGamesSkipped) = (created, mergedInto, skipped); } @@ -188,17 +192,18 @@ public async Task> Commit(AmazonImport var existingGear = await FindAllAsync(gearRepository, ownerId, new GearModel { OwnerId = ownerId, Title = string.Empty }); var (created, mergedInto, skipped) = await CommitAsync( gearRepository, existingGear, gearItems.Select(ToOwnedItemRequestItem).ToList(), - g => g.Title, g => g.OwnedVersions.Select(v => v.Reference), - i => i.Title, i => i.OwnedVersion.Reference, - item => new GearModel - { - OwnerId = ownerId, - Title = item.Title, - Year = item.Year, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), - OwnedVersions = [item.OwnedVersion] - }, - (gear, item) => gear.OwnedVersions.Add(item.OwnedVersion), ownerId); + new OwnedItemImportAdapter( + g => g.Title, g => g.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new GearModel + { + OwnerId = ownerId, + Title = item.Title, + Year = item.Year, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), + OwnedVersions = [item.OwnedVersion] + }, + (gear, item) => gear.OwnedVersions.Add(item.OwnedVersion)), ownerId); (result.GearCreated, result.GearMergedInto, result.GearSkipped) = (created, mergedInto, skipped); } @@ -207,17 +212,18 @@ public async Task> Commit(AmazonImport var existingCollectibles = await FindAllAsync(collectibleRepository, ownerId, new CollectibleModel { OwnerId = ownerId, Title = string.Empty }); var (created, mergedInto, skipped) = await CommitAsync( collectibleRepository, existingCollectibles, collectibleItems.Select(ToOwnedItemRequestItem).ToList(), - c => c.Title, c => c.OwnedVersions.Select(v => v.Reference), - i => i.Title, i => i.OwnedVersion.Reference, - item => new CollectibleModel - { - OwnerId = ownerId, - Title = item.Title, - Year = item.Year, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), - OwnedVersions = [item.OwnedVersion] - }, - (collectible, item) => collectible.OwnedVersions.Add(item.OwnedVersion), ownerId); + new OwnedItemImportAdapter( + c => c.Title, c => c.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new CollectibleModel + { + OwnerId = ownerId, + Title = item.Title, + Year = item.Year, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), + OwnedVersions = [item.OwnedVersion] + }, + (collectible, item) => collectible.OwnedVersions.Add(item.OwnedVersion)), ownerId); (result.CollectiblesCreated, result.CollectiblesMergedInto, result.CollectiblesSkipped) = (created, mergedInto, skipped); } @@ -267,17 +273,11 @@ private static Keeptrack.Domain.Models.CopyType ToDomainCopyType(Keeptrack.WebAp IDataRepository repository, List existingItems, List requestItems, - Func getExistingTitle, - Func> getExistingReferences, - Func getItemTitle, - Func getItemReference, - Func createNew, - Action appendOwnedCopy, + OwnedItemImportAdapter adapter, string ownerId) where TModel : class, IHasIdAndOwnerId { - var plan = OwnedItemImportMergeService.ComputeCommitPlan( - existingItems, requestItems, getExistingTitle, getExistingReferences, getItemTitle, getItemReference, createNew, appendOwnedCopy); + var plan = OwnedItemImportMergeService.ComputeCommitPlan(existingItems, requestItems, adapter); foreach (var item in plan.ItemsToCreate) { diff --git a/src/WebApi/Controllers/GenericVideoGameImportController.cs b/src/WebApi/Controllers/GenericVideoGameImportController.cs index c9be506f..5806945b 100644 --- a/src/WebApi/Controllers/GenericVideoGameImportController.cs +++ b/src/WebApi/Controllers/GenericVideoGameImportController.cs @@ -76,17 +76,18 @@ public async Task> Commit(Ge var plan = OwnedItemImportMergeService.ComputeCommitPlan( existingVideoGames, requestItems, - g => g.Title, g => g.Platforms.Select(p => p.Reference), - i => i.Title, i => i.Platform.Reference, - item => new VideoGameModel - { - OwnerId = ownerId, - Title = item.Title, - Year = item.Year, - Notes = GenericVideoGameImportService.BuildProvenanceNotes(item.Platform.Vendor!, item.SourceTitle), - Platforms = [item.Platform] - }, - (game, item) => game.Platforms.Add(item.Platform)); + new OwnedItemImportAdapter( + g => g.Title, g => g.Platforms.Select(p => p.Reference), + i => i.Title, i => i.Platform.Reference, + item => new VideoGameModel + { + OwnerId = ownerId, + Title = item.Title, + Year = item.Year, + Notes = GenericVideoGameImportService.BuildProvenanceNotes(item.Platform.Vendor!, item.SourceTitle), + Platforms = [item.Platform] + }, + (game, item) => game.Platforms.Add(item.Platform))); foreach (var item in plan.ItemsToCreate) { diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index d37048a6..f9bf7072 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -125,14 +125,12 @@ ValidateLifetime = true }; }); -builder.Services.AddAuthorization(options => -{ - options.AddPolicy("AdminOnly", policy => policy.RequireClaim("role", "admin")); +builder.Services.AddAuthorizationBuilder() + .AddPolicy("AdminOnly", policy => policy.RequireClaim("role", "admin")) // members (and admins - a membership must never be *less* than the owner's own account) get the full app. // authenticated users without the role are the free preview tier (movies + TV shows, capped - see DataCrudControllerBase). // Granted the same way as admin: a Firebase custom claim role=member via the Admin SDK (see CONTRIBUTING.md). - options.AddPolicy("MemberOnly", policy => policy.RequireClaim("role", "member", "admin")); -}); + .AddPolicy("MemberOnly", policy => policy.RequireClaim("role", "member", "admin")); if (configuration.CorsAllowedOrigin.Count != 0) { builder.Services.AddCors(options => diff --git a/test/WebApi.IntegrationTests/Resources/AlbumReferenceRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/AlbumReferenceRepositoryTest.cs index 49170c82..e1eff26c 100644 --- a/test/WebApi.IntegrationTests/Resources/AlbumReferenceRepositoryTest.cs +++ b/test/WebApi.IntegrationTests/Resources/AlbumReferenceRepositoryTest.cs @@ -100,7 +100,7 @@ public async Task UpsertAsync_AlwaysIncludesTheCanonicalTitleAndYearInMatchedAli var found = await repository.FindByIdAsync(created.Id!); found.Should().NotBeNull(); - found!.MatchedAliases.Should().ContainSingle(m => m.Title == title.ToLowerInvariant() && m.Year == 2010); + found!.MatchedAliases.Should().ContainSingle(m => string.Equals(m.Title, title, StringComparison.OrdinalIgnoreCase) && m.Year == 2010); } finally { diff --git a/test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs index 1f27d62c..5bec4933 100644 --- a/test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs +++ b/test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs @@ -141,7 +141,7 @@ public async Task UpsertAsync_AlwaysIncludesTheCanonicalTitleAndYearInMatchedAli var found = await repository.FindByIdAsync(created.Id!); found.Should().NotBeNull(); - found!.MatchedAliases.Should().ContainSingle(m => m.Title == title.ToLowerInvariant() && m.Year == 2010); + found!.MatchedAliases.Should().ContainSingle(m => string.Equals(m.Title, title, StringComparison.OrdinalIgnoreCase) && m.Year == 2010); } finally { diff --git a/test/WebApi.UnitTests/ReferenceData/OpenLibraryClientTest.cs b/test/WebApi.UnitTests/ReferenceData/OpenLibraryClientTest.cs index 5e0c6a31..2307f1da 100644 --- a/test/WebApi.UnitTests/ReferenceData/OpenLibraryClientTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/OpenLibraryClientTest.cs @@ -30,7 +30,7 @@ protected override Task SendAsync(HttpRequestMessage reques }); } - private static IBookReferenceClient BuildClient(Func respond) + private static OpenLibraryClient BuildClient(Func respond) { var http = new HttpClient(new StubHttpMessageHandler(respond)) { BaseAddress = new Uri("https://openlibrary.org/") }; return new OpenLibraryClient(http); diff --git a/test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs b/test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs index 47438ae5..d6513a3b 100644 --- a/test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs +++ b/test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs @@ -10,7 +10,7 @@ namespace Keeptrack.WebApi.UnitTests.Services; [Trait("Category", "UnitTests")] public class AmazonOrderPreviewServiceTest { - private static Stream ToStream(string csv) => new MemoryStream(Encoding.UTF8.GetBytes(csv)); + private static MemoryStream ToStream(string csv) => new(Encoding.UTF8.GetBytes(csv)); // The "é" sequence below is deliberately literal (not a copy/paste accident) - it reproduces the real // export's mojibake byte-for-byte, confirmed against the raw bytes of a real Amazon order-history CSV. diff --git a/test/WebApi.UnitTests/Services/GenericVideoGameImportServiceTest.cs b/test/WebApi.UnitTests/Services/GenericVideoGameImportServiceTest.cs index a5645956..bfb1164c 100644 --- a/test/WebApi.UnitTests/Services/GenericVideoGameImportServiceTest.cs +++ b/test/WebApi.UnitTests/Services/GenericVideoGameImportServiceTest.cs @@ -10,7 +10,7 @@ namespace Keeptrack.WebApi.UnitTests.Services; [Trait("Category", "UnitTests")] public class GenericVideoGameImportServiceTest { - private static Stream ToStream(string csv) => new MemoryStream(Encoding.UTF8.GetBytes(csv)); + private static MemoryStream ToStream(string csv) => new(Encoding.UTF8.GetBytes(csv)); private const string Csv = """ Transaction Date,Game Name,Product Name,Platform,Vendor,Transaction Id,Order Id,Final Price (€) diff --git a/test/WebApi.UnitTests/Services/OwnedItemImportMergeServiceTest.cs b/test/WebApi.UnitTests/Services/OwnedItemImportMergeServiceTest.cs index bc8a94ca..fc77a318 100644 --- a/test/WebApi.UnitTests/Services/OwnedItemImportMergeServiceTest.cs +++ b/test/WebApi.UnitTests/Services/OwnedItemImportMergeServiceTest.cs @@ -32,17 +32,18 @@ private static VideoGameModel Game(string title, params VideoGamePlatformModel[] private static ImportCommitPlan ComputeBookPlan(IReadOnlyCollection existing, IReadOnlyList items) => OwnedItemImportMergeService.ComputeCommitPlan( existing, items, - b => b.Title, b => b.OwnedVersions.Select(v => v.Reference), - i => i.Title, i => i.OwnedVersion.Reference, - item => new BookModel - { - OwnerId = OwnerId, - Title = item.Title, - Author = string.Empty, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, item.Isbn), - OwnedVersions = [item.OwnedVersion] - }, - (book, item) => book.OwnedVersions.Add(item.OwnedVersion)); + new OwnedItemImportAdapter( + b => b.Title, b => b.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new BookModel + { + OwnerId = OwnerId, + Title = item.Title, + Author = string.Empty, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, item.Isbn), + OwnedVersions = [item.OwnedVersion] + }, + (book, item) => book.OwnedVersions.Add(item.OwnedVersion))); [Fact] public void ComputeCommitPlan_CreatesANewBook_WhenNoExistingBookMatchesTheTitle() @@ -190,10 +191,11 @@ public void ComputeCommitPlan_WorksUnmodifiedForVideoGames_ViaPlatformsInsteadOf var plan = OwnedItemImportMergeService.ComputeCommitPlan( new List(), [item], - g => g.Title, g => g.Platforms.Select(p => p.Reference), - i => i.Title, i => i.Platform.Reference, - requestItem => new VideoGameModel { OwnerId = OwnerId, Title = requestItem.Title, Platforms = [requestItem.Platform] }, - (game, requestItem) => game.Platforms.Add(requestItem.Platform)); + new OwnedItemImportAdapter( + g => g.Title, g => g.Platforms.Select(p => p.Reference), + i => i.Title, i => i.Platform.Reference, + requestItem => new VideoGameModel { OwnerId = OwnerId, Title = requestItem.Title, Platforms = [requestItem.Platform] }, + (game, requestItem) => game.Platforms.Add(requestItem.Platform))); plan.ItemsToCreate.Should().ContainSingle(); plan.ItemsToCreate[0].Platforms.Should().ContainSingle().Which.Platform.Should().Be("PS3"); From b5c9768f46a093931196f34849a2e1ca04e7b75b Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 27 Jul 2026 20:44:57 +0200 Subject: [PATCH 04/80] Add list/thumbnail view toggle to inventory list pages Every image-bearing list page (Movies, TV Shows, Books, Albums, Video Games, Cars, Houses, Health Profiles, Gear, Collectibles) gains a list/thumbnail toggle in the search bar. The thumbnail view is a responsive poster grid that leverages each type's cover art (portrait posters, square album art, wide game imagery) with title + meta captions. View mode lives in the URL as ?view=grid, following the existing search/sort/filter URL-state convention: bookmarkable and restored on back-nav. It is deliberately kept out of the query signature so switching views never refetches, and unlike a filter it does not reset the page. Playlists (no cover art) stays list-only. MobileScreenshotTest gains a cover-art showcase seed and grid captures at both phone (390x844) and desktop (1280x900) viewports, verified visually. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WvLYeTtX526Y5QZqrrz1o6 --- .../Components/Inventory/InventoryPageBase.cs | 23 +++++ .../Components/Inventory/Pages/Albums.razor | 2 + .../Components/Inventory/Pages/Books.razor | 2 + .../Components/Inventory/Pages/Cars.razor | 2 + .../Inventory/Pages/Collectibles.razor | 2 + .../Components/Inventory/Pages/Gear.razor | 2 + .../Inventory/Pages/HealthProfiles.razor | 2 + .../Components/Inventory/Pages/Houses.razor | 2 + .../Components/Inventory/Pages/Movies.razor | 2 + .../Components/Inventory/Pages/TvShows.razor | 2 + .../Inventory/Pages/VideoGames.razor | 2 + .../Inventory/Shared/InventoryList.razor | 92 +++++++++++++++---- src/BlazorApp/wwwroot/app.css | 88 ++++++++++++++++++ .../Smoke/MobileScreenshotTest.cs | 90 ++++++++++++++++++ 14 files changed, 293 insertions(+), 20 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs index 587fb01e..e059b1c8 100644 --- a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs +++ b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs @@ -46,6 +46,10 @@ public abstract class InventoryPageBase : ComponentBase protected string _sort = ""; + // View mode is deliberately kept out of the query signature below: switching list<->grid is a pure + // display change over the already-loaded page, so flipping it must never trigger a refetch. + protected string _view = ""; + protected int _page = 1; protected int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); @@ -66,6 +70,14 @@ public abstract class InventoryPageBase : ComponentBase [SupplyParameterFromQuery(Name = "sort")] public string? SortQuery { get; set; } + /// + /// The list's display mode ("" = the default detailed list, "grid" = poster thumbnails). Like the + /// other list-state parameters it lives in the URL so it's restored on back-nav and bookmarkable, but + /// unlike them it's a pure display change - see /. + /// + [SupplyParameterFromQuery(Name = "view")] + public string? ViewQuery { get; set; } + protected abstract InventoryApiClientBase Api { get; } /// @@ -98,6 +110,7 @@ protected override async Task OnParametersSetAsync() { _search = SearchQuery ?? ""; _sort = SortQuery ?? DefaultSort; + _view = ViewQuery ?? ""; _page = PageQuery is > 0 ? PageQuery.Value : 1; var query = BuildQuerySignature(); @@ -155,6 +168,16 @@ protected void SetFilter(string name, string? value) => protected void SetSort(string value) => ApplyQueryChanges(new Dictionary { ["sort"] = string.IsNullOrEmpty(value) ? null : value, ["page"] = null }); + /// + /// Switches the list/thumbnail display mode ("" = the default detailed list, kept out of the URL, + /// "grid" = poster thumbnails) through the same URL-navigation path as sort/filters, so it's restored + /// on back-nav and bookmarkable. Unlike a filter it deliberately does not reset the page, and (being + /// absent from the query signature) never refetches - the router-supplied reload in + /// short-circuits and only re-renders with the new view. + /// + protected void SetView(string value) => + ApplyQueryChanges(new Dictionary { ["view"] = string.IsNullOrEmpty(value) ? null : value }); + /// /// Navigates to the current list URL with the given query-parameter changes applied (a null value /// removes the parameter). The actual reload happens in once the diff --git a/src/BlazorApp/Components/Inventory/Pages/Albums.razor b/src/BlazorApp/Components/Inventory/Pages/Albums.razor index 7f849246..33a41623 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Albums.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Albums.razor @@ -6,6 +6,8 @@ Title="Albums" ItemName="Album" Route="@ListRoute" + View="@_view" + OnViewChanged="@SetView" Items="@(Items ?? [])" Loading="@_loading" Loaded="@_loaded" diff --git a/src/BlazorApp/Components/Inventory/Pages/Books.razor b/src/BlazorApp/Components/Inventory/Pages/Books.razor index 6693cd38..18caee9b 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Books.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Books.razor @@ -6,6 +6,8 @@ Title="Books" ItemName="Book" Route="@ListRoute" + View="@_view" + OnViewChanged="@SetView" Items="@(Items ?? [])" Loading="@_loading" Loaded="@_loaded" diff --git a/src/BlazorApp/Components/Inventory/Pages/Cars.razor b/src/BlazorApp/Components/Inventory/Pages/Cars.razor index ae982c61..6665f0f9 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Cars.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Cars.razor @@ -6,6 +6,8 @@ Title="Cars" ItemName="Car" Route="@ListRoute" + View="@_view" + OnViewChanged="@SetView" Items="@(Items ?? [])" Loading="@_loading" Loaded="@_loaded" diff --git a/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor b/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor index b4377ce1..38a15f36 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor @@ -6,6 +6,8 @@ Title="Collectibles" ItemName="Collectible" Route="@ListRoute" + View="@_view" + OnViewChanged="@SetView" Items="@(Items ?? [])" Loading="@_loading" Loaded="@_loaded" diff --git a/src/BlazorApp/Components/Inventory/Pages/Gear.razor b/src/BlazorApp/Components/Inventory/Pages/Gear.razor index e973423b..8831e846 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Gear.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Gear.razor @@ -6,6 +6,8 @@ Title="Gear" ItemName="Gear" Route="@ListRoute" + View="@_view" + OnViewChanged="@SetView" Items="@(Items ?? [])" Loading="@_loading" Loaded="@_loaded" diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor index c2616c43..0be0f22e 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor @@ -6,6 +6,8 @@ Title="Health" ItemName="Profile" Route="@ListRoute" + View="@_view" + OnViewChanged="@SetView" Items="@(Items ?? [])" Loading="@_loading" Loaded="@_loaded" diff --git a/src/BlazorApp/Components/Inventory/Pages/Houses.razor b/src/BlazorApp/Components/Inventory/Pages/Houses.razor index fcdb5fae..24fa3db6 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Houses.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Houses.razor @@ -6,6 +6,8 @@ Title="Houses" ItemName="House" Route="@ListRoute" + View="@_view" + OnViewChanged="@SetView" Items="@(Items ?? [])" Loading="@_loading" Loaded="@_loaded" diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor b/src/BlazorApp/Components/Inventory/Pages/Movies.razor index 10453724..a99885c1 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor @@ -6,6 +6,8 @@ Title="Movies" ItemName="Movie" Route="@ListRoute" + View="@_view" + OnViewChanged="@SetView" Items="@(Items ?? [])" Loading="@_loading" Loaded="@_loaded" diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor index 59e67db7..8dcf210e 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor @@ -6,6 +6,8 @@ Title="TV Shows" ItemName="TV show" Route="@ListRoute" + View="@_view" + OnViewChanged="@SetView" Items="@(Items ?? [])" Loading="@_loading" Loaded="@_loaded" diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor index 09755f3f..d8faab49 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor @@ -6,6 +6,8 @@ Title="Video Games" ItemName="Video game" Route="@ListRoute" + View="@_view" + OnViewChanged="@SetView" Items="@(Items ?? [])" Loading="@_loading" Loaded="@_loaded" diff --git a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor index 3aab9215..f9c53df5 100644 --- a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor +++ b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor @@ -68,6 +68,18 @@ else } + @if (ItemImageUrl is not null) + { + @* Grid view leverages cover art, so the toggle only appears for types that have it. *@ +
+ + +
+ } @if (Filters is not null) {
@Filters
@@ -75,27 +87,63 @@ else
@if (Items.Count > 0) { -
- @foreach (var item in Items) - { - @* The stretched-link title makes the whole row open the detail page (where all editing - happens); the delete button sits above it via z-index. *@ -
- @if (ItemImageUrl is not null) - { - - } -
- @ItemTitle(item) -
@MetaTemplate(item)
+ @if (View == "grid" && ItemImageUrl is not null) + { + @* Poster/thumbnail view: the cover art is the hero, with title + meta captioned underneath. + Same stretched-link-opens-detail / delete-button-above-via-z-index model as the list rows. *@ +
+ @foreach (var item in Items) + { +
+ @* stretched-link spans the whole card (cover + caption); the delete button sits + above it via z-index, same model as the list rows. *@ + +
+ @if (!string.IsNullOrEmpty(ItemImageUrl(item))) + { + + } + else + { + + } + +
+
+
@ItemTitle(item)
+
@MetaTemplate(item)
+
- -
- } -
+ } +
+ } + else + { +
+ @foreach (var item in Items) + { + @* The stretched-link title makes the whole row open the detail page (where all editing + happens); the delete button sits above it via z-index. *@ +
+ @if (ItemImageUrl is not null) + { + + } +
+ @ItemTitle(item) +
@MetaTemplate(item)
+
+ +
+ } +
+ } } else { @@ -223,6 +271,9 @@ else /// Current sort key ("" = the newest-first default) - see . [Parameter] public required string Sort { get; set; } + /// Current display mode ("" = detailed list, "grid" = poster thumbnails). + [Parameter] public string View { get; set; } = ""; + /// Offers the "Rating" sort option - only for types that carry a rating field. [Parameter] public bool HasRatingSort { get; set; } @@ -268,4 +319,5 @@ else [Parameter] public required EventCallback OnDelete { get; set; } [Parameter] public required EventCallback OnSearchChanged { get; set; } [Parameter] public required EventCallback OnSortChanged { get; set; } + [Parameter] public EventCallback OnViewChanged { get; set; } } diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index 399b7aea..1518c2d4 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -421,6 +421,94 @@ a.kt-item-row { color: inherit; text-decoration: none; } .kt-flag-badge.done { background: var(--kt-success-bg); color: var(--kt-success); } .kt-flag-badge.wishlist { background: var(--kt-accent-glow); color: var(--kt-accent); } +/* ── List/thumbnail view toggle ────────────────────────────────────── */ +/* segmented control in the search bar, only rendered for image-bearing types (see InventoryList) */ +.kt-view-toggle { + display: inline-flex; + flex-shrink: 0; + border: 1px solid var(--kt-border); + border-radius: 8px; + overflow: hidden; +} +.kt-view-btn { + border: 0; + background: transparent; + color: var(--kt-text-muted); + padding: 0.35rem 0.6rem; + font-size: 1rem; + line-height: 1; + cursor: pointer; + transition: background var(--kt-transition), color var(--kt-transition); +} +.kt-view-btn + .kt-view-btn { border-left: 1px solid var(--kt-border); } +.kt-view-btn:hover { background: var(--kt-surface-2); color: var(--kt-text); } +.kt-view-btn.active { background: var(--kt-accent); color: #fff; } + +/* ── Inventory thumbnail (grid) view ───────────────────────────────── */ +/* poster cards: the cover art is the hero, title + meta captioned underneath. The whole card opens the + detail page via a stretched-link over the cover; the delete button is raised above it via z-index. */ +.kt-item-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 1.25rem 1rem; + padding-top: 0.5rem; +} +.kt-grid-card { position: relative; min-width: 0; } +.kt-grid-cover { + position: relative; + aspect-ratio: 2 / 3; + border-radius: 10px; + overflow: hidden; + background: var(--kt-surface-3); + display: flex; + align-items: center; + justify-content: center; + color: var(--kt-text-subtle); + font-size: 2rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + transition: transform var(--kt-transition), box-shadow var(--kt-transition); +} +.kt-grid-cover.square { aspect-ratio: 1 / 1; } +.kt-grid-cover.wide { aspect-ratio: 16 / 9; } +.kt-grid-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.kt-grid-card:hover .kt-grid-cover { + transform: translateY(-3px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.45); +} +/* delete floats top-right over the cover, hidden until hover so it never obscures the art at rest */ +.kt-grid-delete { + position: absolute; + top: 0.35rem; + right: 0.35rem; + z-index: 2; + opacity: 0; + background: rgba(0, 0, 0, 0.55); + border-radius: 6px; + transition: opacity var(--kt-transition); +} +.kt-grid-card:hover .kt-grid-delete, +.kt-grid-delete:focus-visible { opacity: 1; } +.kt-grid-caption { padding: 0.5rem 0.15rem 0; } +.kt-grid-title { + font-weight: 500; + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kt-grid-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.2rem 0.45rem; + margin-top: 0.2rem; + font-size: 0.75rem; + color: var(--kt-text-muted); +} +@media (max-width: 575.98px) { + .kt-item-grid { grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); gap: 0.9rem 0.6rem; } +} + /* candidate synopsis in the admin linking queue - TMDB synopses can run very long, pushing the Link action below the fold; clamp to a teaser so the card stays scannable */ .kt-ref-synopsis { diff --git a/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs index e460781f..3fa675da 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs @@ -77,6 +77,13 @@ public async Task CaptureAllPagesAtPhoneViewport() await CaptureAsync(route, name); } + // Thumbnail/grid view at the phone viewport, one per cover-art shape (portrait/square/wide) plus + // movies (no linked art here, so it exercises the placeholder tile). + await CaptureAsync("/books?view=grid", "books-grid"); + await CaptureAsync("/albums?view=grid", "albums-grid"); + await CaptureAsync("/video-games?view=grid", "video-games-grid"); + await CaptureAsync("/movies?view=grid", "movies-grid"); + // The collapsed sidebar opened via the hamburger toggle. await Page.GotoAsync("/"); await Page.WaitForTimeoutAsync(500); @@ -150,6 +157,13 @@ public async Task CaptureAllPagesAtPhoneViewport() await Page.SetViewportSizeAsync(1280, 900); await CaptureFirstDetailAsync("/video-games", "video-game-detail-desktop"); + // The same grid + list views at desktop width, to verify the responsive grid columns and the + // list/thumbnail toggle at both breakpoints. + await CaptureAsync("/books?view=grid", "books-grid-desktop"); + await CaptureAsync("/albums?view=grid", "albums-grid-desktop"); + await CaptureAsync("/video-games?view=grid", "video-games-grid-desktop"); + await CaptureAsync("/books", "books-list-desktop"); + // A dark-theme sample of the densest pages. await Page.EmulateMediaAsync(new PageEmulateMediaOptions { ColorScheme = ColorScheme.Dark }); await CaptureAsync("/movies", "movies-dark"); @@ -305,6 +319,11 @@ private static async Task SeedAsync(HttpClient api, List created) IsWishlisted = true }); + // Thumbnail/grid-view showcase: several items per shape carrying a deterministic CustomImageUrl + // (portrait book covers, square album art, wide game art) so the grid captures below show a full + // wall of cover art without depending on a live provider link winning a race. + await SeedGridShowcaseAsync(api, created); + var carId = await CreateAsync(api, created, "api/cars", new CarDto { Name = "Daily driver", @@ -349,6 +368,77 @@ await CreateAsync(api, created, "api/car-history", }); } + /// + /// Seeds a wall of cover-art items (portrait books, square albums, wide games) via CustomImageUrl so the + /// thumbnail/grid-view captures show a populated grid deterministically, independent of provider linking. + /// + private static async Task SeedGridShowcaseAsync(HttpClient api, List created) + { + var books = new (string Title, string Author, int Year, float Rating, bool Favorite, bool Read)[] + { + ("The Hobbit", "J. R. R. Tolkien", 1937, 5f, true, true), + ("Dune", "Frank Herbert", 1965, 4.5f, true, true), + ("Neuromancer", "William Gibson", 1984, 4f, false, true), + ("The Name of the Wind", "Patrick Rothfuss", 2007, 4.5f, false, false), + ("Project Hail Mary", "Andy Weir", 2021, 5f, true, false), + ("Foundation", "Isaac Asimov", 1951, 4f, false, true) + }; + foreach (var (title, author, year, rating, favorite, read) in books) + { + await CreateAsync(api, created, "api/books", new BookDto + { + Title = title, + Author = author, + Year = year, + Rating = rating, + IsFavorite = favorite, + FirstReadAt = read ? new DateOnly(2024, 1, 1) : null, + CustomImageUrl = $"https://picsum.photos/seed/kt-book-{Uri.EscapeDataString(title)}/400/600" + }); + } + + var albums = new (string Title, string Artist, int Year, float Rating, bool Favorite)[] + { + ("OK Computer", "Radiohead", 1997, 5f, true), + ("Rumours", "Fleetwood Mac", 1977, 4.5f, false), + ("Random Access Memories", "Daft Punk", 2013, 4.5f, true), + ("To Pimp a Butterfly", "Kendrick Lamar", 2015, 5f, true), + ("The Dark Side of the Moon", "Pink Floyd", 1973, 5f, false) + }; + foreach (var (title, artist, year, rating, favorite) in albums) + { + await CreateAsync(api, created, "api/albums", new AlbumDto + { + Title = title, + Artist = artist, + Year = year, + Rating = rating, + IsFavorite = favorite, + CustomImageUrl = $"https://picsum.photos/seed/kt-album-{Uri.EscapeDataString(title)}/400/400" + }); + } + + var games = new (string Title, int Year, float Rating, string State)[] + { + ("Hollow Knight", 2017, 4.5f, "Completed"), + ("Celeste", 2018, 5f, "Completed"), + ("Stardew Valley", 2016, 4.5f, "Current"), + ("Disco Elysium", 2019, 5f, "On-hold"), + ("Elden Ring", 2022, 5f, "Current") + }; + foreach (var (title, year, rating, state) in games) + { + await CreateAsync(api, created, "api/video-games", new VideoGameDto + { + Title = title, + Year = year, + Rating = rating, + Platforms = [new VideoGamePlatformDto { Platform = "PC", CopyType = CopyType.Digital, State = state }], + CustomImageUrl = $"https://picsum.photos/seed/kt-game-{Uri.EscapeDataString(title)}/600/338" + }); + } + } + /// Links an item to its provider's first search candidate via the admin API (best-effort). private static async Task LinkFirstCandidateAsync(HttpClient api, ReferenceItemType type, string title, int year, string? creator) { From 27f1aefa873b917377354cd0aebec25eb9d889a8 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 27 Jul 2026 21:04:20 +0200 Subject: [PATCH 05/80] Add Playwright smoke tests for the three import pages Close the top UI-coverage gap from the new testing assessment: the TV Time, Amazon, and generic video-game import pages had API-level coverage but were never driven through the browser. - Support/*FixtureCsvBuilder + TvTimeImportFixtureZipBuilder: minimal GUID-suffixed in-memory fixtures so every run imports a genuinely new item it then deletes (no dedup-hidden "already imported" rows, no accumulation). - Pages/ImportPage + Amazon/GenericVideoGame page objects, plus a PageBase.OpenImportAsync nav helper. - End2EndFixture.GetItemIdsAsync: one reusable list-query helper all three tests use for API cleanup (books, video games, tv-shows, episodes). - docs/testing-assessment.md: the assessment itself, with these gaps marked closed. All three pass in self-hosted mutating mode. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PxyaxubjRzm2PSLMz7AE24 --- docs/testing-assessment.md | 93 +++++++++++++++++++ .../Hosting/End2EndFixture.cs | 16 ++++ .../Pages/AmazonImportPage.cs | 32 +++++++ .../Pages/GenericVideoGameImportPage.cs | 32 +++++++ .../Pages/ImportPage.cs | 46 +++++++++ .../Pages/PageBase.cs | 2 + .../Smoke/AmazonImportSmokeTest.cs | 51 ++++++++++ .../Smoke/GenericVideoGameImportSmokeTest.cs | 51 ++++++++++ .../Smoke/TvTimeImportSmokeTest.cs | 53 +++++++++++ .../Support/AmazonImportFixtureCsvBuilder.cs | 27 ++++++ ...GenericVideoGameImportFixtureCsvBuilder.cs | 25 +++++ .../Support/TvTimeImportFixtureZipBuilder.cs | 48 ++++++++++ 12 files changed, 476 insertions(+) create mode 100644 docs/testing-assessment.md create mode 100644 test/BlazorApp.PlaywrightTests/Pages/AmazonImportPage.cs create mode 100644 test/BlazorApp.PlaywrightTests/Pages/GenericVideoGameImportPage.cs create mode 100644 test/BlazorApp.PlaywrightTests/Pages/ImportPage.cs create mode 100644 test/BlazorApp.PlaywrightTests/Smoke/AmazonImportSmokeTest.cs create mode 100644 test/BlazorApp.PlaywrightTests/Smoke/GenericVideoGameImportSmokeTest.cs create mode 100644 test/BlazorApp.PlaywrightTests/Smoke/TvTimeImportSmokeTest.cs create mode 100644 test/BlazorApp.PlaywrightTests/Support/AmazonImportFixtureCsvBuilder.cs create mode 100644 test/BlazorApp.PlaywrightTests/Support/GenericVideoGameImportFixtureCsvBuilder.cs create mode 100644 test/BlazorApp.PlaywrightTests/Support/TvTimeImportFixtureZipBuilder.cs diff --git a/docs/testing-assessment.md b/docs/testing-assessment.md new file mode 100644 index 00000000..efa77f18 --- /dev/null +++ b/docs/testing-assessment.md @@ -0,0 +1,93 @@ +# Testing solution assessment + +This document assesses the state of Keeptrack's automated test suite as of 2026-07-27. +It inventories what exists, judges how well each layer is covered, and lists the user walkthroughs that currently have **no** end-to-end coverage. +It complements `docs/code-quality-findings.md` (which tracks specific defects) and `docs/playwright-e2e-tests-plan.md` (the original e2e design), rather than duplicating them. + +## Summary + +The suite is strong and layered, with roughly 376 test methods across four projects. +The API (WebApi) is very well covered at both the unit and integration levels; the Domain services are uniformly unit-tested; the Blazor UI is covered by a Playwright smoke suite that proves every page loads and that each trackable type's core add/edit/delete/link journey works. + +The main gaps are all in the **UI (Playwright) layer**, and all in flows that already have API-level coverage but no browser-level proof: +the three bulk-import pages, the account-management page, the user-preferences UI, the full reference-data admin page, and most of the Quick Add type variants. +The **Blazor unit layer is thin** (9 tests) — most component logic is only exercised transitively through Playwright, which is slower and gated behind `E2E_ENABLED`. + +## Test topology + +| Project | Kind | Runner | Count | Needs | +|---|---|---|---|---| +| `test/WebApi.UnitTests` | Unit (pure logic, mocked repos, stub HTTP handlers) | xunit v3 / Microsoft.Testing.Platform | ~211 | nothing external | +| `test/WebApi.IntegrationTests` | Integration (real Kestrel + real MongoDB + Firebase auth) | xunit v3 | ~121 | MongoDB, Firebase test user | +| `test/BlazorApp.UnitTests` | Unit (component/helper logic) | xunit v3 | ~9 | nothing external | +| `test/BlazorApp.PlaywrightTests` | End-to-end (real browser, both hosts in-process) | Playwright + xunit v3 | ~35 | `E2E_ENABLED=true`, browsers, provider keys | +| `test/Testing.Shared` | Shared hosting/auth infrastructure (not a test project) | — | — | — | + +Coverage is collected in CI via `dotnet test --coverage --coverage-output-format cobertura` and reported to SonarCloud (`.github/workflows/ci.yaml`). +There is no enforced per-project coverage threshold gate in the pipeline; SonarCloud tracks the trend but a drop does not by itself fail the build. + +## What is well covered + +**Domain services — uniformly unit-tested.** +Every service in `src/Domain/Services` has a matching test: +`WatchNextService`, `WishlistService`, `CarMetricsService`, `HouseMetricsService`, `HealthMetricsService`, `AmazonOrderPreviewService`, `AmazonImportMergeService`, `OwnedItemImportMergeService`, `GenericVideoGameImportService`. +These are pure computation classes, and the tests exercise the tricky branches (next-episode confirmation, the reimbursement balance tolerance, the import merge/dedup engine, the PSN bundled-transaction disambiguation). + +**Reference-data enrichment — deep unit and integration coverage.** +`ReferenceEnrichmentServiceTest`, `ReferenceSyncServiceTest`, `ExternalProviderResilienceTest`, and per-provider client tests (`GoogleBooksClientTest`, `OpenLibraryClientTest`, `BnfClientTest`) cover parsing and resolution against stubbed HTTP. +Integration tests (`TvShowReferenceRepositoryTest`, `BookReferenceRepositoryTest`, `AlbumReferenceRepositoryTest`, `VideoGameReferenceRepositoryTest`, `PersonReferenceRepositoryTest`, `TvShowReferenceLinkingTest`, `RefreshReferenceResourceTest`, `UnlinkReferenceResourceTest`, `BookProviderSearchAndLinkResourceTest`, `BookUnresolvedQueueTest`, `ReferenceDataExportImportTest`, `ReferenceDataAdminResourceTest`) prove the real MongoDB serialization/alias/dedup behavior that a mock cannot. + +**TV Time import — parser-level and end-to-end (API).** +Every CSV parser under `Import/Parsers` has a dedicated test, plus `TvTimeImportServiceIdempotencyTest` (re-import safety) and `TvTimeImportResourceTest` (full API upload/poll cycle). + +**API CRUD — one resource test per type.** +Each trackable type has a full create/read/update/delete integration test (`BookResourceTest`, `MovieResourceTest`, `AlbumResourceTest`, `TvShowResourceTest`, `VideoGameResourceTest`, `CarResourceTest`/`CarHistoryResourceTest`, `HouseResourceTest`/`HouseHistoryResourceTest`, `HealthProfileResourceTest`/`HealthRecordResourceTest`, `PlaylistResourceTest`/`SongResourceTest`, `CollectibleResourceTest`, `GearResourceTest`). +Cross-cutting API concerns are covered too: `FreeTierTest` (quota + policy reflection guard), `ApiExceptionFilterAttributeTest`, `MongoDbHealthCheckTest`, `JobStoreTest`, `LeaseRepositoryTest`, `BackgroundJobRepositoryTest`, `ListSortingRepositoryTest`, `StatsResourceTest`, `SystemStatusResourceTest`, `WishlistShareResourceTest`, `UserPreferencesResourceTest`. + +**UI happy paths — one Playwright smoke test per type.** +`ListStateSmokeTest` (search/filter/pagination URL persistence and back-navigation), `OwnershipSmokeTest`, `VideoGamePlatformSmokeTest`, `SharedWishlistSmokeTest` (genuinely anonymous), `WatchNextSmokeTest`, `AuthSmokeTest` (login redirect + logout), and per-type add/link/delete flows all exist. +`MobileScreenshotTest` provides an assertion-free phone-viewport visual-review harness. + +## Coverage gaps: walkthroughs with no end-to-end (browser) test + +All of these have API/integration or unit coverage but are **never driven through the actual UI**, so a broken form binding, missing DI registration, or Blazor render bug would not be caught by the automated suite (exactly the class of bug the memory note "build/tests don't catch DI/UI bugs here" warns about). + +| # | Walkthrough | UI page | API/unit coverage today | E2E gap | +|---|---|---|---|---| +| 1 | ~~**TV Time import**~~ (upload zip → poll job → see results) | `Import/ImportPage.razor` | `TvTimeImportResourceTest` + all parsers | **Closed** — `TvTimeImportSmokeTest` drives the upload/progress/result UI | +| 2 | ~~**Amazon order import**~~ (upload CSV → preview → commit) | `Import/AmazonImportPage.razor` | `AmazonImportResourceTest`, service tests | **Closed** — `AmazonImportSmokeTest` drives upload/preview/commit/result | +| 3 | ~~**Generic video-game import**~~ (PSN-style CSV → preview → commit) | `Import/GenericVideoGameImportPage.razor` | `GenericVideoGameImportResourceTest`, service test | **Closed** — `GenericVideoGameImportSmokeTest` drives upload/preview/commit/result | +| 4 | **Account management** (view profile, sign-out affordances) | `Account/Pages/Manage.razor` | none | No test at any level | +| 5 | **User preferences** (edit and persist settings) | wherever preferences are edited | `UserPreferencesResourceTest` | No E2E for the settings UI | +| 6 | **Reference-data admin page** (search, unresolved queue, provider picker, link, sync-now, export/import) | `ReferenceDataAdmin/ReferenceDataAdminPage.razor` | `ReferenceDataAdminResourceTest`, `BookUnresolvedQueueTest`, `ReferenceDataExportImportTest` | Only the `InlineReferenceLinker` widget is exercised via detail-page smoke tests; the admin page itself is never opened | +| 7 | **Quick Add — most types** | `QuickAdd/QuickAddPage.razor` | per-type resource tests | Only `movie` and `car` variants are E2E'd; `tv-show`, `book`, `album`, `video-game`, `house`, `health` Quick Add flows are not | +| 8 | **Playlist song editing** (add/edit/remove songs within a playlist) | `Inventory/Pages/PlaylistDetail.razor` | `SongResourceTest`, `PlaylistResourceTest` | `PlaylistSmokeTest` covers add/delete of the playlist itself, not the embedded song-editing UI | +| 9 | **Car/House/Health history rows** (add/edit history entries and see computed metrics/charts) | `CarDetail`, `HouseDetail`, `HealthProfileDetail` | metrics service unit tests + history resource tests | Smoke tests create the parent and (for Health) one record; the metrics charts and multi-row history editing are not asserted in the browser | +| 10 | **Error / NotFound pages** | `Pages/Error.razor`, `Pages/NotFound.razor` | none | No test asserts the error or 404 experience | + +## Secondary observations + +- **Blazor unit layer is under-invested.** + Only `ReturnUrlResolverTest` and `DateOnlyInputTest` exist. + Component logic that is pure enough to test cheaply (form state transitions, the owned-versions draft/save flow, filter URL round-tripping) is currently only proven through Playwright, which is slower, gated, and heavier to debug. + Moving some of that down to bUnit-style component tests would tighten the feedback loop. + +- **E2E provider dependence.** + Several smoke tests (Movie/TvShow/VideoGame/Album linking) hit real TMDB/RAWG/Discogs and hard-require API keys; only the Google Books path uses a deterministic synthetic seed. + This is a deliberate trade-off (documented in the plan), but it means those tests cannot run in a keyless CI job and can flake on provider latency. + +- **No enforced coverage gate.** + Coverage is measured and sent to SonarCloud but nothing fails the build on a regression. + If the quality bar warrants it, a minimum-coverage gate on the WebApi projects would make the "every non-trivial piece of logic needs a test" rule mechanically enforced rather than review-dependent. + +- **Import parsers are the best-tested area; import *UI* is the least-tested.** + The asymmetry is worth closing since imports are exactly the flows where a user uploads a large real file once and cannot easily retry — a broken progress/preview screen is high-impact and currently invisible to CI. + +## Recommendations (priority order) + +1. ~~Add a Playwright smoke test for each of the three import pages~~ — **Done and verified** (2026-07-27): `AmazonImportSmokeTest`, `GenericVideoGameImportSmokeTest`, `TvTimeImportSmokeTest`, each uploading a small GUID-suffixed in-memory fixture (`Support/*FixtureCsvBuilder`/`*FixtureZipBuilder`) through the real UI and cleaning up via the API. All three pass in self-hosted mutating mode (`E2E_ENABLED=true`, Firebase account as `E2E_USERNAME`, provider keys from `appsettings.Development.json`). +2. Add an E2E test that opens `ReferenceDataAdminPage` end-to-end (search → link → sync-now → export/import round-trip through the UI). +3. Extend `QuickAddSmokeTest` to cover the remaining Quick Add types, or parameterize it the way `ListPage` already parameterizes the list pages. +4. Add a minimal smoke test for `Manage.razor` and the user-preferences UI. +5. Introduce a small bUnit component-test project to move fast-feedback UI logic off the gated Playwright suite. +6. Consider a coverage-threshold gate on the WebApi projects in CI. diff --git a/test/BlazorApp.PlaywrightTests/Hosting/End2EndFixture.cs b/test/BlazorApp.PlaywrightTests/Hosting/End2EndFixture.cs index fdcec3d3..7b570f98 100644 --- a/test/BlazorApp.PlaywrightTests/Hosting/End2EndFixture.cs +++ b/test/BlazorApp.PlaywrightTests/Hosting/End2EndFixture.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; using FirebaseAdmin.Auth; @@ -227,6 +228,21 @@ public async Task DeleteItemAsync(string resourcePathAndId) } } + /// + /// Reads the ids of the items returned by a paged list query (e.g. "/api/books?search=..." or "/api/episodes?TvShowId=..."), + /// so an import smoke test can find whatever the commit created and delete it via . + /// Every list endpoint shares the one PagedResult shape, so this single helper serves all of them rather than a per-type variant. + /// + public async Task> GetItemIdsAsync(string listQueryUrl) + { + var page = await ApiHttpClient.GetFromJsonAsync(listQueryUrl); + return page?.Items.Where(item => item.Id is not null).Select(item => item.Id!).ToList() ?? []; + } + + private sealed record PagedItemIds(List Items); + + private sealed record ItemId(string? Id); + public async ValueTask DisposeAsync() { _apiHttpClient?.Dispose(); diff --git a/test/BlazorApp.PlaywrightTests/Pages/AmazonImportPage.cs b/test/BlazorApp.PlaywrightTests/Pages/AmazonImportPage.cs new file mode 100644 index 00000000..c77ee757 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Pages/AmazonImportPage.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using Microsoft.Playwright; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; + +/// +/// The /import/amazon sub-page: upload an order-history CSV, review the parsed rows, then commit the selected ones. +/// +public class AmazonImportPage(IPage page) : PageBase(page) +{ + public override async Task WaitForReadyAsync() + { + await base.WaitForReadyAsync(); + await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new PageGetByRoleOptions { Name = "Amazon", Level = 1 })).ToBeVisibleAsync(); + } + + private ILocator FileInput => Page.Locator(".kt-dropzone input[type='file']"); + + /// + /// Only rendered once a preview exists; its label carries the selected-row count (e.g. "Import selected (1)"), + /// so asserting on its text proves the upload parsed and pre-selected the expected rows. + /// + public ILocator CommitButton => Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Import selected" }); + + /// The result banner rendered after a successful commit. + public ILocator ResultAlert => Page.Locator(".alert-info"); + + public async Task UploadAsync(byte[] csv, string fileName) + => await FileInput.SetInputFilesAsync(new FilePayload { Name = fileName, MimeType = "text/csv", Buffer = csv }); + + public async Task CommitSelectedAsync() => await CommitButton.ClickAsync(); +} diff --git a/test/BlazorApp.PlaywrightTests/Pages/GenericVideoGameImportPage.cs b/test/BlazorApp.PlaywrightTests/Pages/GenericVideoGameImportPage.cs new file mode 100644 index 00000000..ad6e3c94 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Pages/GenericVideoGameImportPage.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using Microsoft.Playwright; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; + +/// +/// The /import/video-games sub-page: upload a video-game transaction-history CSV, review the parsed rows, then commit the selected ones. +/// +public class GenericVideoGameImportPage(IPage page) : PageBase(page) +{ + public override async Task WaitForReadyAsync() + { + await base.WaitForReadyAsync(); + await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new PageGetByRoleOptions { Name = "video game transactions", Level = 1 })).ToBeVisibleAsync(); + } + + private ILocator FileInput => Page.Locator(".kt-dropzone input[type='file']"); + + /// + /// Only rendered once a preview exists; its label carries the selected-row count (e.g. "Import selected (1)"), + /// so asserting on its text proves the upload parsed and pre-selected the expected rows. + /// + public ILocator CommitButton => Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Import selected" }); + + /// The result banner rendered after a successful commit. + public ILocator ResultAlert => Page.Locator(".alert-info"); + + public async Task UploadAsync(byte[] csv, string fileName) + => await FileInput.SetInputFilesAsync(new FilePayload { Name = fileName, MimeType = "text/csv", Buffer = csv }); + + public async Task CommitSelectedAsync() => await CommitButton.ClickAsync(); +} diff --git a/test/BlazorApp.PlaywrightTests/Pages/ImportPage.cs b/test/BlazorApp.PlaywrightTests/Pages/ImportPage.cs new file mode 100644 index 00000000..e8ea5609 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Pages/ImportPage.cs @@ -0,0 +1,46 @@ +using System.Threading.Tasks; +using Microsoft.Playwright; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; + +/// +/// The /import landing page. The TV Time zip upload (and the car/health spreadsheet uploads) live directly on it, +/// while the Amazon and generic-video-game importers are separate sub-pages reached via the links below. +/// +public class ImportPage(IPage page) : PageBase(page) +{ + public override async Task WaitForReadyAsync() + { + await base.WaitForReadyAsync(); + // Unique to the landing page (the Amazon/video-game sub-pages don't carry this heading). + await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new PageGetByRoleOptions { Name = "TV Time", Level = 1 })).ToBeVisibleAsync(); + } + + /// + /// The TV Time upload accepts .zip; the car/health dropzones on the same page accept .xlsx, + /// so the accept attribute is what disambiguates the three file inputs. + /// + private ILocator TvTimeFileInput => Page.Locator("input[type='file'][accept='.zip']"); + + /// The single result banner rendered once an upload completes (only the TV Time flow is triggered here). + public ILocator ResultAlert => Page.Locator(".alert-info"); + + public async Task UploadTvTimeExportAsync(byte[] zip, string fileName) + => await TvTimeFileInput.SetInputFilesAsync(new FilePayload { Name = fileName, MimeType = "application/zip", Buffer = zip }); + + public async Task GoToAmazonImportAsync() + { + await Page.GetByRole(AriaRole.Link, new PageGetByRoleOptions { Name = "Import from Amazon" }).ClickAsync(); + var next = new AmazonImportPage(Page); + await next.WaitForReadyAsync(); + return next; + } + + public async Task GoToVideoGameImportAsync() + { + await Page.GetByRole(AriaRole.Link, new PageGetByRoleOptions { Name = "Import video game transactions" }).ClickAsync(); + var next = new GenericVideoGameImportPage(Page); + await next.WaitForReadyAsync(); + return next; + } +} diff --git a/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs b/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs index 9526aa7f..9393c1ee 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs @@ -71,6 +71,8 @@ private async Task NavigateAsync(string linkName, TPage next) wher public Task OpenQuickAddAsync() => NavigateAsync("Quick add", new QuickAddPage(Page)); + public Task OpenImportAsync() => NavigateAsync("Import", new ImportPage(Page)); + public Task OpenWatchNextAsync() => NavigateAsync("Watch next", new WatchNextPage(Page)); public Task OpenWishlistAsync() => NavigateAsync("Wishlist", new WishlistPage(Page)); diff --git a/test/BlazorApp.PlaywrightTests/Smoke/AmazonImportSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/AmazonImportSmokeTest.cs new file mode 100644 index 00000000..39e29a41 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Smoke/AmazonImportSmokeTest.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; +using Keeptrack.BlazorApp.PlaywrightTests.Hosting; +using Keeptrack.BlazorApp.PlaywrightTests.Pages; +using Keeptrack.BlazorApp.PlaywrightTests.Support; +using Microsoft.Playwright; +using Xunit; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; + +/// +/// Drives the Amazon order-history import UI end to end (upload → preview → commit → result), +/// the browser-level coverage the API-only AmazonImportResourceTest can't provide. +/// Uses a GUID-suffixed book title (and a fresh order id inside the fixture) so every run imports a genuinely new book it then deletes. +/// +[Trait("Category", "E2eTests")] +[Trait("Mode", "Mutating")] +public class AmazonImportSmokeTest(End2EndFixture fixture) : SmokeTestBase(fixture) +{ + [Fact] + public async Task UploadPreviewAndCommit_ImportsABookFromAnAmazonOrderCsv() + { + SkipIfReadOnly(); + + var title = $"E2e Amazon Book {Guid.NewGuid():N}"; + var csv = AmazonImportFixtureCsvBuilder.Build(title); + + try + { + var home = await new HomePage(Page).OpenAsync(); + var import = await home.OpenImportAsync(); + var amazon = await import.GoToAmazonImportAsync(); + + await amazon.UploadAsync(csv, "amazon-orders.csv"); + + // The single ISBN-bearing row is auto-selected as a Book, so the commit button reports exactly one selected row. + await Assertions.Expect(amazon.CommitButton).ToContainTextAsync("(1)"); + await amazon.CommitSelectedAsync(); + + await Assertions.Expect(amazon.ResultAlert).ToContainTextAsync("Books:"); + await Assertions.Expect(amazon.ResultAlert).ToContainTextAsync("1 created"); + } + finally + { + foreach (var id in await Fixture.GetItemIdsAsync($"/api/books?search={Uri.EscapeDataString(title)}")) + { + await Fixture.DeleteItemAsync($"/api/books/{id}"); + } + } + } +} diff --git a/test/BlazorApp.PlaywrightTests/Smoke/GenericVideoGameImportSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/GenericVideoGameImportSmokeTest.cs new file mode 100644 index 00000000..b9df4c1c --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Smoke/GenericVideoGameImportSmokeTest.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; +using Keeptrack.BlazorApp.PlaywrightTests.Hosting; +using Keeptrack.BlazorApp.PlaywrightTests.Pages; +using Keeptrack.BlazorApp.PlaywrightTests.Support; +using Microsoft.Playwright; +using Xunit; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; + +/// +/// Drives the generic video-game (PSN-style) transaction import UI end to end (upload → preview → commit → result), +/// the browser-level coverage the API-only GenericVideoGameImportResourceTest can't provide. +/// Uses a GUID-suffixed title (and fresh transaction/order ids inside the fixture) so every run imports a genuinely new game it then deletes. +/// +[Trait("Category", "E2eTests")] +[Trait("Mode", "Mutating")] +public class GenericVideoGameImportSmokeTest(End2EndFixture fixture) : SmokeTestBase(fixture) +{ + [Fact] + public async Task UploadPreviewAndCommit_ImportsAVideoGameFromATransactionCsv() + { + SkipIfReadOnly(); + + var title = $"E2e VideoGame Import {Guid.NewGuid():N}"; + var csv = GenericVideoGameImportFixtureCsvBuilder.Build(title); + + try + { + var home = await new HomePage(Page).OpenAsync(); + var import = await home.OpenImportAsync(); + var videoGames = await import.GoToVideoGameImportAsync(); + + await videoGames.UploadAsync(csv, "video-game-transactions.csv"); + + // The single row is auto-selected with its platform pre-filled from the CSV, so exactly one row is ready to commit. + await Assertions.Expect(videoGames.CommitButton).ToContainTextAsync("(1)"); + await videoGames.CommitSelectedAsync(); + + await Assertions.Expect(videoGames.ResultAlert).ToContainTextAsync("Video games:"); + await Assertions.Expect(videoGames.ResultAlert).ToContainTextAsync("1 created"); + } + finally + { + foreach (var id in await Fixture.GetItemIdsAsync($"/api/video-games?search={Uri.EscapeDataString(title)}")) + { + await Fixture.DeleteItemAsync($"/api/video-games/{id}"); + } + } + } +} diff --git a/test/BlazorApp.PlaywrightTests/Smoke/TvTimeImportSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/TvTimeImportSmokeTest.cs new file mode 100644 index 00000000..67685ffc --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Smoke/TvTimeImportSmokeTest.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading.Tasks; +using Keeptrack.BlazorApp.PlaywrightTests.Hosting; +using Keeptrack.BlazorApp.PlaywrightTests.Pages; +using Keeptrack.BlazorApp.PlaywrightTests.Support; +using Microsoft.Playwright; +using Xunit; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; + +/// +/// Drives the TV Time GDPR-export import UI end to end (upload the zip → the background job's progress bar → the result banner), +/// the browser-level coverage the API-only TvTimeImportResourceTest can't provide. +/// Uses a GUID-suffixed show title (and a fresh TV Time show id inside the fixture) so every run imports a genuinely new show it then deletes. +/// +[Trait("Category", "E2eTests")] +[Trait("Mode", "Mutating")] +public class TvTimeImportSmokeTest(End2EndFixture fixture) : SmokeTestBase(fixture) +{ + [Fact] + public async Task UploadAndPoll_ImportsAShowFromATvTimeExport() + { + SkipIfReadOnly(); + + var showTitle = $"E2e TvTime Show {Guid.NewGuid():N}"; + var zip = TvTimeImportFixtureZipBuilder.Build(showTitle); + + try + { + var home = await new HomePage(Page).OpenAsync(); + var import = await home.OpenImportAsync(); + + await import.UploadTvTimeExportAsync(zip, "tv-time-export.zip"); + + // The import runs as a polled background job, so the result banner can take a few seconds to appear. + await Assertions.Expect(import.ResultAlert).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 30000 }); + await Assertions.Expect(import.ResultAlert).ToContainTextAsync("Shows:"); + await Assertions.Expect(import.ResultAlert).ToContainTextAsync("1 created"); + } + finally + { + foreach (var showId in await Fixture.GetItemIdsAsync($"/api/tv-shows?search={Uri.EscapeDataString(showTitle)}")) + { + foreach (var episodeId in await Fixture.GetItemIdsAsync($"/api/episodes?TvShowId={showId}")) + { + await Fixture.DeleteItemAsync($"/api/episodes/{episodeId}"); + } + + await Fixture.DeleteItemAsync($"/api/tv-shows/{showId}"); + } + } + } +} diff --git a/test/BlazorApp.PlaywrightTests/Support/AmazonImportFixtureCsvBuilder.cs b/test/BlazorApp.PlaywrightTests/Support/AmazonImportFixtureCsvBuilder.cs new file mode 100644 index 00000000..6a678e00 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Support/AmazonImportFixtureCsvBuilder.cs @@ -0,0 +1,27 @@ +using System; +using System.Text; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Support; + +/// +/// Builds a minimal one-row Amazon order-history CSV for the import smoke test. +/// The row carries a real, checksum-valid ISBN-10 so it trips the "looks like a book" heuristic and is auto-selected as a Book, +/// and a per-call unique title plus a fresh order id so every run imports a genuinely new book the test then deletes - +/// never a dedup-hidden "already imported" row from a previous run. +/// Never use a real personal export as a fixture (same rule as the integration suite's own builders). +/// +internal static class AmazonImportFixtureCsvBuilder +{ + private const string BookIsbn = "0552177571"; + + public static byte[] Build(string bookTitle) + { + var orderId = $"999-{Guid.NewGuid():N}"; + var csv = $""" + ASIN,Order Date,Order ID,Product Name,Product Condition,Total Amount,Website + {BookIsbn},2024-01-24T09:01:58Z,{orderId},{bookTitle},New,10.49,Amazon.fr + + """; + return Encoding.UTF8.GetBytes(csv); + } +} diff --git a/test/BlazorApp.PlaywrightTests/Support/GenericVideoGameImportFixtureCsvBuilder.cs b/test/BlazorApp.PlaywrightTests/Support/GenericVideoGameImportFixtureCsvBuilder.cs new file mode 100644 index 00000000..a7b31818 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Support/GenericVideoGameImportFixtureCsvBuilder.cs @@ -0,0 +1,25 @@ +using System; +using System.Text; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Support; + +/// +/// Builds a minimal one-row video-game transaction-history CSV (PSN-style export shape) for the import smoke test. +/// The single row carries a per-call unique title plus a fresh transaction/order id so every run imports a genuinely new game the test then deletes, +/// and a real platform value (PS4) so the preview's platform column is pre-filled and the commit button enables without further input. +/// Never use a real personal export as a fixture (same rule as the integration suite's own builders). +/// +internal static class GenericVideoGameImportFixtureCsvBuilder +{ + public static byte[] Build(string gameTitle) + { + var transactionId = Guid.NewGuid().ToString("N"); + var orderId = Guid.NewGuid().ToString("N"); + var csv = $""" + Transaction Date,Game Name,Product Name,Platform,Vendor,Transaction Id,Order Id,Final Price (€) + 2019-08-02,{gameTitle},{gameTitle},PS4,PlayStation Store,{transactionId},{orderId},14.99 + + """; + return Encoding.UTF8.GetBytes(csv); + } +} diff --git a/test/BlazorApp.PlaywrightTests/Support/TvTimeImportFixtureZipBuilder.cs b/test/BlazorApp.PlaywrightTests/Support/TvTimeImportFixtureZipBuilder.cs new file mode 100644 index 00000000..5a0d89a8 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Support/TvTimeImportFixtureZipBuilder.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Support; + +/// +/// Builds a minimal synthetic TV Time GDPR export zip for the import smoke test: one followed show and one seen episode. +/// The importer tolerates every other export file being absent (ReadCsvEntry returns null for a missing entry), so a two-file zip is enough to create one show + one episode. +/// A per-call unique show title and TV Time show id keep every run's imported show distinct (the id drives the importer's idempotency), so the test always creates a fresh show it then deletes. +/// Never use a real personal export as a fixture (same rule as the integration suite's own builder). +/// +internal static class TvTimeImportFixtureZipBuilder +{ + public static byte[] Build(string showTitle) + { + var showId = Random.Shared.Next(900_000, 999_999); + + var followedShows = $""" + updated_at,active,notification_type,folder_id,archived,notification_offset,user_id,tv_show_id,tv_show_name,created_at,diffusion + 2020-01-01 00:00:00,1,2,,0,1440,999,{showId},{showTitle},2020-01-01 00:00:00,original + + """; + + var seenEpisodes = $""" + updated_at,tv_show_name,episode_season_number,episode_number,user_id,episode_id,source,created_at + 2020-01-02 00:00:00,{showTitle},1,1,999,1,episode-detail,2020-01-02 00:00:00 + + """; + + using var zipStream = new MemoryStream(); + using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) + { + WriteEntry(archive, "followed_tv_show.csv", followedShows); + WriteEntry(archive, "seen_episode_source.csv", seenEpisodes); + } + + return zipStream.ToArray(); + } + + private static void WriteEntry(ZipArchive archive, string fileName, string content) + { + var entry = archive.CreateEntry(fileName); + using var writer = new StreamWriter(entry.Open(), Encoding.UTF8); + writer.Write(content); + } +} From 49b261a6cb33f29adc9a5b1fd5a667aa98e8ea1b Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 27 Jul 2026 21:08:23 +0200 Subject: [PATCH 06/80] Add Playwright smoke test for the reference-data admin page Covers the admin page's provider-free, deterministic surfaces (recommendation #2 from the testing assessment): page load via the Admin nav link, the System status panel resolving, unresolved-queue type switching, and the export -> import round-trip (idempotent upsert-by-id, so it changes no data). The provider search/link flow is deliberately left to the per-type detail-page smoke tests (same endpoints, real providers), and a full sync-now poll is left out as known-flaky on provider latency. Passes in self-hosted mutating mode. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PxyaxubjRzm2PSLMz7AE24 --- docs/testing-assessment.md | 4 +- .../Pages/PageBase.cs | 2 + .../Pages/ReferenceDataAdminPage.cs | 51 +++++++++++++++++++ .../Smoke/ReferenceDataAdminSmokeTest.cs | 49 ++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 test/BlazorApp.PlaywrightTests/Pages/ReferenceDataAdminPage.cs create mode 100644 test/BlazorApp.PlaywrightTests/Smoke/ReferenceDataAdminSmokeTest.cs diff --git a/docs/testing-assessment.md b/docs/testing-assessment.md index efa77f18..4b7ab279 100644 --- a/docs/testing-assessment.md +++ b/docs/testing-assessment.md @@ -59,7 +59,7 @@ All of these have API/integration or unit coverage but are **never driven throug | 3 | ~~**Generic video-game import**~~ (PSN-style CSV → preview → commit) | `Import/GenericVideoGameImportPage.razor` | `GenericVideoGameImportResourceTest`, service test | **Closed** — `GenericVideoGameImportSmokeTest` drives upload/preview/commit/result | | 4 | **Account management** (view profile, sign-out affordances) | `Account/Pages/Manage.razor` | none | No test at any level | | 5 | **User preferences** (edit and persist settings) | wherever preferences are edited | `UserPreferencesResourceTest` | No E2E for the settings UI | -| 6 | **Reference-data admin page** (search, unresolved queue, provider picker, link, sync-now, export/import) | `ReferenceDataAdmin/ReferenceDataAdminPage.razor` | `ReferenceDataAdminResourceTest`, `BookUnresolvedQueueTest`, `ReferenceDataExportImportTest` | Only the `InlineReferenceLinker` widget is exercised via detail-page smoke tests; the admin page itself is never opened | +| 6 | **Reference-data admin page** (search, unresolved queue, provider picker, link, sync-now, export/import) | `ReferenceDataAdmin/ReferenceDataAdminPage.razor` | `ReferenceDataAdminResourceTest`, `BookUnresolvedQueueTest`, `ReferenceDataExportImportTest` | **Partially closed** — `ReferenceDataAdminSmokeTest` drives page load, the System panel, unresolved type-switching, and the export→import round-trip. The provider search/link flow is left to the per-type detail-page smoke tests (same endpoints, real providers), and a full sync-now poll is deliberately not driven (flakes on provider latency). | | 7 | **Quick Add — most types** | `QuickAdd/QuickAddPage.razor` | per-type resource tests | Only `movie` and `car` variants are E2E'd; `tv-show`, `book`, `album`, `video-game`, `house`, `health` Quick Add flows are not | | 8 | **Playlist song editing** (add/edit/remove songs within a playlist) | `Inventory/Pages/PlaylistDetail.razor` | `SongResourceTest`, `PlaylistResourceTest` | `PlaylistSmokeTest` covers add/delete of the playlist itself, not the embedded song-editing UI | | 9 | **Car/House/Health history rows** (add/edit history entries and see computed metrics/charts) | `CarDetail`, `HouseDetail`, `HealthProfileDetail` | metrics service unit tests + history resource tests | Smoke tests create the parent and (for Health) one record; the metrics charts and multi-row history editing are not asserted in the browser | @@ -86,7 +86,7 @@ All of these have API/integration or unit coverage but are **never driven throug ## Recommendations (priority order) 1. ~~Add a Playwright smoke test for each of the three import pages~~ — **Done and verified** (2026-07-27): `AmazonImportSmokeTest`, `GenericVideoGameImportSmokeTest`, `TvTimeImportSmokeTest`, each uploading a small GUID-suffixed in-memory fixture (`Support/*FixtureCsvBuilder`/`*FixtureZipBuilder`) through the real UI and cleaning up via the API. All three pass in self-hosted mutating mode (`E2E_ENABLED=true`, Firebase account as `E2E_USERNAME`, provider keys from `appsettings.Development.json`). -2. Add an E2E test that opens `ReferenceDataAdminPage` end-to-end (search → link → sync-now → export/import round-trip through the UI). +2. ~~Add an E2E test that opens `ReferenceDataAdminPage` end-to-end~~ — **Done and verified** (2026-07-27): `ReferenceDataAdminSmokeTest` covers page load + System panel + unresolved type-switch + export→import round-trip (the provider-free, deterministic surfaces). Provider search/link stays with the detail-page smoke tests; sync-now polling is left out as known-flaky. 3. Extend `QuickAddSmokeTest` to cover the remaining Quick Add types, or parameterize it the way `ListPage` already parameterizes the list pages. 4. Add a minimal smoke test for `Manage.razor` and the user-preferences UI. 5. Introduce a small bUnit component-test project to move fast-feedback UI logic off the gated Playwright suite. diff --git a/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs b/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs index 9393c1ee..f4897779 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs @@ -73,6 +73,8 @@ private async Task NavigateAsync(string linkName, TPage next) wher public Task OpenImportAsync() => NavigateAsync("Import", new ImportPage(Page)); + public Task OpenAdminAsync() => NavigateAsync("Admin", new ReferenceDataAdminPage(Page)); + public Task OpenWatchNextAsync() => NavigateAsync("Watch next", new WatchNextPage(Page)); public Task OpenWishlistAsync() => NavigateAsync("Wishlist", new WishlistPage(Page)); diff --git a/test/BlazorApp.PlaywrightTests/Pages/ReferenceDataAdminPage.cs b/test/BlazorApp.PlaywrightTests/Pages/ReferenceDataAdminPage.cs new file mode 100644 index 00000000..8b38215d --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Pages/ReferenceDataAdminPage.cs @@ -0,0 +1,51 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Playwright; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; + +/// +/// The admin-only /admin/reference-data page: the unresolved-title queue (per reference type), the sync-now control, +/// the export/import round-trip, and the System status panel. +/// The smoke test deliberately drives only the provider-free, deterministic surfaces here - +/// the provider search/link flow is already covered by the per-type detail-page smoke tests, and a full sync-now poll is known to flake on provider latency. +/// +public class ReferenceDataAdminPage(IPage page) : PageBase(page) +{ + public override async Task WaitForReadyAsync() + { + await base.WaitForReadyAsync(); + await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new PageGetByRoleOptions { Name = "Reference data", Level = 1 })).ToBeVisibleAsync(); + } + + /// A System-panel row that only renders once the (admin-only) status call has resolved - proof the panel loaded. + public ILocator SystemInstanceRow => Page.GetByText("Answered by instance"); + + private ILocator TypeButton(string name) => Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = name, Exact = true }); + + /// + /// Switches the unresolved queue to another reference type and waits for that type's button to read back as the active (primary) one. + /// Uses the same first-click-after-load retry as the list pages, so it doubles as the circuit-warmup before the export click below. + /// + public async Task SelectUnresolvedTypeAsync(string typeButtonName) + => await ClickUntilAsync(TypeButton(typeButtonName), Page.Locator("button.btn-primary", new PageLocatorOptions { HasText = typeButtonName })); + + private ILocator ExportButton => Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Download export" }); + + /// The import file input (the only file input on the page - the export is a button, not a file input). + private ILocator ImportFileInput => Page.Locator("input[type='file'][accept='.zip']"); + + public ILocator ImportResultAlert => Page.Locator(".alert-info", new PageLocatorOptions { HasText = "Imported" }); + + /// Clicks "Download export" and saves the browser download to a temp path the caller owns (and deletes). + public async Task DownloadExportAsync() + { + var download = await Page.RunAndWaitForDownloadAsync(async () => await ExportButton.ClickAsync()); + var path = Path.Combine(Path.GetTempPath(), $"kt-e2e-ref-export-{Guid.NewGuid():N}.zip"); + await download.SaveAsAsync(path); + return path; + } + + public async Task ImportExportAsync(string zipPath) => await ImportFileInput.SetInputFilesAsync(zipPath); +} diff --git a/test/BlazorApp.PlaywrightTests/Smoke/ReferenceDataAdminSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/ReferenceDataAdminSmokeTest.cs new file mode 100644 index 00000000..3a3cd3e6 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Smoke/ReferenceDataAdminSmokeTest.cs @@ -0,0 +1,49 @@ +using System.IO; +using System.Threading.Tasks; +using Keeptrack.BlazorApp.PlaywrightTests.Hosting; +using Keeptrack.BlazorApp.PlaywrightTests.Pages; +using Microsoft.Playwright; +using Xunit; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; + +/// +/// Drives the admin-only reference-data page's provider-free, deterministic surfaces end to end: +/// the page loads for an admin (via the Admin nav link), its System status panel resolves, the unresolved queue switches reference type, +/// and the export → import round-trip works through the real UI (export is idempotent upsert-by-id, so re-importing changes nothing). +/// The provider search/link flow is intentionally left to the per-type detail-page smoke tests, and a full sync-now poll is intentionally not driven (it flakes on provider latency). +/// +[Trait("Category", "E2eTests")] +[Trait("Mode", "Mutating")] +public class ReferenceDataAdminSmokeTest(End2EndFixture fixture) : SmokeTestBase(fixture) +{ + [Fact] + public async Task AdminPage_LoadsSystemStatus_SwitchesUnresolvedType_AndRoundTripsExportImport() + { + SkipIfReadOnly(); + + var home = await new HomePage(Page).OpenAsync(); + var admin = await home.OpenAdminAsync(); + + // The System panel only renders this row once the admin-only status call resolves. + await Assertions.Expect(admin.SystemInstanceRow).ToBeVisibleAsync(); + + // Switch the unresolved queue to another reference type (also warms the interactive circuit before the export click). + await admin.SelectUnresolvedTypeAsync("Movies"); + + var exportPath = await admin.DownloadExportAsync(); + try + { + await admin.ImportExportAsync(exportPath); + await Assertions.Expect(admin.ImportResultAlert).ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 30000 }); + await Assertions.Expect(admin.ImportResultAlert).ToContainTextAsync("TV show(s)"); + } + finally + { + if (File.Exists(exportPath)) + { + File.Delete(exportPath); + } + } + } +} From d1807597e79d504efe7bea4f57ad09e195bae5eb Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 27 Jul 2026 21:48:35 +0200 Subject: [PATCH 07/80] Add Playwright smoke test for the account/preferences page Close the last genuine coverage gaps from the assessment (#4 account page, #5 user-preferences UI) - Manage.razor previously had no test at any level. ManageAccountSmokeTest asserts the signed-in identity renders, then round-trips a preference toggle through the real UI (toggle -> persisted -> a fresh load reflects it) and restores the original value via the API so the shared account is left unchanged. The toggle re-clicks through the prerender->interactive gap the same way ClickUntilAsync does for buttons. Also records in the assessment doc why per-type Quick Add scenarios (original recommendation #3) were withdrawn: they would duplicate coverage the suite already provides, which the quality bar rejects. Passes in self-hosted mutating mode. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PxyaxubjRzm2PSLMz7AE24 --- docs/testing-assessment.md | 13 +-- .../Pages/ManageAccountPage.cs | 29 +++++++ .../Smoke/ManageAccountSmokeTest.cs | 83 +++++++++++++++++++ 3 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 test/BlazorApp.PlaywrightTests/Pages/ManageAccountPage.cs create mode 100644 test/BlazorApp.PlaywrightTests/Smoke/ManageAccountSmokeTest.cs diff --git a/docs/testing-assessment.md b/docs/testing-assessment.md index 4b7ab279..2e686fe8 100644 --- a/docs/testing-assessment.md +++ b/docs/testing-assessment.md @@ -57,10 +57,10 @@ All of these have API/integration or unit coverage but are **never driven throug | 1 | ~~**TV Time import**~~ (upload zip → poll job → see results) | `Import/ImportPage.razor` | `TvTimeImportResourceTest` + all parsers | **Closed** — `TvTimeImportSmokeTest` drives the upload/progress/result UI | | 2 | ~~**Amazon order import**~~ (upload CSV → preview → commit) | `Import/AmazonImportPage.razor` | `AmazonImportResourceTest`, service tests | **Closed** — `AmazonImportSmokeTest` drives upload/preview/commit/result | | 3 | ~~**Generic video-game import**~~ (PSN-style CSV → preview → commit) | `Import/GenericVideoGameImportPage.razor` | `GenericVideoGameImportResourceTest`, service test | **Closed** — `GenericVideoGameImportSmokeTest` drives upload/preview/commit/result | -| 4 | **Account management** (view profile, sign-out affordances) | `Account/Pages/Manage.razor` | none | No test at any level | -| 5 | **User preferences** (edit and persist settings) | wherever preferences are edited | `UserPreferencesResourceTest` | No E2E for the settings UI | +| 4 | ~~**Account management**~~ (view identity) | `Account/Pages/Manage.razor` | none | **Closed** — `ManageAccountSmokeTest` asserts the signed-in identity renders | +| 5 | ~~**User preferences**~~ (edit and persist settings) | `Account/Pages/Manage.razor` | `UserPreferencesResourceTest` | **Closed** — `ManageAccountSmokeTest` round-trips a preference toggle through the UI (toggle → persisted → reload reflects it) | | 6 | **Reference-data admin page** (search, unresolved queue, provider picker, link, sync-now, export/import) | `ReferenceDataAdmin/ReferenceDataAdminPage.razor` | `ReferenceDataAdminResourceTest`, `BookUnresolvedQueueTest`, `ReferenceDataExportImportTest` | **Partially closed** — `ReferenceDataAdminSmokeTest` drives page load, the System panel, unresolved type-switching, and the export→import round-trip. The provider search/link flow is left to the per-type detail-page smoke tests (same endpoints, real providers), and a full sync-now poll is deliberately not driven (flakes on provider latency). | -| 7 | **Quick Add — most types** | `QuickAdd/QuickAddPage.razor` | per-type resource tests | Only `movie` and `car` variants are E2E'd; `tv-show`, `book`, `album`, `video-game`, `house`, `health` Quick Add flows are not | +| 7 | ~~**Quick Add — most types**~~ | `QuickAdd/QuickAddPage.razor` | per-type resource tests | **Not a real gap** — `QuickAddSmokeTest` deliberately covers one media type (movie) and one record type (car), which exercise Quick Add's whole plumbing; the per-type form fields are already covered by each type's own detail-page smoke test, so per-type Quick Add scenarios would be duplication the quality bar rejects. Left intentionally uncovered. | | 8 | **Playlist song editing** (add/edit/remove songs within a playlist) | `Inventory/Pages/PlaylistDetail.razor` | `SongResourceTest`, `PlaylistResourceTest` | `PlaylistSmokeTest` covers add/delete of the playlist itself, not the embedded song-editing UI | | 9 | **Car/House/Health history rows** (add/edit history entries and see computed metrics/charts) | `CarDetail`, `HouseDetail`, `HealthProfileDetail` | metrics service unit tests + history resource tests | Smoke tests create the parent and (for Health) one record; the metrics charts and multi-row history editing are not asserted in the browser | | 10 | **Error / NotFound pages** | `Pages/Error.razor`, `Pages/NotFound.razor` | none | No test asserts the error or 404 experience | @@ -87,7 +87,8 @@ All of these have API/integration or unit coverage but are **never driven throug 1. ~~Add a Playwright smoke test for each of the three import pages~~ — **Done and verified** (2026-07-27): `AmazonImportSmokeTest`, `GenericVideoGameImportSmokeTest`, `TvTimeImportSmokeTest`, each uploading a small GUID-suffixed in-memory fixture (`Support/*FixtureCsvBuilder`/`*FixtureZipBuilder`) through the real UI and cleaning up via the API. All three pass in self-hosted mutating mode (`E2E_ENABLED=true`, Firebase account as `E2E_USERNAME`, provider keys from `appsettings.Development.json`). 2. ~~Add an E2E test that opens `ReferenceDataAdminPage` end-to-end~~ — **Done and verified** (2026-07-27): `ReferenceDataAdminSmokeTest` covers page load + System panel + unresolved type-switch + export→import round-trip (the provider-free, deterministic surfaces). Provider search/link stays with the detail-page smoke tests; sync-now polling is left out as known-flaky. -3. Extend `QuickAddSmokeTest` to cover the remaining Quick Add types, or parameterize it the way `ListPage` already parameterizes the list pages. -4. Add a minimal smoke test for `Manage.razor` and the user-preferences UI. +3. ~~Extend `QuickAddSmokeTest` to cover the remaining Quick Add types~~ — **Withdrawn** (2026-07-27): on review, `QuickAddSmokeTest` intentionally proves Quick Add's plumbing with one media + one record type, and the per-type fields are covered by each type's own detail-page smoke test. Adding per-type Quick Add scenarios would duplicate existing coverage, which the quality bar rejects. +4. ~~Add a minimal smoke test for `Manage.razor` and the user-preferences UI~~ — **Done and verified** (2026-07-27): `ManageAccountSmokeTest` covers the account identity and a preference-toggle UI round-trip (persistence confirmed, then restored via the API so the shared account is unchanged). Closes gaps #4 and #5. 5. Introduce a small bUnit component-test project to move fast-feedback UI logic off the gated Playwright suite. -6. Consider a coverage-threshold gate on the WebApi projects in CI. +6. Add a cheap smoke test for the Error / NotFound pages. +7. Consider a coverage-threshold gate on the WebApi projects in CI. diff --git a/test/BlazorApp.PlaywrightTests/Pages/ManageAccountPage.cs b/test/BlazorApp.PlaywrightTests/Pages/ManageAccountPage.cs new file mode 100644 index 00000000..1abfd04c --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Pages/ManageAccountPage.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; +using Microsoft.Playwright; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; + +/// +/// The /account/manage page: the signed-in identity summary plus the user-preference toggles. +/// +public class ManageAccountPage(IPage page) : PageBase(page) +{ + public async Task OpenAsync() + { + await Page.GotoAsync("/account/manage"); + await WaitForReadyAsync(); + return this; + } + + public override async Task WaitForReadyAsync() + { + await base.WaitForReadyAsync(); + await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new PageGetByRoleOptions { Name = "Manage", Level = 1 })).ToBeVisibleAsync(); + } + + /// The "Signed in as {email}" line - proof the authenticated identity rendered. + public ILocator SignedInAs => Page.GetByText("Signed in as"); + + /// One of the two preference checkboxes; it carries a real <label for>, so GetByLabel resolves it. + public ILocator ChasseAuxLivresPreference => Page.GetByLabel("Show chasse-aux-livres.fr link on book pages"); +} diff --git a/test/BlazorApp.PlaywrightTests/Smoke/ManageAccountSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/ManageAccountSmokeTest.cs new file mode 100644 index 00000000..07c7d89f --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Smoke/ManageAccountSmokeTest.cs @@ -0,0 +1,83 @@ +using System; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Keeptrack.BlazorApp.PlaywrightTests.Hosting; +using Keeptrack.BlazorApp.PlaywrightTests.Pages; +using Keeptrack.WebApi.Contracts.Dto; +using Microsoft.Playwright; +using Xunit; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; + +/// +/// Covers the account-management page - previously the only walkthrough with no test at any level - +/// and, with it, the user-preferences UI: the signed-in identity renders, and toggling a preference checkbox round-trips through the real UI +/// (toggle → persisted → a fresh page load reflects it). The preference is restored to its original value via the API so the shared test account is left unchanged. +/// +[Trait("Category", "E2eTests")] +[Trait("Mode", "Mutating")] +public class ManageAccountSmokeTest(End2EndFixture fixture) : SmokeTestBase(fixture) +{ + private const string PreferencesUrl = "api/user-preferences"; + + [Fact] + public async Task ManagePage_ShowsIdentity_AndPersistsAPreferenceToggle() + { + SkipIfReadOnly(); + + var manage = await new ManageAccountPage(Page).OpenAsync(); + await Assertions.Expect(manage.SignedInAs).ToBeVisibleAsync(); + + var pref = manage.ChasseAuxLivresPreference; + await Assertions.Expect(pref).ToBeVisibleAsync(); + var original = await GetChasseAuxLivresAsync(); + + try + { + await ToggleUntilPersistedAsync(pref, original); + + // A fresh page load reflecting the persisted value is the actual UI round-trip assertion. + await manage.OpenAsync(); + await Assertions.Expect(manage.ChasseAuxLivresPreference).ToBeCheckedAsync(new LocatorAssertionsToBeCheckedOptions { Checked = !original }); + } + finally + { + var ct = TestContext.Current.CancellationToken; + var prefs = await Fixture.ApiHttpClient.GetFromJsonAsync(PreferencesUrl, ct) ?? new UserPreferencesDto(); + prefs.Features.ShowChasseAuxLivresLink = original; + await Fixture.ApiHttpClient.PutAsJsonAsync(PreferencesUrl, prefs, ct); + } + } + + /// + /// Toggles the preference checkbox through the prerender→interactive gap: the very first @onchange after a fresh load can land + /// before the Blazor circuit is live (the same gap mitigates for buttons), so re-click until the + /// change actually reaches the server - detected as the stored value flipping away from its original value. The first click that registers + /// server-side flips it to !original, so extra pre-connection DOM-only flips don't affect the final persisted value. + /// + private async Task ToggleUntilPersistedAsync(ILocator pref, bool original, int maxAttempts = 8) + { + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + await pref.ClickAsync(); + + for (var poll = 0; poll < 8; poll++) + { + if (await GetChasseAuxLivresAsync() != original) + { + return; + } + + await Task.Delay(200); + } + } + + throw new TimeoutException("The preference toggle never reached the server."); + } + + private async Task GetChasseAuxLivresAsync() + { + var prefs = await Fixture.ApiHttpClient.GetFromJsonAsync(PreferencesUrl); + return prefs?.Features.ShowChasseAuxLivresLink ?? false; + } +} From ba43822f49dbfece01c8761a2d5d64b03e8309b2 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 27 Jul 2026 22:04:30 +0200 Subject: [PATCH 08/80] Refine thumbnail grid: side margins + shape-aware column widths - Inset the grid from the panel edges (was flush): 1rem desktop, 0.75rem mobile, matching the list rows' horizontal padding. - Size grid columns per cover shape so wide (16:9) tiles are no longer tiny strips: wide uses a 260px min (3 large columns on desktop, 2 on mobile) for video games/cars/houses/health/gear/collectibles; square (albums) 160px; portrait unchanged. The grid container now carries its ItemImageShape class so the CSS can target it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WvLYeTtX526Y5QZqrrz1o6 --- .../Components/Inventory/Shared/InventoryList.razor | 2 +- src/BlazorApp/wwwroot/app.css | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor index f9c53df5..c73725c8 100644 --- a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor +++ b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor @@ -91,7 +91,7 @@ else { @* Poster/thumbnail view: the cover art is the hero, with title + meta captioned underneath. Same stretched-link-opens-detail / delete-button-above-via-z-index model as the list rows. *@ -
+
@foreach (var item in Items) {
diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index 1518c2d4..5ce5dc5f 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -451,8 +451,12 @@ a.kt-item-row { color: inherit; text-decoration: none; } display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 1.25rem 1rem; - padding-top: 0.5rem; + padding: 0.5rem 1rem 0; } +/* per-shape column sizing: square art reads fine a touch wider than portrait, and wide (16:9) imagery + needs a much larger min so the tiles don't shrink to unreadable strips (video games, cars, houses, ...) */ +.kt-item-grid.square { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } +.kt-item-grid.wide { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); } .kt-grid-card { position: relative; min-width: 0; } .kt-grid-cover { position: relative; @@ -506,7 +510,9 @@ a.kt-item-row { color: inherit; text-decoration: none; } color: var(--kt-text-muted); } @media (max-width: 575.98px) { - .kt-item-grid { grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); gap: 0.9rem 0.6rem; } + .kt-item-grid { grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); gap: 0.9rem 0.6rem; padding: 0.5rem 0.75rem 0; } + .kt-item-grid.square { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); } + .kt-item-grid.wide { grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); } } /* candidate synopsis in the admin linking queue - TMDB synopses can run very long, pushing the Link From 62b246e6a7ad5375d7f0ffc313ae827e90dbbd0e Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 27 Jul 2026 22:51:38 +0200 Subject: [PATCH 09/80] Add bottom padding below the thumbnail grid Space the last row of grid items off the card's bottom border (1.5rem desktop, 1.25rem mobile); the grid previously had no bottom padding so items sat flush against the panel edge. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WvLYeTtX526Y5QZqrrz1o6 --- src/BlazorApp/wwwroot/app.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index 5ce5dc5f..5d02101f 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -451,7 +451,7 @@ a.kt-item-row { color: inherit; text-decoration: none; } display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 1.25rem 1rem; - padding: 0.5rem 1rem 0; + padding: 0.5rem 1rem 1.5rem; } /* per-shape column sizing: square art reads fine a touch wider than portrait, and wide (16:9) imagery needs a much larger min so the tiles don't shrink to unreadable strips (video games, cars, houses, ...) */ @@ -510,7 +510,7 @@ a.kt-item-row { color: inherit; text-decoration: none; } color: var(--kt-text-muted); } @media (max-width: 575.98px) { - .kt-item-grid { grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); gap: 0.9rem 0.6rem; padding: 0.5rem 0.75rem 0; } + .kt-item-grid { grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); gap: 0.9rem 0.6rem; padding: 0.5rem 0.75rem 1.25rem; } .kt-item-grid.square { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); } .kt-item-grid.wide { grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); } } From 64e7f1abcf6279cfdfa65c021c33baea148a5efb Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 27 Jul 2026 22:55:57 +0200 Subject: [PATCH 10/80] Fix time entry issue on mobile view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed the problem was in the shared DateTimeFields.razor time input (used by both the health record form and car-history form). It's a free-text HH:mm field with inputmode="numeric", so the phone's numeric keypad shows digits but no : key, making the colon impossible to type. The owner deliberately chose free-text over the native picker to guarantee 24h display (documented in the component and CLAUDE.md), so rather than override that decision, I made the colon optional on input: - SetTimeTextAsync now also accepts bare digits — 1430 → 14:30, 930 → 09:30 — via a new TryParseTime helper, while still accepting 14:30 typed on a desktop keyboard. - The field reformats to the canonical HH:mm on blur, so the stored/displayed value is unchanged. - Loosened the pattern to allow the optional colon and added a title hint ("Type HH:mm, or just the digits"). - An unparseable entry is still ignored, leaving the previous value untouched — same behavior as before. Because it's the shared component, car-history time entry on mobile benefits from the same fix. --- .../Components/Shared/DateTimeFields.razor | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/BlazorApp/Components/Shared/DateTimeFields.razor b/src/BlazorApp/Components/Shared/DateTimeFields.razor index 20718fbb..377ebd25 100644 --- a/src/BlazorApp/Components/Shared/DateTimeFields.razor +++ b/src/BlazorApp/Components/Shared/DateTimeFields.razor @@ -13,9 +13,12 @@ @* A plain text "HH:mm" field rather than - the native time picker's 12h/24h display is decided by the browser from the OS region format, not anything this page controls (Chrome and Firefox both ignore the page's own language/culture for it), so it can't be forced to - 24h that way. A free-text field sidesteps the native widget entirely and always reads/writes 24h. *@ + 24h that way. A free-text field sidesteps the native widget entirely and always reads/writes 24h. + The colon is optional on input: a phone's numeric keypad has no ":" key, so bare digits like "1430" + (or "930") are accepted too and reformatted to "HH:mm" on blur. *@ + title="Type HH:mm, or just the digits (e.g. 1430) - the colon is optional." + pattern="[0-2]?[0-9]:?[0-5][0-9]" @bind:get="TimeText" @bind:set="SetTimeTextAsync" @bind:event="onchange"/>
@code { @@ -29,9 +32,9 @@ private Task SetDateAsync(DateOnly value) => UpdateAsync(value.ToDateTime(TimeOnly.FromDateTime(Value))); - private Task SetTimeTextAsync(string value) + private Task SetTimeTextAsync(string? value) { - if (TimeOnly.TryParseExact(value, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var time)) + if (TryParseTime(value, out var time)) { return UpdateAsync(DateOnly.FromDateTime(Value).ToDateTime(time)); } @@ -39,6 +42,26 @@ return Task.CompletedTask; } + // Accept "HH:mm" (typed on a full keyboard) but also bare digits ("1430", "930") so a phone's + // numeric keypad - which has no ":" key - can still enter a time. An invalid entry is ignored, + // leaving the previous value untouched, same as before. + private static bool TryParseTime(string? value, out TimeOnly time) + { + if (TimeOnly.TryParseExact(value, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out time)) + { + return true; + } + + var digits = new string((value ?? "").Where(char.IsDigit).ToArray()); + if (digits.Length is 3 or 4) + { + return TimeOnly.TryParseExact(digits.PadLeft(4, '0'), "HHmm", CultureInfo.InvariantCulture, DateTimeStyles.None, out time); + } + + time = default; + return false; + } + private Task UpdateAsync(DateTime value) { Value = value; From 44093139279758d4a400174b7df5eb1fae7c722c Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 27 Jul 2026 23:07:18 +0200 Subject: [PATCH 11/80] Persist the list/thumbnail view as a per-device preference The view choice is now remembered instead of defaulting to list on every page. It is a global user preference (like a theme), not list state: it never changes which items show or their order, only their presentation. Stored per-device in localStorage and mirrored in a new circuit-scoped ListViewPreference so every list page in the session shares one choice. The app renders InteractiveServer over a WebSocket circuit, so in-app navigation carries no fresh HttpContext to read a cookie from - hence localStorage (read once per circuit on first interactive render, since it is not reachable during prerender) rather than a server cookie. Chose per-device deliberately: thumbnails suit a desktop while a phone may prefer the compact list. The ?view= URL parameter is removed; toggling now re-renders in place (no navigation, no refetch) and writes the preference. MobileScreenshotTest drives the captures via the toggle + localStorage instead of ?view=, which also verifies the preference carries across pages and full reloads. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WvLYeTtX526Y5QZqrrz1o6 --- .../Components/Inventory/InventoryPageBase.cs | 79 ++++++++++++++----- .../Inventory/ListViewPreference.cs | 24 ++++++ src/BlazorApp/Program.cs | 1 + .../Smoke/MobileScreenshotTest.cs | 42 +++++++--- 4 files changed, 117 insertions(+), 29 deletions(-) create mode 100644 src/BlazorApp/Components/Inventory/ListViewPreference.cs diff --git a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs index e059b1c8..949973ac 100644 --- a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs +++ b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs @@ -2,6 +2,7 @@ using Keeptrack.Common.System; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; namespace Keeptrack.BlazorApp.Components.Inventory; @@ -46,8 +47,10 @@ public abstract class InventoryPageBase : ComponentBase protected string _sort = ""; - // View mode is deliberately kept out of the query signature below: switching list<->grid is a pure - // display change over the already-loaded page, so flipping it must never trigger a refetch. + // View mode ("" = list, "grid" = thumbnails) is a global user preference, not list state: it never + // changes which items are shown or their order, only their presentation. So it lives in the shared, + // circuit-scoped ListViewPreference (seeded once from localStorage) rather than in the URL/query + // signature - flipping it must never refetch, and it carries to every list page for the session. protected string _view = ""; protected int _page = 1; @@ -56,6 +59,10 @@ public abstract class InventoryPageBase : ComponentBase [Inject] protected NavigationManager Navigation { get; set; } = null!; + [Inject] protected IJSRuntime JS { get; set; } = null!; + + [Inject] protected ListViewPreference ViewPreference { get; set; } = null!; + /// /// List state (search, page, and each page's own filters) lives in the URL query string, so that /// opening an item's detail page and navigating back restores the exact list position instead of @@ -70,14 +77,6 @@ public abstract class InventoryPageBase : ComponentBase [SupplyParameterFromQuery(Name = "sort")] public string? SortQuery { get; set; } - /// - /// The list's display mode ("" = the default detailed list, "grid" = poster thumbnails). Like the - /// other list-state parameters it lives in the URL so it's restored on back-nav and bookmarkable, but - /// unlike them it's a pure display change - see /. - /// - [SupplyParameterFromQuery(Name = "view")] - public string? ViewQuery { get; set; } - protected abstract InventoryApiClientBase Api { get; } /// @@ -110,7 +109,7 @@ protected override async Task OnParametersSetAsync() { _search = SearchQuery ?? ""; _sort = SortQuery ?? DefaultSort; - _view = ViewQuery ?? ""; + _view = ViewPreference.View; _page = PageQuery is > 0 ? PageQuery.Value : 1; var query = BuildQuerySignature(); @@ -129,6 +128,38 @@ protected override async Task OnParametersSetAsync() await LoadAsync(); } + /// + /// Seeds the shared from the browser's localStorage exactly once per + /// circuit. localStorage isn't reachable during the server-side prerender, so this runs on the first + /// interactive render; every later in-circuit navigation reads the already-seeded value synchronously + /// in , so only the very first list page of a session can briefly + /// show the default view before the saved one applies. + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || ViewPreference.Seeded) + { + return; + } + + ViewPreference.Seeded = true; + try + { + var saved = await JS.InvokeAsync("localStorage.getItem", ListViewPreference.StorageKey); + ViewPreference.View = saved ?? ""; + } + catch (JSException) + { + // localStorage unavailable (e.g. private-mode restrictions) - keep the default list view. + } + + if (_view != ViewPreference.View) + { + _view = ViewPreference.View; + StateHasChanged(); + } + } + protected void OnSearchChanged(string value) => _search = value; protected void OnSearchKeyUp(KeyboardEventArgs e) @@ -169,14 +200,26 @@ protected void SetSort(string value) => ApplyQueryChanges(new Dictionary { ["sort"] = string.IsNullOrEmpty(value) ? null : value, ["page"] = null }); /// - /// Switches the list/thumbnail display mode ("" = the default detailed list, kept out of the URL, - /// "grid" = poster thumbnails) through the same URL-navigation path as sort/filters, so it's restored - /// on back-nav and bookmarkable. Unlike a filter it deliberately does not reset the page, and (being - /// absent from the query signature) never refetches - the router-supplied reload in - /// short-circuits and only re-renders with the new view. + /// Switches the list/thumbnail display mode ("" = detailed list, "grid" = poster thumbnails) and + /// persists it as a global preference: it updates the shared (so every + /// other list page in the session inherits it) and writes localStorage (so it survives reloads and + /// future sessions). This is a pure presentation change over the already-loaded page, so it just + /// re-renders in place - no navigation, no refetch. /// - protected void SetView(string value) => - ApplyQueryChanges(new Dictionary { ["view"] = string.IsNullOrEmpty(value) ? null : value }); + protected async Task SetView(string value) + { + _view = value; + ViewPreference.View = value; + ViewPreference.Seeded = true; + try + { + await JS.InvokeVoidAsync("localStorage.setItem", ListViewPreference.StorageKey, value); + } + catch (JSException) + { + // localStorage unavailable - the in-memory ViewPreference still carries the choice for the session. + } + } /// /// Navigates to the current list URL with the given query-parameter changes applied (a null value diff --git a/src/BlazorApp/Components/Inventory/ListViewPreference.cs b/src/BlazorApp/Components/Inventory/ListViewPreference.cs new file mode 100644 index 00000000..0e0802eb --- /dev/null +++ b/src/BlazorApp/Components/Inventory/ListViewPreference.cs @@ -0,0 +1,24 @@ +namespace Keeptrack.BlazorApp.Components.Inventory; + +/// +/// Circuit-scoped holder for the user's list/thumbnail view preference ("" = detailed list, "grid" = +/// poster thumbnails). Registered scoped, so it lives for the lifetime of one Blazor Server circuit and is +/// shared by every - flipping the view on one list page carries to +/// every other list page for the session without re-clicking the toggle. +/// +/// It is seeded once per circuit from the browser's localStorage (see InventoryPageBase.OnAfterRenderAsync) +/// so the choice also survives a full reload and future sessions. localStorage can't be read during the +/// server-side prerender, which is why an in-memory holder carries it across in-circuit navigations instead +/// of re-reading storage (and re-flashing) on every page. +/// +public sealed class ListViewPreference +{ + /// The localStorage key the preference is persisted under. + public const string StorageKey = "kt-list-view"; + + /// Whether this circuit has already read the persisted value once (see the class summary). + public bool Seeded { get; set; } + + /// Current view: "" (detailed list, the default) or "grid" (poster thumbnails). + public string View { get; set; } = ""; +} diff --git a/src/BlazorApp/Program.cs b/src/BlazorApp/Program.cs index b303966e..651de7d2 100644 --- a/src/BlazorApp/Program.cs +++ b/src/BlazorApp/Program.cs @@ -42,6 +42,7 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddWebApiHttpClient(builder.Configuration.TryGetSection("WebApi:BaseUrl")); builder.Services.AddHealthChecks(); diff --git a/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs index 3fa675da..09eea248 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs @@ -77,12 +77,18 @@ public async Task CaptureAllPagesAtPhoneViewport() await CaptureAsync(route, name); } - // Thumbnail/grid view at the phone viewport, one per cover-art shape (portrait/square/wide) plus - // movies (no linked art here, so it exercises the placeholder tile). - await CaptureAsync("/books?view=grid", "books-grid"); - await CaptureAsync("/albums?view=grid", "albums-grid"); - await CaptureAsync("/video-games?view=grid", "video-games-grid"); - await CaptureAsync("/movies?view=grid", "movies-grid"); + // Thumbnail/grid view at the phone viewport. Click the real toggle once (exercises SetView and + // its localStorage persistence), then the sibling list pages inherit the saved preference across + // full reloads - which is the whole point of the feature. Restore list view for the later shots. + await Page.GotoAsync("/books"); + await Page.WaitForTimeoutAsync(1200); + await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Thumbnail view" }).ClickAsync(); + await Page.WaitForTimeoutAsync(800); + await Page.ScreenshotAsync(new PageScreenshotOptions { Path = Path.Combine(ShotsDirectory, "books-grid.png"), FullPage = true }); + await CaptureAsync("/albums", "albums-grid"); + await CaptureAsync("/video-games", "video-games-grid"); + await CaptureAsync("/movies", "movies-grid"); + await SetListViewPreferenceAsync(null); // The collapsed sidebar opened via the hamburger toggle. await Page.GotoAsync("/"); @@ -157,11 +163,13 @@ public async Task CaptureAllPagesAtPhoneViewport() await Page.SetViewportSizeAsync(1280, 900); await CaptureFirstDetailAsync("/video-games", "video-game-detail-desktop"); - // The same grid + list views at desktop width, to verify the responsive grid columns and the - // list/thumbnail toggle at both breakpoints. - await CaptureAsync("/books?view=grid", "books-grid-desktop"); - await CaptureAsync("/albums?view=grid", "albums-grid-desktop"); - await CaptureAsync("/video-games?view=grid", "video-games-grid-desktop"); + // The same grid + list views at desktop width, to verify the responsive grid columns at both + // breakpoints. Drive the view via the persisted preference, then restore list for the list shot. + await SetListViewPreferenceAsync("grid"); + await CaptureAsync("/books", "books-grid-desktop"); + await CaptureAsync("/albums", "albums-grid-desktop"); + await CaptureAsync("/video-games", "video-games-grid-desktop"); + await SetListViewPreferenceAsync(null); await CaptureAsync("/books", "books-list-desktop"); // A dark-theme sample of the densest pages. @@ -463,6 +471,18 @@ private static async Task CreateAsync(HttpClient api, List return id; } + /// + /// Sets (or clears, when null) the per-device list-view preference in localStorage. Each subsequent + /// full navigation re-seeds a fresh circuit from it, so this deterministically drives grid vs list for + /// the captures without depending on the (removed) ?view= URL parameter. + /// + private async Task SetListViewPreferenceAsync(string? view) + { + await Page.EvaluateAsync(view is null + ? "() => localStorage.removeItem('kt-list-view')" + : $"() => localStorage.setItem('kt-list-view', '{view}')"); + } + private async Task CaptureAsync(string route, string name) { await Page.GotoAsync(route); From ba92c7b8656bbe5d730fe71cc1c59ee29b36d2c6 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 28 Jul 2026 04:34:40 +0200 Subject: [PATCH 12/80] Add the list/thumbnail view toggle to Wishlist and Watch Next These two pages build their own layout (not InventoryList) but render the same media rows, so they gained the same view toggle and poster grid. To keep one copy of the logic, the toggle's seed/persist plumbing moved into a shared ListViewToggle component (used by InventoryList too, which no longer inlines it or the seeding in InventoryPageBase), and the poster card moved into a shared ItemGridCard used by all three grids. The card's caption render-fragment is named MetaContent, not Meta, because collides with the HTML void element in Razor. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WvLYeTtX526Y5QZqrrz1o6 --- .../Components/Inventory/InventoryPageBase.cs | 58 +--------- .../Inventory/Shared/InventoryList.razor | 34 ++---- .../Components/Shared/ItemGridCard.razor | 43 ++++++++ .../Components/Shared/ListViewToggle.razor | 65 +++++++++++ .../Components/WatchNext/WatchNextPage.razor | 102 +++++++++++++----- .../Components/Wishlist/WishlistPage.razor | 67 +++++++++--- 6 files changed, 248 insertions(+), 121 deletions(-) create mode 100644 src/BlazorApp/Components/Shared/ItemGridCard.razor create mode 100644 src/BlazorApp/Components/Shared/ListViewToggle.razor diff --git a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs index 949973ac..0a72ed72 100644 --- a/src/BlazorApp/Components/Inventory/InventoryPageBase.cs +++ b/src/BlazorApp/Components/Inventory/InventoryPageBase.cs @@ -2,7 +2,6 @@ using Keeptrack.Common.System; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; -using Microsoft.JSInterop; namespace Keeptrack.BlazorApp.Components.Inventory; @@ -59,8 +58,6 @@ public abstract class InventoryPageBase : ComponentBase [Inject] protected NavigationManager Navigation { get; set; } = null!; - [Inject] protected IJSRuntime JS { get; set; } = null!; - [Inject] protected ListViewPreference ViewPreference { get; set; } = null!; /// @@ -128,38 +125,6 @@ protected override async Task OnParametersSetAsync() await LoadAsync(); } - /// - /// Seeds the shared from the browser's localStorage exactly once per - /// circuit. localStorage isn't reachable during the server-side prerender, so this runs on the first - /// interactive render; every later in-circuit navigation reads the already-seeded value synchronously - /// in , so only the very first list page of a session can briefly - /// show the default view before the saved one applies. - /// - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!firstRender || ViewPreference.Seeded) - { - return; - } - - ViewPreference.Seeded = true; - try - { - var saved = await JS.InvokeAsync("localStorage.getItem", ListViewPreference.StorageKey); - ViewPreference.View = saved ?? ""; - } - catch (JSException) - { - // localStorage unavailable (e.g. private-mode restrictions) - keep the default list view. - } - - if (_view != ViewPreference.View) - { - _view = ViewPreference.View; - StateHasChanged(); - } - } - protected void OnSearchChanged(string value) => _search = value; protected void OnSearchKeyUp(KeyboardEventArgs e) @@ -200,26 +165,11 @@ protected void SetSort(string value) => ApplyQueryChanges(new Dictionary { ["sort"] = string.IsNullOrEmpty(value) ? null : value, ["page"] = null }); /// - /// Switches the list/thumbnail display mode ("" = detailed list, "grid" = poster thumbnails) and - /// persists it as a global preference: it updates the shared (so every - /// other list page in the session inherits it) and writes localStorage (so it survives reloads and - /// future sessions). This is a pure presentation change over the already-loaded page, so it just - /// re-renders in place - no navigation, no refetch. + /// Adopts a new view reported by the (which owns persisting it to the + /// shared and localStorage). This is a pure presentation change over the + /// already-loaded page, so it just re-renders in place - no navigation, no refetch. /// - protected async Task SetView(string value) - { - _view = value; - ViewPreference.View = value; - ViewPreference.Seeded = true; - try - { - await JS.InvokeVoidAsync("localStorage.setItem", ListViewPreference.StorageKey, value); - } - catch (JSException) - { - // localStorage unavailable - the in-memory ViewPreference still carries the choice for the session. - } - } + protected void SetView(string value) => _view = value; /// /// Navigates to the current list URL with the given query-parameter changes applied (a null value diff --git a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor index c73725c8..f794d231 100644 --- a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor +++ b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor @@ -71,14 +71,7 @@ else @if (ItemImageUrl is not null) { @* Grid view leverages cover art, so the toggle only appears for types that have it. *@ -
- - -
+ } @if (Filters is not null) { @@ -94,29 +87,16 @@ else
@foreach (var item in Items) { -
- @* stretched-link spans the whole card (cover + caption); the delete button sits - above it via z-index, same model as the list rows. *@ - -
- @if (!string.IsNullOrEmpty(ItemImageUrl(item))) - { - - } - else - { - - } + + -
-
-
@ItemTitle(item)
-
@MetaTemplate(item)
-
-
+ + @MetaTemplate(item) + }
} diff --git a/src/BlazorApp/Components/Shared/ItemGridCard.razor b/src/BlazorApp/Components/Shared/ItemGridCard.razor new file mode 100644 index 00000000..ef236e78 --- /dev/null +++ b/src/BlazorApp/Components/Shared/ItemGridCard.razor @@ -0,0 +1,43 @@ +@* One poster card for the thumbnail/grid view - the cover art is the hero, with a title + optional meta + captioned underneath. Shared by the inventory list pages, Wishlist and Watch Next so the card markup + (and its stretched-link-opens-detail / actions-above-via-z-index model) exists exactly once. *@ + +
+ +
+ @if (!string.IsNullOrEmpty(ImageUrl)) + { + + } + else + { + + } + @Actions +
+
+
@Title
+ @if (MetaContent is not null) + { +
@MetaContent
+ } +
+
+ +@code { + /// Detail-page URL the whole card links to. + [Parameter] public string? Href { get; set; } + + [Parameter] public string? Title { get; set; } + + [Parameter] public string? ImageUrl { get; set; } + + /// See : unset = portrait, "square" (albums), "wide" (games). + [Parameter] public string? Shape { get; set; } + + /// The caption's second line (year/creator text, flag pills, badges). Named to avoid the HTML <meta> element. + [Parameter] public RenderFragment? MetaContent { get; set; } + + /// Optional overlay(s) on the cover, e.g. a delete button - raised above the stretched link via z-index. + [Parameter] public RenderFragment? Actions { get; set; } +} diff --git a/src/BlazorApp/Components/Shared/ListViewToggle.razor b/src/BlazorApp/Components/Shared/ListViewToggle.razor new file mode 100644 index 00000000..60d5156b --- /dev/null +++ b/src/BlazorApp/Components/Shared/ListViewToggle.razor @@ -0,0 +1,65 @@ +@using Keeptrack.BlazorApp.Components.Inventory +@inject ListViewPreference Preference +@inject IJSRuntime JS + +@* The list/thumbnail segmented control, shared by every list page plus Wishlist and Watch Next. It owns the + single copy of the view-preference plumbing: seed once per circuit from localStorage, persist on toggle, + and notify the parent (which owns rendering list vs grid) via ViewChanged. *@ + +
+ + +
+ +@code { + /// Current view ("" = detailed list, "grid" = poster thumbnails) - the parent owns this value. + [Parameter] public string View { get; set; } = ""; + + [Parameter] public EventCallback ViewChanged { get; set; } + + // Seeds the shared preference from localStorage once per circuit. localStorage isn't reachable during the + // server-side prerender, so this runs on the first interactive render; every later page reads the + // already-seeded value synchronously, so only the very first page of a session can briefly show the + // default view before the saved one applies. + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || Preference.Seeded) + { + return; + } + + Preference.Seeded = true; + try + { + var saved = await JS.InvokeAsync("localStorage.getItem", ListViewPreference.StorageKey); + Preference.View = saved ?? ""; + } + catch (JSException) + { + // localStorage unavailable (e.g. private-mode restrictions) - keep the default list view. + } + + if (View != Preference.View) + { + await ViewChanged.InvokeAsync(Preference.View); + } + } + + private async Task SetAsync(string value) + { + Preference.View = value; + Preference.Seeded = true; + try + { + await JS.InvokeVoidAsync("localStorage.setItem", ListViewPreference.StorageKey, value); + } + catch (JSException) + { + // localStorage unavailable - the in-memory preference still carries the choice for the session. + } + + await ViewChanged.InvokeAsync(value); + } +} diff --git a/src/BlazorApp/Components/WatchNext/WatchNextPage.razor b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor index 4b32c84e..9e82c93f 100644 --- a/src/BlazorApp/Components/WatchNext/WatchNextPage.razor +++ b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor @@ -1,8 +1,10 @@ @page "/watch-next" @attribute [Authorize] +@using Keeptrack.BlazorApp.Components.Inventory

Watch next

+
@if (!_loaded) @@ -41,22 +43,39 @@ else if (Data is not null) else {
- } } @@ -72,23 +91,42 @@ else if (Data is not null) else {
- } } @@ -101,6 +139,8 @@ else if (Data is not null) [Inject] private NavigationManager Navigation { get; set; } = null!; + [Inject] private ListViewPreference ViewPreference { get; set; } = null!; + // Persisted in the URL (?tab=) the same way list pages persist search/page/filters - see // InventoryPageBase's ApplyQueryChanges/SetFilter for the pattern this mirrors. Unlike a list page's // query params, changing this one never triggers a reload: Data isn't query-dependent here (WatchNextDto @@ -124,10 +164,20 @@ else if (Data is not null) private Tab _tab = Tab.TvShows; - protected override Task OnInitializedAsync() => LoadAsync(); + // The shared list/thumbnail preference (see ListViewToggle). Both tabs (shows, movies) use portrait + // covers, so the grid needs no per-tab column-shape override here. + private string _view = ""; + + protected override Task OnInitializedAsync() + { + _view = ViewPreference.View; + return LoadAsync(); + } protected override void OnParametersSet() => _tab = Enum.TryParse(TabQuery, out var tab) ? tab : Tab.TvShows; + private void OnViewChanged(string view) => _view = view; + private async Task LoadAsync() { await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); diff --git a/src/BlazorApp/Components/Wishlist/WishlistPage.razor b/src/BlazorApp/Components/Wishlist/WishlistPage.razor index a03d8f7b..44c4569a 100644 --- a/src/BlazorApp/Components/Wishlist/WishlistPage.razor +++ b/src/BlazorApp/Components/Wishlist/WishlistPage.razor @@ -1,9 +1,11 @@ @page "/wishlist" @attribute [Authorize] +@using Keeptrack.BlazorApp.Components.Inventory

Wishlist

-
+
+
@@ -76,14 +78,13 @@ else if (Data is not null) else {
- } } @@ -110,6 +135,8 @@ else if (Data is not null) [Inject] private IJSRuntime JsRuntime { get; set; } = null!; + [Inject] private ListViewPreference ViewPreference { get; set; } = null!; + // Persisted in the URL (?tab=) the same way list pages persist search/page/filters - see // InventoryPageBase's ApplyQueryChanges/SetFilter for the pattern this mirrors. Unlike a list page's // query params, changing this one never triggers a reload: Data isn't query-dependent here (WishlistDto @@ -133,6 +160,12 @@ else if (Data is not null) private Tab _tab = Tab.Movies; + // The shared list/thumbnail preference (see ListViewToggle). Video games are the only wide-cover tab, so + // the grid uses that column shape there and the default portrait shape for movies/TV shows/books. + private string _view = ""; + + private string CurrentShape => _tab == Tab.VideoGames ? "wide" : ""; + private bool _showSharePanel; private List _shares = []; private string? _shareError; @@ -141,10 +174,16 @@ else if (Data is not null) private string ShareUrl(WishlistShareDto share) => Navigation.ToAbsoluteUri($"shared/wishlist/{share.Token}").ToString(); - protected override Task OnInitializedAsync() => LoadAsync(); + protected override Task OnInitializedAsync() + { + _view = ViewPreference.View; + return LoadAsync(); + } protected override void OnParametersSet() => _tab = Enum.TryParse(TabQuery, out var tab) ? tab : Tab.Movies; + private void OnViewChanged(string view) => _view = view; + private async Task LoadAsync() { await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); From 064d096b9987866ccc0b01f20d8e2823244ff84b Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 28 Jul 2026 04:43:05 +0200 Subject: [PATCH 13/80] Add generic import --- CLAUDE.md | 14 + .../Import/GenericImportApiClient.cs | 28 ++ .../Components/Import/GenericImportPage.razor | 315 ++++++++++++++++++ .../Components/Import/ImportPage.razor | 16 +- ...frastructureServiceCollectionExtensions.cs | 2 + src/Domain/Models/GenericImportPreviewRow.cs | 83 +++++ src/Domain/Models/ImportMediaType.cs | 17 + .../Models/OwnedItemImportCommitCounts.cs | 39 +++ src/Domain/Models/OwnedItemImportInput.cs | 40 +++ src/Domain/Services/GenericImportService.cs | 223 +++++++++++++ .../OwnedItemImportCommitCoordinator.cs | 185 ++++++++++ .../Dto/GenericImportCommitItemDto.cs | 76 +++++ .../Dto/GenericImportCommitRequestDto.cs | 11 + .../Dto/GenericImportCommitResultDto.cs | 46 +++ .../Dto/GenericImportPreviewRowDto.cs | 63 ++++ src/WebApi.Contracts/Dto/ImportMediaType.cs | 16 + .../Controllers/AmazonImportController.cs | 245 ++++---------- .../Controllers/GenericImportController.cs | 176 ++++++++++ .../GenericImportPreviewRowDtoMapper.cs | 17 + src/WebApi/Program.cs | 1 + .../Pages/GenericImportPage.cs | 33 ++ .../Pages/ImportPage.cs | 8 + .../Smoke/GenericImportSmokeTest.cs | 52 +++ .../Support/GenericImportFixtureCsvBuilder.cs | 25 ++ .../GenericImportFixtureCsvBuilder.cs | 42 +++ .../Resources/GenericImportResourceTest.cs | 151 +++++++++ .../Controllers/FreeTierTest.cs | 1 + .../Services/GenericImportServiceTest.cs | 202 +++++++++++ 28 files changed, 1941 insertions(+), 186 deletions(-) create mode 100644 src/BlazorApp/Components/Import/GenericImportApiClient.cs create mode 100644 src/BlazorApp/Components/Import/GenericImportPage.razor create mode 100644 src/Domain/Models/GenericImportPreviewRow.cs create mode 100644 src/Domain/Models/ImportMediaType.cs create mode 100644 src/Domain/Models/OwnedItemImportCommitCounts.cs create mode 100644 src/Domain/Models/OwnedItemImportInput.cs create mode 100644 src/Domain/Services/GenericImportService.cs create mode 100644 src/Domain/Services/OwnedItemImportCommitCoordinator.cs create mode 100644 src/WebApi.Contracts/Dto/GenericImportCommitItemDto.cs create mode 100644 src/WebApi.Contracts/Dto/GenericImportCommitRequestDto.cs create mode 100644 src/WebApi.Contracts/Dto/GenericImportCommitResultDto.cs create mode 100644 src/WebApi.Contracts/Dto/GenericImportPreviewRowDto.cs create mode 100644 src/WebApi.Contracts/Dto/ImportMediaType.cs create mode 100644 src/WebApi/Controllers/GenericImportController.cs create mode 100644 src/WebApi/Mappers/GenericImportPreviewRowDtoMapper.cs create mode 100644 test/BlazorApp.PlaywrightTests/Pages/GenericImportPage.cs create mode 100644 test/BlazorApp.PlaywrightTests/Smoke/GenericImportSmokeTest.cs create mode 100644 test/BlazorApp.PlaywrightTests/Support/GenericImportFixtureCsvBuilder.cs create mode 100644 test/WebApi.IntegrationTests/Resources/GenericImportFixtureCsvBuilder.cs create mode 100644 test/WebApi.IntegrationTests/Resources/GenericImportResourceTest.cs create mode 100644 test/WebApi.UnitTests/Services/GenericImportServiceTest.cs diff --git a/CLAUDE.md b/CLAUDE.md index 6169c932..f1211dda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,6 +196,20 @@ and `SkippedRowTitles` lists exactly which selected rows were skipped as already together they let `GenericVideoGameImportPage.razor` show a reconciling "X of Y selected rows imported" line plus a named list of anything actually skipped, so the user can trust nothing was silently dropped instead of having to guess from the per-item counts alone. +`GenericImportController`/`GenericImportService` (`POST /api/import/generic`, `MemberOnly`) is the fully store-agnostic, column-driven third importer of this shape - the one to reach for by default now, and the one to extend rather than adding another store-specific importer. +It removes every Amazon specificity by reading each field from a canonical, case-insensitive column set (all optional except `Title`) the user reshapes any retailer export into within a spreadsheet - confirmed against a real Rakuten export. +`Vendor` is a per-row column (like the video game importer), and crucially a `Type` column, when present, sets each row's `ImportMediaType` directly (`GenericImportService.ParseMediaType` tolerates the natural spellings a user types: "TV Show", "Video Game", "Film", "Jeu"...), so a well-prepared sheet pre-selects every row's type instead of forcing the per-row picker the Amazon page needs (Amazon's export has no category column). +A blank/unrecognized `Type` falls back to that picker rather than guessing - the same "don't guess when you don't have the info" rule as everywhere else. +Column aliases cover the common real headers (`Product Name`→Title, `ASIN`/`SKU`→`ProductId`, `Total Amount`→Price, `Product Condition`→Condition). +`Vendor` (the store name) and `Website` are two **separate** columns feeding two different owned-copy fields: `Vendor`→the copy's `Vendor` field, `Website`→the copy's `Reference` (a free-text per-item label - product/order URL, seller). They are deliberately not the same input: `Vendor` is NOT aliased to `Website` (unlike Amazon's own parser, whose export calls the storefront "Website" and maps it to vendor). +`GenericImportService.FormatReference` (`"{website} order {orderId} ({productId})"`, falling back to the title when `ProductId` is blank) is the store-agnostic counterpart to Amazon's ASIN reference and the video game importer's product-name one; its per-line-item dedup precision comes from the order id + product id, so the `Website` label being non-unique is harmless. +One deliberate behavioral difference from Amazon: the `Condition` column value is preserved on the created owned copy's `ProductName` ("Product") field rather than dropped as display-only, at the owner's request. + +The per-type create/merge orchestration is **not** duplicated between the two multi-type importers: it lives once in `Domain/Services/OwnedItemImportCommitCoordinator.cs`, which both `AmazonImportController.Commit` and `GenericImportController.Commit` call with a flat `List` (a pure-Domain shape carrying the already-computed reference/provenance text) and read back per-type `OwnedItemImportCommitCounts`. +The coordinator fans the inputs out by `ImportMediaType`, supplies each of the six types' model-construction delegates, and persists the resulting `ComputeCommitPlan` - the six near-identical `if (xItems.Count > 0)` blocks that were inline in the Amazon controller moved here wholesale when the generic importer would otherwise have copied them. +`ImportMediaType` exists as **two** identically-named enums (`Domain.Models` and `WebApi.Contracts.Dto`, mapped by name, same split as every other DTO/Domain enum pair) - a controller that imports both namespaces (Amazon/Generic do, via the `Contracts.Dto` global using) must alias one to disambiguate, same as `CopyType` already needed. +Covered by `GenericImportServiceTest` (unit - the real Rakuten header proves every column alias resolves), `GenericImportResourceTest` (integration - mixed-type preview→commit, `Condition`→Product-field, and re-import dedup), and `GenericImportSmokeTest` (Playwright). + ### Child entities (1-to-many owned by another entity) `CarHistory` (owned by `Car`) and `Episode` (owned by `TvShow`) are separate top-level collections referencing their parent by id (`car_id`, `tv_show_id`), not embedded arrays. diff --git a/src/BlazorApp/Components/Import/GenericImportApiClient.cs b/src/BlazorApp/Components/Import/GenericImportApiClient.cs new file mode 100644 index 00000000..7fecd2ce --- /dev/null +++ b/src/BlazorApp/Components/Import/GenericImportApiClient.cs @@ -0,0 +1,28 @@ +using System.Net.Http.Headers; +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Import; + +public sealed class GenericImportApiClient(HttpClient http) +{ + public async Task> PreviewAsync(Stream csvStream, string fileName) + { + using var content = new MultipartFormDataContent(); + using var fileContent = new StreamContent(csvStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/csv"); + content.Add(fileContent, "file", fileName); + + var response = await http.PostAsync("/api/import/generic/preview", content); + response.EnsureSuccessStatusCode(); + + return (await response.Content.ReadFromJsonAsync>())!; + } + + public async Task CommitAsync(List items) + { + var response = await http.PostAsJsonAsync("/api/import/generic/commit", new GenericImportCommitRequestDto { Items = items }); + response.EnsureSuccessStatusCode(); + + return (await response.Content.ReadFromJsonAsync())!; + } +} diff --git a/src/BlazorApp/Components/Import/GenericImportPage.razor b/src/BlazorApp/Components/Import/GenericImportPage.razor new file mode 100644 index 00000000..0e62c3f8 --- /dev/null +++ b/src/BlazorApp/Components/Import/GenericImportPage.razor @@ -0,0 +1,315 @@ +@page "/import/generic" +@attribute [Authorize] +@using Keeptrack.WebApi.Contracts.Dto +@using Keeptrack.BlazorApp.Components.Inventory.Pages + +
+

Import from a store (CSV)

+
+ +
+

+ Upload a CSV you've shaped in a spreadsheet (from any retailer's order/purchase history). + Only a Title column is required; every other column below is optional and matched case-insensitively: +

+
    +
  • Title (or Product Name) — the item's name.
  • +
  • Type — Book, Movie, TvShow, VideoGame, Gear or Collectible. Pre-selects each row's type; leave blank to pick it here.
  • +
  • Order Date, Order ID, Product Id (or ASIN/SKU), Price (or Total Amount).
  • +
  • Vendor (the store name → the copy's Vendor field) and Website (a product/order URL or seller → the copy's reference).
  • +
  • Author (books), Platform (video games), Year, Condition, ISBN, Copy (Physical/Digital).
  • +
+

Nothing is imported until you click "Import selected" below.

+ +
+ + +
+ + @if (_previewing) + { +

Reading your file…

+ } + + @if (_error is not null) + { +
@_error
+ } +
+ +@if (_rows is not null) +{ +
+
+
@DisplayedRows.Count() row(s) shown
+ +
+ +
+ + + + + + + + + + + + + + @foreach (var row in DisplayedRows) + { + + + + + + + + + + } + +
+ + TitleTypeYearCopyAuthor / PlatformOrder info
+ + @if (row.Preview.AlreadyImported) + { + + } + + + + + + @if (row.MediaType == ImportMediaType.VideoGame) + { + + } + else if (row.MediaType == ImportMediaType.Book) + { + + } + else + { + + } + + @if (!string.IsNullOrWhiteSpace(row.Preview.Vendor)) + { +
@row.Preview.Vendor
+ } + @if (!string.IsNullOrWhiteSpace(row.Preview.Condition)) + { +
@row.Preview.Condition
+ } + @if (row.Preview.OrderDate is not null) + { +
@row.Preview.OrderDate.Value.ToString("yyyy-MM-dd")
+ } + @if (row.Preview.Price is not null) + { +
@row.Preview.Price.Value.ToString("0.00") €
+ } +
+
+ + @if (SelectedRows.Any(r => r.MediaType is null)) + { +

Pick a type for every selected row before importing.

+ } + + @if (SelectedRows.Any(IsMissingPlatform)) + { +

Pick a platform for every selected video game before importing.

+ } + + +
+} + +@if (_commitError is not null) +{ +
@_commitError
+} + +@if (_commitResult is not null) +{ +
+

Books: @_commitResult.BooksCreated created, @_commitResult.BooksMergedInto merged into existing, @_commitResult.BooksSkipped already imported

+

Movies: @_commitResult.MoviesCreated created, @_commitResult.MoviesMergedInto merged into existing, @_commitResult.MoviesSkipped already imported

+

TV shows: @_commitResult.TvShowsCreated created, @_commitResult.TvShowsMergedInto merged into existing, @_commitResult.TvShowsSkipped already imported

+

Video games: @_commitResult.VideoGamesCreated created, @_commitResult.VideoGamesMergedInto merged into existing, @_commitResult.VideoGamesSkipped already imported

+

Gear: @_commitResult.GearCreated created, @_commitResult.GearMergedInto merged into existing, @_commitResult.GearSkipped already imported

+

Collectibles: @_commitResult.CollectiblesCreated created, @_commitResult.CollectiblesMergedInto merged into existing, @_commitResult.CollectiblesSkipped already imported

+
+

@_commitResult.RowsImported of @(_commitResult.RowsImported + _commitResult.SkippedRowTitles.Count) selected row(s) imported.

+ @if (_commitResult.SkippedRowTitles.Count > 0) + { +

Skipped as already imported:

+
    + @foreach (var title in _commitResult.SkippedRowTitles) + { +
  • @title
  • + } +
+ } +
+} + +@code { + private const long MaxFileSize = 20_000_000; + + [Inject] private GenericImportApiClient ImportApi { get; set; } = null!; + + private bool _previewing; + private string? _error; + private List? _rows; + + /// + /// Defaults on - a re-uploaded file is mostly already-imported rows, and hiding them by default is what keeps the review list short. + /// + private bool _hideAlreadyImported = true; + + private bool _committing; + private string? _commitError; + private GenericImportCommitResultDto? _commitResult; + + private IEnumerable DisplayedRows => (_rows ?? []) + .Where(r => !_hideAlreadyImported || !r.Preview.AlreadyImported); + + private IEnumerable SelectedRows => _rows is null ? [] : _rows.Where(r => r.Selected); + + private static bool IsMissingPlatform(EditableRow row) => row.MediaType == ImportMediaType.VideoGame && string.IsNullOrEmpty(row.Platform); + + /// Drives the header checkbox: checked only once every currently-shown row is selected. + private bool AllDisplayedSelected => DisplayedRows.Any() && DisplayedRows.All(r => r.Selected); + + private void SetAllDisplayedSelected(bool selected) + { + foreach (var row in DisplayedRows) + { + row.Selected = selected; + } + } + + private async Task OnFileSelectedAsync(InputFileChangeEventArgs e) + { + _previewing = true; + _error = null; + _rows = null; + _commitResult = null; + _commitError = null; + + try + { + await using var stream = e.File.OpenReadStream(MaxFileSize); + var preview = await ImportApi.PreviewAsync(stream, e.File.Name); + _rows = [.. preview.Select(ToEditableRow)]; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _previewing = false; + } + } + + private async Task CommitAsync() + { + _committing = true; + _commitError = null; + _commitResult = null; + + try + { + var items = SelectedRows.Select(row => new GenericImportCommitItemDto + { + RowId = row.Preview.RowId, + Title = row.Title, + // The original, unedited parsed title - kept separate from the editable row.Title so it can be + // recorded verbatim in the created item's notes and used as the reference's product-id fallback. + SourceTitle = row.Preview.Title, + MediaType = row.MediaType, + Year = row.Year, + Author = row.MediaType == ImportMediaType.Book ? row.Author : null, + Isbn = row.MediaType == ImportMediaType.Book ? row.Preview.Isbn : null, + Platform = row.MediaType == ImportMediaType.VideoGame ? row.Platform : null, + Condition = row.Preview.Condition, + // Echoed back rather than trusting a client-computed Reference string - the server derives the + // actual owned-copy Reference (and dedup key) from these itself. Website feeds the Reference, + // Vendor feeds the Vendor field. + OrderId = row.Preview.OrderId, + ProductId = row.Preview.ProductId, + Vendor = row.Preview.Vendor, + Website = row.Preview.Website, + AcquiredAt = row.Preview.OrderDate, + Price = row.Preview.Price, + CopyType = row.CopyType + }).ToList(); + + _commitResult = await ImportApi.CommitAsync(items); + } + catch (Exception ex) + { + _commitError = ex.Message; + } + finally + { + _committing = false; + } + } + + private static EditableRow ToEditableRow(GenericImportPreviewRowDto preview) => new() + { + Preview = preview, + // Pre-select rows that already know their type (from the "Type" column) and aren't already imported - + // a row with no type needs an explicit pick first, so leaving it unselected is the safe default. + Selected = preview.SuggestedMediaType is not null && !preview.AlreadyImported, + MediaType = preview.SuggestedMediaType, + Title = preview.Title, + Author = preview.Author, + Year = preview.Year, + Platform = preview.Platform, + CopyType = preview.CopyType + }; + + private sealed class EditableRow + { + public required GenericImportPreviewRowDto Preview { get; init; } + public bool Selected { get; set; } + + public string Title { get; set; } = ""; + public int? Year { get; set; } + public ImportMediaType? MediaType { get; set; } + public string? Author { get; set; } + public string? Platform { get; set; } + public CopyType CopyType { get; set; } + } +} diff --git a/src/BlazorApp/Components/Import/ImportPage.razor b/src/BlazorApp/Components/Import/ImportPage.razor index d7d77a17..214253eb 100644 --- a/src/BlazorApp/Components/Import/ImportPage.razor +++ b/src/BlazorApp/Components/Import/ImportPage.razor @@ -2,12 +2,26 @@ @attribute [Authorize]
+

Import from a store (CSV)

+
+ +
+

+ Upload a CSV you've shaped in a spreadsheet from any retailer's order/purchase history. + Only a Title column is required; optional columns (Type, Vendor, Author, Platform, Price, Condition…) pre-fill each row. + Review and pick which items to import as books, movies, TV shows, video games, gear or collectibles. +

+ Import from a CSV +
+ +

Import from Amazon

- Review your Amazon order history and pick which items to import as books (only books are supported so far). + Upload the order-history CSV from Amazon's "Request My Data" export and review which items to import + as books, movies, TV shows, video games, gear or collectibles.

Import from Amazon
diff --git a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 7eab9b56..d2bb9fbd 100644 --- a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -53,6 +53,8 @@ internal static void AddWebApiHttpClient(this IServiceCollection services, strin .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) diff --git a/src/Domain/Models/GenericImportPreviewRow.cs b/src/Domain/Models/GenericImportPreviewRow.cs new file mode 100644 index 00000000..88c1d969 --- /dev/null +++ b/src/Domain/Models/GenericImportPreviewRow.cs @@ -0,0 +1,83 @@ +using System; + +namespace Keeptrack.Domain.Models; + +/// +/// One line item parsed from a generic store/CSV import (any retailer export the user has reshaped into the +/// canonical column set in a spreadsheet), before the user has reviewed/selected it. Transient - never +/// persisted. Produced by and mapped to +/// GenericImportPreviewRowDto for the review UI. +/// Unlike , everything Amazon hardcodes (vendor, the "is this a book" +/// heuristic) is instead read straight from columns here: comes from a +/// "Type" column, from a per-row column, etc. +/// +public class GenericImportPreviewRow +{ + /// + /// Stable within one file, used only to correlate a selected/edited row back to this one at commit time. + /// Never stored. Built from order id + product id + title so a single order's several line items stay distinct. + /// + public required string RowId { get; set; } + + /// The item title (from the "Title"/"Product Name" column). + public required string Title { get; set; } + + /// + /// The media type read from the optional "Type" column, or null when that column is blank/absent or holds + /// an unrecognized value - in which case the review UI forces an explicit per-row pick before commit. + /// + public ImportMediaType? SuggestedMediaType { get; set; } + + /// The order/invoice number, if the file has one - part of the dedup reference. + public string? OrderId { get; set; } + + /// + /// The store's own stable per-product id (an Amazon ASIN, a retailer SKU...) - the disambiguator that + /// keeps two different line items sharing one order distinct in the dedup reference. Falls back to the + /// title when absent. + /// + public string? ProductId { get; set; } + + public DateOnly? OrderDate { get; set; } + + /// What was paid for this item, if a price/amount column is present. + public decimal? Price { get; set; } + + /// The store name, read per row (never hardcoded) - populates the owned copy's Vendor field. + public string? Vendor { get; set; } + + /// + /// The "Website" column - a free-text per-item label (product/order URL, seller...) that goes into the owned + /// copy's Reference (see ), independent of Vendor. + /// + public string? Website { get; set; } + + /// Book author, if an "Author" column is present - pre-fills the created book. + public string? Author { get; set; } + + /// Video game platform, if a "Platform" column is present - pre-fills the created game's copy. + public string? Platform { get; set; } + + /// Release/publication year, if a "Year" column is present. + public int? Year { get; set; } + + /// + /// Item condition ("New", "Used"...) from a "Condition"/"Product Condition" column - preserved on the + /// created owned copy's Product field rather than discarded (the one behavioral difference from the Amazon + /// importer, which treats condition as display-only). + /// + public string? Condition { get; set; } + + /// Book ISBN, if an "ISBN" column is present. + public string? Isbn { get; set; } + + /// Whether the row's "Copy"/"Format" column indicated a digital (vs. the default physical) copy. + public CopyType CopyType { get; set; } + + /// + /// True when an existing item already has an owned copy referencing this order line - see + /// . Defaults to unchecked in the review UI, + /// but stays selectable in case the user wants to re-import anyway. + /// + public bool AlreadyImported { get; set; } +} diff --git a/src/Domain/Models/ImportMediaType.cs b/src/Domain/Models/ImportMediaType.cs new file mode 100644 index 00000000..a3b680df --- /dev/null +++ b/src/Domain/Models/ImportMediaType.cs @@ -0,0 +1,17 @@ +namespace Keeptrack.Domain.Models; + +/// +/// Which trackable item type a generic import row should be created/merged as. The Domain-side counterpart of +/// the ImportMediaType DTO enum (member names kept identical, mapped by name) - the generic importer +/// reads it per row from an optional "Type" column, falling back to a per-row picker in the review UI when a +/// row's Type is blank or unrecognized. +/// +public enum ImportMediaType +{ + Book, + Movie, + TvShow, + VideoGame, + Gear, + Collectible +} diff --git a/src/Domain/Models/OwnedItemImportCommitCounts.cs b/src/Domain/Models/OwnedItemImportCommitCounts.cs new file mode 100644 index 00000000..7c6cdcbd --- /dev/null +++ b/src/Domain/Models/OwnedItemImportCommitCounts.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; + +namespace Keeptrack.Domain.Models; + +/// +/// The per-type outcome of , plus the +/// reconciling per-row totals every owned-item importer surfaces so the user can trust nothing was silently +/// dropped: + the sum of every type's always +/// equals the number of rows submitted (several rows sharing a title consolidate into one item, which makes +/// the created/merged counts add up to less than the row count without any loss). +/// +public sealed class OwnedItemImportCommitCounts +{ + public TypeCounts Books { get; } = new(); + public TypeCounts Movies { get; } = new(); + public TypeCounts TvShows { get; } = new(); + public TypeCounts VideoGames { get; } = new(); + public TypeCounts Gear { get; } = new(); + public TypeCounts Collectibles { get; } = new(); + + /// The true per-row count of rows that got an owned copy added, across every type. + public int RowsImported { get; set; } + + /// The title of each row skipped as an already-imported duplicate, in submission order. + public List SkippedTitles { get; } = []; +} + +/// Created / merged-into / skipped counts for one trackable type within a single commit. +public sealed class TypeCounts +{ + /// Brand new items created. + public int Created { get; set; } + + /// Existing (or created-earlier-this-batch) items that received an additional owned copy. + public int MergedInto { get; set; } + + /// Rows whose reference already matched an existing owned copy - not duplicated. + public int Skipped { get; set; } +} diff --git a/src/Domain/Models/OwnedItemImportInput.cs b/src/Domain/Models/OwnedItemImportInput.cs new file mode 100644 index 00000000..985a3ad4 --- /dev/null +++ b/src/Domain/Models/OwnedItemImportInput.cs @@ -0,0 +1,40 @@ +namespace Keeptrack.Domain.Models; + +/// +/// One user-selected/edited import row, already translated from the web contract into pure Domain shape, ready +/// for to create/merge across whichever trackable type +/// names. Shared by every owned-item importer (Amazon, generic store/CSV) so the +/// six-type create/merge orchestration lives in exactly one place - the two importers differ only in how they +/// build 's reference text and , which they compute +/// themselves before handing the input over. +/// +public sealed class OwnedItemImportInput +{ + public required ImportMediaType MediaType { get; init; } + + /// The (possibly user-edited) title to create/match the item under. + public required string Title { get; init; } + + /// Notes stamped onto a newly-created item (never onto a pre-existing one) recording the source's + /// original listing text - see the importers' own BuildProvenanceNotes. + public required string ProvenanceNotes { get; init; } + + public int? Year { get; init; } + + /// Book-only - the created book's author. Null (stored as empty) for every other type. + public string? Author { get; init; } + + /// Book-only - the created book's ISBN. + public string? Isbn { get; init; } + + /// VideoGame-only - the platform name for the created game's copy. Required when + /// is (validated by the caller). + public string? Platform { get; init; } + + /// + /// The owned copy to attach. For every non-video-game type this is added to the item's + /// OwnedVersions as-is; for a video game its shared fields (copy type, price, vendor, acquired date, + /// reference, product name) seed a alongside . + /// + public required OwnedVersionModel OwnedVersion { get; init; } +} diff --git a/src/Domain/Services/GenericImportService.cs b/src/Domain/Services/GenericImportService.cs new file mode 100644 index 00000000..714e36a6 --- /dev/null +++ b/src/Domain/Services/GenericImportService.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using CsvHelper; +using CsvHelper.Configuration; +using CsvHelper.Configuration.Attributes; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Services; + +/// +/// Parses a generic store/CSV import into review rows. Pure: bytes in, rows out, no repository access - +/// alreadyImportedReferences below is computed by the caller from already-fetched data so this stays +/// testable without a database. +/// +/// This is the store-agnostic generalization of : instead of hardcoding +/// Amazon's vendor, columns, and its ISBN-based "is this a book" guess, every field is read from a canonical, +/// case-insensitive column set (all optional except the title) the user reshapes their own export into within a +/// spreadsheet. Notably a "Type" column, when present, decides each row's media type directly - no per-row +/// guessing - which is the whole reason the Amazon page's per-row picker can be pre-filled here. +/// +public static class GenericImportService +{ + private sealed class GenericImportRecord + { + // Title is the one required column. Its aliases cover the common retailer header ("Product Name"). + [Name("Title", "Product Name")] + public required string Title { get; set; } + + [Optional] + [Name("Type")] + public string? Type { get; set; } + + [Optional] + [Name("Order Date", "Date")] + public string? OrderDate { get; set; } + + [Optional] + [Name("Order ID", "Order Id")] + public string? OrderId { get; set; } + + [Optional] + [Name("Product Id", "Product ID", "ASIN", "SKU")] + public string? ProductId { get; set; } + + [Optional] + [Name("Price", "Total Amount", "Amount")] + public string? Price { get; set; } + + // The store name - populates the owned copy's Vendor field only. Its own column, separate from Website + // below (which feeds the copy's Reference, not the Vendor field). + [Optional] + [Name("Vendor", "Store")] + public string? Vendor { get; set; } + + // Free-text label that goes into the owned copy's Reference (a product/order URL, seller...). Feeds the + // Reference only, never the Vendor field. + [Optional] + [Name("Website")] + public string? Website { get; set; } + + [Optional] + [Name("Author")] + public string? Author { get; set; } + + [Optional] + [Name("Platform")] + public string? Platform { get; set; } + + [Optional] + [Name("Year")] + public string? Year { get; set; } + + [Optional] + [Name("Condition", "Product Condition")] + public string? Condition { get; set; } + + [Optional] + [Name("ISBN")] + public string? Isbn { get; set; } + + [Optional] + [Name("Copy", "Format")] + public string? Copy { get; set; } + } + + private static readonly CsvConfiguration s_csvConfiguration = new(CultureInfo.InvariantCulture) + { + PrepareHeaderForMatch = args => args.Header.Trim().ToLowerInvariant() + }; + + public static List BuildPreview(Stream csvStream, IReadOnlySet alreadyImportedReferences) + { + using var reader = new StreamReader(csvStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + using var csv = new CsvReader(reader, s_csvConfiguration); + var records = csv.GetRecords().ToList(); + + return records.Select(record => + { + var title = record.Title.Trim(); + var orderId = Trimmed(record.OrderId); + var productId = Trimmed(record.ProductId); + var vendor = Trimmed(record.Vendor); + var website = Trimmed(record.Website); + + return new GenericImportPreviewRow + { + RowId = $"{orderId}:{productId}:{title}", + Title = title, + SuggestedMediaType = ParseMediaType(record.Type), + OrderId = orderId, + ProductId = productId, + OrderDate = ParseDate(record.OrderDate), + Price = ParsePrice(record.Price), + Vendor = vendor, + Website = website, + Author = Trimmed(record.Author), + Platform = Trimmed(record.Platform), + Year = ParseYear(record.Year), + Condition = Trimmed(record.Condition), + Isbn = Trimmed(record.Isbn), + CopyType = ParseCopyType(record.Copy), + AlreadyImported = alreadyImportedReferences.Contains(FormatReference(website, orderId, productId, title)) + }; + }).ToList(); + } + + /// + /// The one place that formats an owned copy's Reference for an imported row - human-readable, and + /// also the exact-match dedup key + /// looks for on a later re-import. The leading label is the value (the free-text + /// "Website" column - a product/order URL, seller...), NOT the vendor: the reference is deliberately + /// independent of the Vendor field. Dedup uniqueness comes from the product id (falling back to the title + /// when the file has none) alongside the order id, not from that label: a single order commonly contains + /// several different line items, and order-id-only matching would silently skip every sibling once any one + /// of them had been imported - the same class of bug + /// avoids with the ASIN and avoids with the + /// product name. So a non-unique label is harmless here. + /// + public static string FormatReference(string? website, string? orderId, string? productId, string title) + { + var prefix = string.IsNullOrWhiteSpace(website) ? "Order" : $"{website.Trim()} order"; + var order = string.IsNullOrWhiteSpace(orderId) ? "?" : orderId.Trim(); + var descriptor = string.IsNullOrWhiteSpace(productId) ? title : productId.Trim(); + return $"{prefix} {order} ({descriptor})"; + } + + /// + /// Reference-data linking is expected to overwrite the created item's title (and, for a book, its ISBN) + /// with the provider's canonical values, and the user may have already cleaned up the title before commit + /// - so this is the one place the source export's original listing text is preserved, for an item created + /// by this import. Only used at creation time: a pre-existing item's provenance isn't this import's to + /// invent. is null for every domain but Book. + /// + public static string BuildProvenanceNotes(string? vendor, string sourceTitle, string? isbn) + { + var source = string.IsNullOrWhiteSpace(vendor) ? "import" : vendor.Trim(); + var lines = new List { $"Title from {source}: {sourceTitle}" }; + if (!string.IsNullOrWhiteSpace(isbn)) lines.Add($"ISBN from {source}: {isbn.Trim()}"); + return string.Join('\n', lines); + } + + /// + /// Maps a free-text "Type" column value onto , tolerating the natural + /// spellings a user would type in a spreadsheet ("TV Show", "Video Game", "Film", "Jeu"...). Returns null + /// for a blank or unrecognized value, which the review UI turns into a required per-row pick rather than + /// guessing - the same "don't guess when you don't have the info" principle used across the app. + /// + public static ImportMediaType? ParseMediaType(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + + var normalized = new string(raw.Trim().ToLowerInvariant().Where(c => c is not (' ' or '-' or '_')).ToArray()); + return normalized switch + { + "book" or "books" or "livre" or "livres" => ImportMediaType.Book, + "movie" or "movies" or "film" or "films" => ImportMediaType.Movie, + "tvshow" or "tvshows" or "tv" or "show" or "shows" or "series" or "serie" => ImportMediaType.TvShow, + "videogame" or "videogames" or "game" or "games" or "jeu" or "jeux" => ImportMediaType.VideoGame, + "gear" or "equipment" or "equipement" => ImportMediaType.Gear, + "collectible" or "collectibles" or "collectable" or "collectables" => ImportMediaType.Collectible, + _ => null + }; + } + + private static string? Trimmed(string? raw) => string.IsNullOrWhiteSpace(raw) ? null : raw.Trim(); + + private static CopyType ParseCopyType(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return CopyType.Physical; + return raw.Trim().ToLowerInvariant() switch + { + "digital" or "digitale" or "numérique" or "numerique" or "dematerialise" or "dématérialisé" => CopyType.Digital, + _ => CopyType.Physical + }; + } + + /// + /// Strips a leading apostrophe (a spreadsheet's Excel-formula-injection guard, e.g. the literal '-5) + /// before parsing - the same defense needs. + /// + private static decimal? ParsePrice(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + + var cleaned = raw.Trim().Trim('\'').Replace("€", "").Replace("$", "").Replace("£", "").Trim(); + return decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var value) ? value : null; + } + + private static int? ParseYear(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + return int.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null; + } + + private static DateOnly? ParseDate(string? raw) => + !string.IsNullOrWhiteSpace(raw) && DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) + ? DateOnly.FromDateTime(parsed.UtcDateTime) + : null; +} diff --git a/src/Domain/Services/OwnedItemImportCommitCoordinator.cs b/src/Domain/Services/OwnedItemImportCommitCoordinator.cs new file mode 100644 index 00000000..568e54cf --- /dev/null +++ b/src/Domain/Services/OwnedItemImportCommitCoordinator.cs @@ -0,0 +1,185 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; + +namespace Keeptrack.Domain.Services; + +/// +/// The single place the "create/merge a set of reviewed import rows across the six owned-item types" algorithm +/// lives. Both owned-item importers (AmazonImportController and GenericImportController) hand it a +/// flat list of and get back per-type - +/// neither duplicates the per-type branching. The actual merge/dedup decision for each type still comes from the +/// generic ; this coordinator only +/// fans the inputs out by , supplies each type's model-construction delegates, and +/// persists the resulting plan. +/// Repository access lives here (not in a controller) precisely so the two controllers share it; that's the same +/// tradeoff already made by taking the owner's already-fetched items. +/// +public static class OwnedItemImportCommitCoordinator +{ + public static async Task CommitAsync( + string ownerId, + IReadOnlyList inputs, + IBookRepository bookRepository, + IMovieRepository movieRepository, + ITvShowRepository tvShowRepository, + IVideoGameRepository videoGameRepository, + IGearRepository gearRepository, + ICollectibleRepository collectibleRepository) + { + var counts = new OwnedItemImportCommitCounts(); + + await CommitTypeAsync( + inputs, ImportMediaType.Book, counts.Books, counts, ownerId, bookRepository, + new BookModel { OwnerId = ownerId, Title = string.Empty, Author = string.Empty }, + b => b.Title, b => b.OwnedVersions.Select(v => v.Reference), + input => new BookModel + { + OwnerId = ownerId, + Title = input.Title, + Author = input.Author ?? string.Empty, + Year = input.Year, + Isbn = input.Isbn, + Notes = input.ProvenanceNotes, + OwnedVersions = [input.OwnedVersion] + }, + (book, input) => book.OwnedVersions.Add(input.OwnedVersion)); + + await CommitTypeAsync( + inputs, ImportMediaType.Movie, counts.Movies, counts, ownerId, movieRepository, + new MovieModel { OwnerId = ownerId, Title = string.Empty }, + m => m.Title, m => m.OwnedVersions.Select(v => v.Reference), + input => new MovieModel + { + OwnerId = ownerId, + Title = input.Title, + Year = input.Year, + Notes = input.ProvenanceNotes, + OwnedVersions = [input.OwnedVersion] + }, + (movie, input) => movie.OwnedVersions.Add(input.OwnedVersion)); + + await CommitTypeAsync( + inputs, ImportMediaType.TvShow, counts.TvShows, counts, ownerId, tvShowRepository, + new TvShowModel { OwnerId = ownerId, Title = string.Empty }, + t => t.Title, t => t.OwnedVersions.Select(v => v.Reference), + input => new TvShowModel + { + OwnerId = ownerId, + Title = input.Title, + Year = input.Year, + Notes = input.ProvenanceNotes, + OwnedVersions = [input.OwnedVersion] + }, + (tvShow, input) => tvShow.OwnedVersions.Add(input.OwnedVersion)); + + await CommitTypeAsync( + inputs, ImportMediaType.VideoGame, counts.VideoGames, counts, ownerId, videoGameRepository, + new VideoGameModel { OwnerId = ownerId, Title = string.Empty }, + g => g.Title, g => g.Platforms.Select(p => p.Reference), + input => new VideoGameModel + { + OwnerId = ownerId, + Title = input.Title, + Year = input.Year, + Notes = input.ProvenanceNotes, + Platforms = [ToPlatform(input)] + }, + (game, input) => game.Platforms.Add(ToPlatform(input))); + + await CommitTypeAsync( + inputs, ImportMediaType.Gear, counts.Gear, counts, ownerId, gearRepository, + new GearModel { OwnerId = ownerId, Title = string.Empty }, + g => g.Title, g => g.OwnedVersions.Select(v => v.Reference), + input => new GearModel + { + OwnerId = ownerId, + Title = input.Title, + Year = input.Year, + Notes = input.ProvenanceNotes, + OwnedVersions = [input.OwnedVersion] + }, + (gear, input) => gear.OwnedVersions.Add(input.OwnedVersion)); + + await CommitTypeAsync( + inputs, ImportMediaType.Collectible, counts.Collectibles, counts, ownerId, collectibleRepository, + new CollectibleModel { OwnerId = ownerId, Title = string.Empty }, + c => c.Title, c => c.OwnedVersions.Select(v => v.Reference), + input => new CollectibleModel + { + OwnerId = ownerId, + Title = input.Title, + Year = input.Year, + Notes = input.ProvenanceNotes, + OwnedVersions = [input.OwnedVersion] + }, + (collectible, input) => collectible.OwnedVersions.Add(input.OwnedVersion)); + + return counts; + } + + /// + /// A video game's owned copy is a (with a required platform), not an + /// - so its shared purchase fields are copied off the input's owned version + /// onto a fresh platform entry. Built fresh per call since each input maps to exactly one create-or-append. + /// + private static VideoGamePlatformModel ToPlatform(OwnedItemImportInput input) => new() + { + Platform = input.Platform!, + CopyType = input.OwnedVersion.CopyType, + ProductName = input.OwnedVersion.ProductName, + Price = input.OwnedVersion.Price, + Vendor = input.OwnedVersion.Vendor, + AcquiredAt = input.OwnedVersion.AcquiredAt, + Reference = input.OwnedVersion.Reference + }; + + private static async Task CommitTypeAsync( + IReadOnlyList allInputs, + ImportMediaType mediaType, + TypeCounts typeCounts, + OwnedItemImportCommitCounts counts, + string ownerId, + IDataRepository repository, + TModel blankSample, + System.Func getExistingTitle, + System.Func> getExistingReferences, + System.Func createNew, + System.Action appendOwnedCopy) + where TModel : class, IHasIdAndOwnerId + { + var inputs = allInputs.Where(i => i.MediaType == mediaType).ToList(); + if (inputs.Count == 0) + { + return; + } + + var existing = (await repository.FindAllAsync(ownerId, 1, int.MaxValue, null, blankSample)).Items; + + var adapter = new OwnedItemImportAdapter( + getExistingTitle, getExistingReferences, + i => i.Title, i => i.OwnedVersion.Reference, + createNew, appendOwnedCopy); + + var plan = OwnedItemImportMergeService.ComputeCommitPlan(existing, inputs, adapter); + + foreach (var item in plan.ItemsToCreate) + { + await repository.CreateAsync(item); + } + + foreach (var item in plan.ItemsToUpdate) + { + await repository.UpdateAsync(item.Id!, item, ownerId); + } + + typeCounts.Created = plan.ItemsToCreate.Count; + typeCounts.MergedInto = plan.ItemsToUpdate.Count; + typeCounts.Skipped = plan.OwnedCopiesSkipped; + counts.RowsImported += plan.OwnedCopiesAdded; + counts.SkippedTitles.AddRange(plan.SkippedTitles); + } +} diff --git a/src/WebApi.Contracts/Dto/GenericImportCommitItemDto.cs b/src/WebApi.Contracts/Dto/GenericImportCommitItemDto.cs new file mode 100644 index 00000000..9bbbf895 --- /dev/null +++ b/src/WebApi.Contracts/Dto/GenericImportCommitItemDto.cs @@ -0,0 +1,76 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One row selected for import, carrying whatever the user reviewed/edited in the review table. +/// +public class GenericImportCommitItemDto +{ + /// + /// The this came from. Not used server-side beyond echoing + /// it back in error messages - the row's data is taken entirely from this DTO's own fields. + /// + public required string RowId { get; set; } + + public required string Title { get; set; } + + /// + /// The title exactly as the source file listed it, even if was edited in the review UI + /// - recorded in the created item's notes, since reference-data linking is expected to overwrite + /// later. + /// + public required string SourceTitle { get; set; } + + /// + /// Which trackable item type to create/merge this row as. Nullable and validated as required server-side + /// (same as for a video game) so a row whose "Type" column was blank can't be + /// silently committed as the wrong type - the reviewer must pick one. + /// + public ImportMediaType? MediaType { get; set; } + + public int? Year { get; set; } + + /// Book-only - ignored for every other . + public string? Author { get; set; } + + /// Book-only - ignored for every other . + public string? Isbn { get; set; } + + /// + /// VideoGame-only - required and validated server-side when is + /// , ignored otherwise. + /// + public string? Platform { get; set; } + + /// + /// Item condition ("New", "Used"...) - persisted onto the created owned copy's Product field rather than + /// discarded, so the retailer's condition is not lost on import. + /// + public string? Condition { get; set; } + + /// + /// The order number and product id, echoed back from the preview row - together with the vendor they're the + /// server-derived owned-copy Reference (see GenericImportService.FormatReference), which also + /// doubles as the exact dedup key. Echoed rather than accepting a client-supplied Reference so the + /// format can't drift from what a later re-preview checks against, and so a single order's several line + /// items stay distinct from one another. + /// + public string? OrderId { get; set; } + + public string? ProductId { get; set; } + + public string? Vendor { get; set; } + + /// + /// The "Website" column value, echoed back from the preview row - the server puts it into the owned copy's + /// Reference (and dedup key) via GenericImportService.FormatReference, independent of the Vendor field. + /// + public string? Website { get; set; } + + public DateOnly? AcquiredAt { get; set; } + + public decimal? Price { get; set; } + + public CopyType CopyType { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/GenericImportCommitRequestDto.cs b/src/WebApi.Contracts/Dto/GenericImportCommitRequestDto.cs new file mode 100644 index 00000000..efe1161e --- /dev/null +++ b/src/WebApi.Contracts/Dto/GenericImportCommitRequestDto.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// The set of generic-import rows the user picked in the review UI, ready to be created/merged. +/// +public class GenericImportCommitRequestDto +{ + public required List Items { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/GenericImportCommitResultDto.cs b/src/WebApi.Contracts/Dto/GenericImportCommitResultDto.cs new file mode 100644 index 00000000..670e66a1 --- /dev/null +++ b/src/WebApi.Contracts/Dto/GenericImportCommitResultDto.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Outcome of committing a selected set of generic-import rows, broken down per trackable item type - only the +/// types actually present in the commit request end up non-zero - plus the reconciling per-row totals. +/// +public class GenericImportCommitResultDto +{ + public int BooksCreated { get; set; } + public int BooksMergedInto { get; set; } + public int BooksSkipped { get; set; } + + public int MoviesCreated { get; set; } + public int MoviesMergedInto { get; set; } + public int MoviesSkipped { get; set; } + + public int TvShowsCreated { get; set; } + public int TvShowsMergedInto { get; set; } + public int TvShowsSkipped { get; set; } + + public int VideoGamesCreated { get; set; } + public int VideoGamesMergedInto { get; set; } + public int VideoGamesSkipped { get; set; } + + public int GearCreated { get; set; } + public int GearMergedInto { get; set; } + public int GearSkipped { get; set; } + + public int CollectiblesCreated { get; set; } + public int CollectiblesMergedInto { get; set; } + public int CollectiblesSkipped { get; set; } + + /// + /// The true per-row count of rows that got an owned copy added, whether onto a brand-new item or an + /// existing/already-created-this-batch one. The per-type Created/MergedInto counts are per distinct item, + /// so rows sharing a title consolidate and make them add up to less than the selected-row count without any + /// loss. RowsImported + the sum of every type's *Skipped always equals the number of rows + /// submitted - the reconciling total to show the user so they can trust nothing was silently dropped. + /// + public int RowsImported { get; set; } + + /// The title of each row skipped as an already-imported duplicate, so the user can see exactly which. + public List SkippedRowTitles { get; set; } = []; +} diff --git a/src/WebApi.Contracts/Dto/GenericImportPreviewRowDto.cs b/src/WebApi.Contracts/Dto/GenericImportPreviewRowDto.cs new file mode 100644 index 00000000..382c44d1 --- /dev/null +++ b/src/WebApi.Contracts/Dto/GenericImportPreviewRowDto.cs @@ -0,0 +1,63 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One line item parsed from an uploaded generic store/CSV import, awaiting the user's review before anything +/// is imported. Everything the Amazon importer hardcodes (vendor, the media type) is instead read from +/// canonical columns here. See for what gets sent back once selected. +/// +public class GenericImportPreviewRowDto +{ + /// Correlates a selected/edited row back to this one at commit time. Stable within one file, never stored. + public required string RowId { get; set; } + + /// The item title (from the "Title"/"Product Name" column). + public required string Title { get; set; } + + /// + /// The media type read from the optional "Type" column, or null when that column is blank/absent or holds + /// an unrecognized value - the review UI then forces an explicit per-row pick before commit. + /// + public ImportMediaType? SuggestedMediaType { get; set; } + + /// The order/invoice number, if present - part of the dedup reference. + public string? OrderId { get; set; } + + /// The store's own stable per-product id (ASIN, SKU...) - dedup disambiguator, falls back to the title. + public string? ProductId { get; set; } + + public DateOnly? OrderDate { get; set; } + + public decimal? Price { get; set; } + + /// The store name, read per row - populates the owned copy's Vendor field. + public string? Vendor { get; set; } + + /// The "Website" column - a free-text per-item label (product/order URL, seller...) that goes into + /// the owned copy's Reference, independent of Vendor. + public string? Website { get; set; } + + /// Book author, if an "Author" column is present. + public string? Author { get; set; } + + /// Video game platform, if a "Platform" column is present. + public string? Platform { get; set; } + + public int? Year { get; set; } + + /// Item condition ("New", "Used"...) - preserved on the created copy's Product field. + public string? Condition { get; set; } + + /// Book ISBN, if an "ISBN" column is present. + public string? Isbn { get; set; } + + /// Physical (default) or Digital, from a "Copy"/"Format" column. + public CopyType CopyType { get; set; } + + /// + /// True when an existing item already has an owned copy referencing this order line. Defaults to + /// unchecked/hidden in the review UI, but stays selectable in case the user wants to re-import anyway. + /// + public bool AlreadyImported { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/ImportMediaType.cs b/src/WebApi.Contracts/Dto/ImportMediaType.cs new file mode 100644 index 00000000..cec36eea --- /dev/null +++ b/src/WebApi.Contracts/Dto/ImportMediaType.cs @@ -0,0 +1,16 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Which trackable item type a generic import row should be created/merged as. Read per row from an optional +/// "Type" column when the file has one, otherwise picked in the review UI. Member names are kept identical to +/// the Domain ImportMediaType enum so the mapper can map them by name. +/// +public enum ImportMediaType +{ + Book, + Movie, + TvShow, + VideoGame, + Gear, + Collectible +} diff --git a/src/WebApi/Controllers/AmazonImportController.cs b/src/WebApi/Controllers/AmazonImportController.cs index 372072b7..5033ad30 100644 --- a/src/WebApi/Controllers/AmazonImportController.cs +++ b/src/WebApi/Controllers/AmazonImportController.cs @@ -6,6 +6,9 @@ using Keeptrack.WebApi.Mappers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +// Both Domain.Models (imported above) and Contracts.Dto (a global using) declare ImportMediaType/CopyType. +// The DTOs the request carries use the Contracts ones; these aliases keep the mapping below unambiguous. +using AmazonMediaType = Keeptrack.WebApi.Contracts.Dto.AmazonImportMediaType; namespace Keeptrack.WebApi.Controllers; @@ -14,6 +17,9 @@ namespace Keeptrack.WebApi.Controllers; /// UI as books, movies, TV shows, video games, gear, or collectibles (picked per row - see ). /// Synchronous on both ends: unlike the TV Time import, there is no external API call in the loop, so even /// a multi-year export completes well within a normal request. +/// The create/merge/dedup work is delegated to the shared (the +/// same engine the generic store/CSV importer uses); this controller only parses Amazon's specific export and +/// builds the reference/provenance text that is genuinely Amazon-specific. ///
[ApiController] [Authorize(Policy = "MemberOnly")] @@ -76,7 +82,7 @@ public async Task>> Preview(IFormFil /// Creates/updates items from the rows the user selected in the review UI, grouped by the media type /// each row was assigned. A row whose (normalized) title matches an existing item of the same type - or /// one created earlier in this same request - gets an additional owned copy instead of a duplicate - /// item; see . + /// item; see . ///
[HttpPost("commit")] [ProducesResponseType(200)] @@ -84,7 +90,6 @@ public async Task>> Preview(IFormFil public async Task> Commit(AmazonImportCommitRequestDto request) { var ownerId = this.GetUserId(); - var result = new AmazonImportCommitResultDto(); var itemMissingMediaType = request.Items.FirstOrDefault(item => item.MediaType is null); if (itemMissingMediaType is not null) @@ -92,206 +97,76 @@ public async Task> Commit(AmazonImport throw new ArgumentException($"A media type is required to import '{itemMissingMediaType.Title}'."); } - var bookItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.Book).ToList(); - var movieItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.Movie).ToList(); - var tvShowItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.TvShow).ToList(); - var videoGameItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.VideoGame).ToList(); - var gearItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.Gear).ToList(); - var collectibleItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.Collectible).ToList(); - - var videoGameItemMissingPlatform = videoGameItems.FirstOrDefault(item => string.IsNullOrWhiteSpace(item.Platform)); + var videoGameItemMissingPlatform = request.Items.FirstOrDefault(item => + item.MediaType == AmazonMediaType.VideoGame && string.IsNullOrWhiteSpace(item.Platform)); if (videoGameItemMissingPlatform is not null) { throw new ArgumentException($"A platform is required to import '{videoGameItemMissingPlatform.Title}' as a video game."); } - if (bookItems.Count > 0) - { - var existingBooks = await FindAllAsync(bookRepository, ownerId, new BookModel { OwnerId = ownerId, Title = string.Empty, Author = string.Empty }); - var (created, mergedInto, skipped) = await CommitAsync( - bookRepository, existingBooks, bookItems.Select(ToOwnedItemRequestItem).ToList(), - new OwnedItemImportAdapter( - b => b.Title, b => b.OwnedVersions.Select(v => v.Reference), - i => i.Title, i => i.OwnedVersion.Reference, - item => new BookModel - { - OwnerId = ownerId, - Title = item.Title, - Author = string.Empty, - Year = item.Year, - Isbn = item.Isbn, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, item.Isbn), - OwnedVersions = [item.OwnedVersion] - }, - (book, item) => book.OwnedVersions.Add(item.OwnedVersion)), ownerId); - (result.BooksCreated, result.BooksMergedInto, result.BooksSkipped) = (created, mergedInto, skipped); - } - - if (movieItems.Count > 0) - { - var existingMovies = await FindAllAsync(movieRepository, ownerId, new MovieModel { OwnerId = ownerId, Title = string.Empty }); - var (created, mergedInto, skipped) = await CommitAsync( - movieRepository, existingMovies, movieItems.Select(ToOwnedItemRequestItem).ToList(), - new OwnedItemImportAdapter( - m => m.Title, m => m.OwnedVersions.Select(v => v.Reference), - i => i.Title, i => i.OwnedVersion.Reference, - item => new MovieModel - { - OwnerId = ownerId, - Title = item.Title, - Year = item.Year, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), - OwnedVersions = [item.OwnedVersion] - }, - (movie, item) => movie.OwnedVersions.Add(item.OwnedVersion)), ownerId); - (result.MoviesCreated, result.MoviesMergedInto, result.MoviesSkipped) = (created, mergedInto, skipped); - } - - if (tvShowItems.Count > 0) - { - var existingTvShows = await FindAllAsync(tvShowRepository, ownerId, new TvShowModel { OwnerId = ownerId, Title = string.Empty }); - var (created, mergedInto, skipped) = await CommitAsync( - tvShowRepository, existingTvShows, tvShowItems.Select(ToOwnedItemRequestItem).ToList(), - new OwnedItemImportAdapter( - t => t.Title, t => t.OwnedVersions.Select(v => v.Reference), - i => i.Title, i => i.OwnedVersion.Reference, - item => new TvShowModel - { - OwnerId = ownerId, - Title = item.Title, - Year = item.Year, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), - OwnedVersions = [item.OwnedVersion] - }, - (tvShow, item) => tvShow.OwnedVersions.Add(item.OwnedVersion)), ownerId); - (result.TvShowsCreated, result.TvShowsMergedInto, result.TvShowsSkipped) = (created, mergedInto, skipped); - } - - if (videoGameItems.Count > 0) - { - var existingVideoGames = await FindAllAsync(videoGameRepository, ownerId, new VideoGameModel { OwnerId = ownerId, Title = string.Empty }); - var (created, mergedInto, skipped) = await CommitAsync( - videoGameRepository, existingVideoGames, videoGameItems.Select(ToVideoGameRequestItem).ToList(), - new OwnedItemImportAdapter( - g => g.Title, g => g.Platforms.Select(p => p.Reference), - i => i.Title, i => i.Platform.Reference, - item => new VideoGameModel - { - OwnerId = ownerId, - Title = item.Title, - Year = item.Year, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), - Platforms = [item.Platform] - }, - (game, item) => game.Platforms.Add(item.Platform)), ownerId); - (result.VideoGamesCreated, result.VideoGamesMergedInto, result.VideoGamesSkipped) = (created, mergedInto, skipped); - } + var inputs = request.Items.Select(ToInput).ToList(); - if (gearItems.Count > 0) - { - var existingGear = await FindAllAsync(gearRepository, ownerId, new GearModel { OwnerId = ownerId, Title = string.Empty }); - var (created, mergedInto, skipped) = await CommitAsync( - gearRepository, existingGear, gearItems.Select(ToOwnedItemRequestItem).ToList(), - new OwnedItemImportAdapter( - g => g.Title, g => g.OwnedVersions.Select(v => v.Reference), - i => i.Title, i => i.OwnedVersion.Reference, - item => new GearModel - { - OwnerId = ownerId, - Title = item.Title, - Year = item.Year, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), - OwnedVersions = [item.OwnedVersion] - }, - (gear, item) => gear.OwnedVersions.Add(item.OwnedVersion)), ownerId); - (result.GearCreated, result.GearMergedInto, result.GearSkipped) = (created, mergedInto, skipped); - } + var counts = await OwnedItemImportCommitCoordinator.CommitAsync( + ownerId, inputs, + bookRepository, movieRepository, tvShowRepository, videoGameRepository, gearRepository, collectibleRepository); - if (collectibleItems.Count > 0) + return Ok(new AmazonImportCommitResultDto { - var existingCollectibles = await FindAllAsync(collectibleRepository, ownerId, new CollectibleModel { OwnerId = ownerId, Title = string.Empty }); - var (created, mergedInto, skipped) = await CommitAsync( - collectibleRepository, existingCollectibles, collectibleItems.Select(ToOwnedItemRequestItem).ToList(), - new OwnedItemImportAdapter( - c => c.Title, c => c.OwnedVersions.Select(v => v.Reference), - i => i.Title, i => i.OwnedVersion.Reference, - item => new CollectibleModel - { - OwnerId = ownerId, - Title = item.Title, - Year = item.Year, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), - OwnedVersions = [item.OwnedVersion] - }, - (collectible, item) => collectible.OwnedVersions.Add(item.OwnedVersion)), ownerId); - (result.CollectiblesCreated, result.CollectiblesMergedInto, result.CollectiblesSkipped) = (created, mergedInto, skipped); - } - - return Ok(result); + BooksCreated = counts.Books.Created, + BooksMergedInto = counts.Books.MergedInto, + BooksSkipped = counts.Books.Skipped, + MoviesCreated = counts.Movies.Created, + MoviesMergedInto = counts.Movies.MergedInto, + MoviesSkipped = counts.Movies.Skipped, + TvShowsCreated = counts.TvShows.Created, + TvShowsMergedInto = counts.TvShows.MergedInto, + TvShowsSkipped = counts.TvShows.Skipped, + VideoGamesCreated = counts.VideoGames.Created, + VideoGamesMergedInto = counts.VideoGames.MergedInto, + VideoGamesSkipped = counts.VideoGames.Skipped, + GearCreated = counts.Gear.Created, + GearMergedInto = counts.Gear.MergedInto, + GearSkipped = counts.Gear.Skipped, + CollectiblesCreated = counts.Collectibles.Created, + CollectiblesMergedInto = counts.Collectibles.MergedInto, + CollectiblesSkipped = counts.Collectibles.Skipped + }); } - private static AmazonOwnedItemImportRequestItem ToOwnedItemRequestItem(AmazonImportCommitItemDto item) => new() + private static OwnedItemImportInput ToInput(AmazonImportCommitItemDto item) { - Title = item.Title, - AmazonTitle = item.AmazonTitle, - Year = item.Year, - Isbn = item.Isbn, - OwnedVersion = ToOwnedVersion(item) - }; + var isBook = item.MediaType == AmazonMediaType.Book; + var isVideoGame = item.MediaType == AmazonMediaType.VideoGame; - private static AmazonVideoGameImportRequestItem ToVideoGameRequestItem(AmazonImportCommitItemDto item) => new() - { - Title = item.Title, - AmazonTitle = item.AmazonTitle, - Year = item.Year, - Platform = new VideoGamePlatformModel + return new OwnedItemImportInput { - Platform = item.Platform!, - CopyType = ToDomainCopyType(item.CopyType), - Price = item.Price, - Vendor = item.Vendor, - AcquiredAt = item.AcquiredAt, - Reference = AmazonImportMergeService.FormatOrderReference(item.OrderId, item.Asin) - } - }; - - private static OwnedVersionModel ToOwnedVersion(AmazonImportCommitItemDto item) => new() - { - CopyType = ToDomainCopyType(item.CopyType), - Price = item.Price, - Vendor = item.Vendor, - AcquiredAt = item.AcquiredAt, - // Derived server-side from the order id + ASIN the preview row reported, never from a client-supplied - // Reference string - this is what disambiguates two different items sharing one Amazon order. - Reference = AmazonImportMergeService.FormatOrderReference(item.OrderId, item.Asin) - }; + MediaType = Enum.Parse(item.MediaType!.Value.ToString()), + Title = item.Title, + // The original, unedited Amazon listing text - kept in the created item's notes since reference-data + // linking is expected to overwrite Title (and, for a book, ISBN) with canonical values later. + ProvenanceNotes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, isBook ? item.Isbn : null), + Year = item.Year, + // Amazon's export has no author column; books are created author-less (the coordinator stores ""). + Author = null, + Isbn = isBook ? item.Isbn : null, + Platform = isVideoGame ? item.Platform : null, + OwnedVersion = new OwnedVersionModel + { + CopyType = ToDomainCopyType(item.CopyType), + Price = item.Price, + Vendor = item.Vendor, + AcquiredAt = item.AcquiredAt, + // Derived server-side from the order id + ASIN the preview row reported, never from a + // client-supplied Reference string - this is what disambiguates two different items sharing one + // Amazon order and what a later re-preview dedups against. + Reference = AmazonImportMergeService.FormatOrderReference(item.OrderId, item.Asin) + } + }; + } private static Keeptrack.Domain.Models.CopyType ToDomainCopyType(Keeptrack.WebApi.Contracts.Dto.CopyType copyType) => Enum.Parse(copyType.ToString()); - private static async Task<(int Created, int MergedInto, int Skipped)> CommitAsync( - IDataRepository repository, - List existingItems, - List requestItems, - OwnedItemImportAdapter adapter, - string ownerId) - where TModel : class, IHasIdAndOwnerId - { - var plan = OwnedItemImportMergeService.ComputeCommitPlan(existingItems, requestItems, adapter); - - foreach (var item in plan.ItemsToCreate) - { - await repository.CreateAsync(item); - } - - foreach (var item in plan.ItemsToUpdate) - { - await repository.UpdateAsync(item.Id!, item, ownerId); - } - - return (plan.ItemsToCreate.Count, plan.ItemsToUpdate.Count, plan.OwnedCopiesSkipped); - } - private static async Task> FindAllAsync(IDataRepository repository, string ownerId, TModel blankSample) where TModel : IHasIdAndOwnerId => (await repository.FindAllAsync(ownerId, 1, int.MaxValue, null, blankSample)).Items; diff --git a/src/WebApi/Controllers/GenericImportController.cs b/src/WebApi/Controllers/GenericImportController.cs new file mode 100644 index 00000000..b261e675 --- /dev/null +++ b/src/WebApi/Controllers/GenericImportController.cs @@ -0,0 +1,176 @@ +using System.Diagnostics.CodeAnalysis; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Domain.Services; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +// Both Domain.Models (imported above) and Contracts.Dto (a global using) declare ImportMediaType/CopyType. +// The DTOs the request carries use the Contracts ones; this alias keeps the comparisons below unambiguous. +using ContractsImportMediaType = Keeptrack.WebApi.Contracts.Dto.ImportMediaType; + +namespace Keeptrack.WebApi.Controllers; + +/// +/// Previews a generic store/CSV import (any retailer export the user has reshaped into the canonical column set +/// in a spreadsheet) and commits the rows the user selected/edited in the review UI. The store-agnostic +/// generalization of : vendor and each row's media type are read from +/// columns rather than hardcoded/guessed, so a well-prepared "Type" column pre-selects every row's type. Both +/// controllers share the same create/merge orchestration - - +/// and the same dedup engine, differing only in parsing and reference/provenance text. +/// Synchronous on both ends: there is no external API call in the loop, so even a multi-year export completes +/// well within a normal request. +/// +[ApiController] +[Authorize(Policy = "MemberOnly")] +[Route("api/import/generic")] +public class GenericImportController( + IBookRepository bookRepository, + IMovieRepository movieRepository, + ITvShowRepository tvShowRepository, + IVideoGameRepository videoGameRepository, + IGearRepository gearRepository, + ICollectibleRepository collectibleRepository, + GenericImportPreviewRowDtoMapper previewMapper) : ControllerBase +{ + /// + /// Parses the uploaded CSV and returns every line item for review - nothing is persisted by this call. A + /// row is flagged when its order reference already + /// exists on an owned copy of any type. + /// + [HttpPost("preview")] + [RequestSizeLimit(20_000_000)] + [Consumes("multipart/form-data")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + [SuppressMessage("Security", "S5693:Make sure the content length limit is safe here", + Justification = "The limit IS set (20 MB), deliberately above Sonar's 8 MB default: a multi-year " + + "order-history export can be sizeable, and the endpoint is authenticated, member-only, admin-of-your-own-data.")] + public async Task>> Preview(IFormFile file) + { + if (file.Length == 0) + { + return BadRequest(); + } + + var ownerId = this.GetUserId(); + + // "Already imported" is checked across every type, not just one - a row previously imported as a movie + // must still be flagged when the same file is uploaded again. + var existingBooks = await FindAllAsync(bookRepository, ownerId, new BookModel { OwnerId = ownerId, Title = string.Empty, Author = string.Empty }); + var existingMovies = await FindAllAsync(movieRepository, ownerId, new MovieModel { OwnerId = ownerId, Title = string.Empty }); + var existingTvShows = await FindAllAsync(tvShowRepository, ownerId, new TvShowModel { OwnerId = ownerId, Title = string.Empty }); + var existingVideoGames = await FindAllAsync(videoGameRepository, ownerId, new VideoGameModel { OwnerId = ownerId, Title = string.Empty }); + var existingGear = await FindAllAsync(gearRepository, ownerId, new GearModel { OwnerId = ownerId, Title = string.Empty }); + var existingCollectibles = await FindAllAsync(collectibleRepository, ownerId, new CollectibleModel { OwnerId = ownerId, Title = string.Empty }); + + var alreadyImportedReferences = new HashSet(); + alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingBooks, b => b.OwnedVersions.Select(v => v.Reference))); + alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingMovies, m => m.OwnedVersions.Select(v => v.Reference))); + alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingTvShows, t => t.OwnedVersions.Select(v => v.Reference))); + alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingVideoGames, g => g.Platforms.Select(p => p.Reference))); + alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingGear, g => g.OwnedVersions.Select(v => v.Reference))); + alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingCollectibles, c => c.OwnedVersions.Select(v => v.Reference))); + + await using var stream = file.OpenReadStream(); + var rows = GenericImportService.BuildPreview(stream, alreadyImportedReferences); + + return Ok(rows.Select(previewMapper.ToDto).ToList()); + } + + /// + /// Creates/updates items from the rows the user selected, grouped by the media type each row was assigned. + /// A row whose (normalized) title matches an existing item of the same type - or one created earlier in + /// this same request - gets an additional owned copy instead of a duplicate; see + /// . + /// + [HttpPost("commit")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task> Commit(GenericImportCommitRequestDto request) + { + var ownerId = this.GetUserId(); + + var itemMissingMediaType = request.Items.FirstOrDefault(item => item.MediaType is null); + if (itemMissingMediaType is not null) + { + throw new ArgumentException($"A type is required to import '{itemMissingMediaType.Title}'."); + } + + var videoGameItemMissingPlatform = request.Items.FirstOrDefault(item => + item.MediaType == ContractsImportMediaType.VideoGame && string.IsNullOrWhiteSpace(item.Platform)); + if (videoGameItemMissingPlatform is not null) + { + throw new ArgumentException($"A platform is required to import '{videoGameItemMissingPlatform.Title}' as a video game."); + } + + var inputs = request.Items.Select(ToInput).ToList(); + + var counts = await OwnedItemImportCommitCoordinator.CommitAsync( + ownerId, inputs, + bookRepository, movieRepository, tvShowRepository, videoGameRepository, gearRepository, collectibleRepository); + + return Ok(new GenericImportCommitResultDto + { + BooksCreated = counts.Books.Created, + BooksMergedInto = counts.Books.MergedInto, + BooksSkipped = counts.Books.Skipped, + MoviesCreated = counts.Movies.Created, + MoviesMergedInto = counts.Movies.MergedInto, + MoviesSkipped = counts.Movies.Skipped, + TvShowsCreated = counts.TvShows.Created, + TvShowsMergedInto = counts.TvShows.MergedInto, + TvShowsSkipped = counts.TvShows.Skipped, + VideoGamesCreated = counts.VideoGames.Created, + VideoGamesMergedInto = counts.VideoGames.MergedInto, + VideoGamesSkipped = counts.VideoGames.Skipped, + GearCreated = counts.Gear.Created, + GearMergedInto = counts.Gear.MergedInto, + GearSkipped = counts.Gear.Skipped, + CollectiblesCreated = counts.Collectibles.Created, + CollectiblesMergedInto = counts.Collectibles.MergedInto, + CollectiblesSkipped = counts.Collectibles.Skipped, + RowsImported = counts.RowsImported, + SkippedRowTitles = counts.SkippedTitles + }); + } + + private static OwnedItemImportInput ToInput(GenericImportCommitItemDto item) + { + var isBook = item.MediaType == ContractsImportMediaType.Book; + var isVideoGame = item.MediaType == ContractsImportMediaType.VideoGame; + + return new OwnedItemImportInput + { + MediaType = Enum.Parse(item.MediaType!.Value.ToString()), + Title = item.Title, + // Provenance notes preserve the source's original listing text, since reference-data linking is + // expected to overwrite Title later. The ISBN line is only meaningful for a book. + ProvenanceNotes = GenericImportService.BuildProvenanceNotes(item.Vendor, item.SourceTitle, isBook ? item.Isbn : null), + Year = item.Year, + Author = isBook ? item.Author : null, + Isbn = isBook ? item.Isbn : null, + Platform = isVideoGame ? item.Platform : null, + OwnedVersion = new OwnedVersionModel + { + CopyType = Enum.Parse(item.CopyType.ToString()), + Price = item.Price, + Vendor = item.Vendor, + AcquiredAt = item.AcquiredAt, + // The reference carries the Website label + order id + product id (with SourceTitle as the + // product-id fallback so it matches what preview checked against) - derived server-side, never + // from a client-supplied Reference string. Independent of the Vendor field above; the order + // id + product id are what disambiguate two different items sharing one order on re-import. + Reference = GenericImportService.FormatReference(item.Website, item.OrderId, item.ProductId, item.SourceTitle), + // Condition is preserved on the copy's Product field rather than dropped (the one behavioral + // difference from the Amazon importer). + ProductName = string.IsNullOrWhiteSpace(item.Condition) ? null : item.Condition.Trim() + } + }; + } + + private static async Task> FindAllAsync(IDataRepository repository, string ownerId, TModel blankSample) + where TModel : IHasIdAndOwnerId => + (await repository.FindAllAsync(ownerId, 1, int.MaxValue, null, blankSample)).Items; +} diff --git a/src/WebApi/Mappers/GenericImportPreviewRowDtoMapper.cs b/src/WebApi/Mappers/GenericImportPreviewRowDtoMapper.cs new file mode 100644 index 00000000..19ca197f --- /dev/null +++ b/src/WebApi/Mappers/GenericImportPreviewRowDtoMapper.cs @@ -0,0 +1,17 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// One-directional (Model -> Dto): is pure and knows +/// nothing about the web contract, so maps its rows here - +/// same shape as . maps +/// the Domain ImportMediaType onto the identically-named Contracts one (drift between the two members +/// is then a build error, not a silent runtime mismatch). +/// +[Mapper(EnumMappingStrategy = EnumMappingStrategy.ByName)] +public partial class GenericImportPreviewRowDtoMapper +{ + public partial Keeptrack.WebApi.Contracts.Dto.GenericImportPreviewRowDto ToDto(GenericImportPreviewRow model); +} diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index f9bf7072..71edf9c3 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -32,6 +32,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/test/BlazorApp.PlaywrightTests/Pages/GenericImportPage.cs b/test/BlazorApp.PlaywrightTests/Pages/GenericImportPage.cs new file mode 100644 index 00000000..07edaa8a --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Pages/GenericImportPage.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using Microsoft.Playwright; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; + +/// +/// The /import/generic sub-page: upload a store/CSV export, review the parsed rows (their media type +/// pre-filled from the file's "Type" column), then commit the selected ones. +/// +public class GenericImportPage(IPage page) : PageBase(page) +{ + public override async Task WaitForReadyAsync() + { + await base.WaitForReadyAsync(); + await Assertions.Expect(Page.GetByRole(AriaRole.Heading, new PageGetByRoleOptions { Name = "a store (CSV)", Level = 1 })).ToBeVisibleAsync(); + } + + private ILocator FileInput => Page.Locator(".kt-dropzone input[type='file']"); + + /// + /// Only rendered once a preview exists; its label carries the selected-row count (e.g. "Import selected (1)"), + /// so asserting on its text proves the upload parsed and pre-selected the expected rows. + /// + public ILocator CommitButton => Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Import selected" }); + + /// The result banner rendered after a successful commit. + public ILocator ResultAlert => Page.Locator(".alert-info"); + + public async Task UploadAsync(byte[] csv, string fileName) + => await FileInput.SetInputFilesAsync(new FilePayload { Name = fileName, MimeType = "text/csv", Buffer = csv }); + + public async Task CommitSelectedAsync() => await CommitButton.ClickAsync(); +} diff --git a/test/BlazorApp.PlaywrightTests/Pages/ImportPage.cs b/test/BlazorApp.PlaywrightTests/Pages/ImportPage.cs index e8ea5609..7fa376c7 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/ImportPage.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/ImportPage.cs @@ -36,6 +36,14 @@ public async Task GoToAmazonImportAsync() return next; } + public async Task GoToGenericImportAsync() + { + await Page.GetByRole(AriaRole.Link, new PageGetByRoleOptions { Name = "Import from a CSV" }).ClickAsync(); + var next = new GenericImportPage(Page); + await next.WaitForReadyAsync(); + return next; + } + public async Task GoToVideoGameImportAsync() { await Page.GetByRole(AriaRole.Link, new PageGetByRoleOptions { Name = "Import video game transactions" }).ClickAsync(); diff --git a/test/BlazorApp.PlaywrightTests/Smoke/GenericImportSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/GenericImportSmokeTest.cs new file mode 100644 index 00000000..461475ae --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Smoke/GenericImportSmokeTest.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using Keeptrack.BlazorApp.PlaywrightTests.Hosting; +using Keeptrack.BlazorApp.PlaywrightTests.Pages; +using Keeptrack.BlazorApp.PlaywrightTests.Support; +using Microsoft.Playwright; +using Xunit; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; + +/// +/// Drives the generic store/CSV import UI end to end (upload → preview → commit → result), the browser-level +/// coverage the API-only GenericImportResourceTest can't provide. The fixture row carries a "Type" +/// column of Book, so it's pre-selected from the file rather than any heuristic. Uses a GUID-suffixed book +/// title (and a fresh order id inside the fixture) so every run imports a genuinely new book it then deletes. +/// +[Trait("Category", "E2eTests")] +[Trait("Mode", "Mutating")] +public class GenericImportSmokeTest(End2EndFixture fixture) : SmokeTestBase(fixture) +{ + [Fact] + public async Task UploadPreviewAndCommit_ImportsABookFromAGenericCsv() + { + SkipIfReadOnly(); + + var title = $"E2e Generic Book {Guid.NewGuid():N}"; + var csv = GenericImportFixtureCsvBuilder.Build(title); + + try + { + var home = await new HomePage(Page).OpenAsync(); + var import = await home.OpenImportAsync(); + var generic = await import.GoToGenericImportAsync(); + + await generic.UploadAsync(csv, "orders.csv"); + + // The single row carries Type=Book, so it's auto-selected and the commit button reports one selected row. + await Assertions.Expect(generic.CommitButton).ToContainTextAsync("(1)"); + await generic.CommitSelectedAsync(); + + await Assertions.Expect(generic.ResultAlert).ToContainTextAsync("Books:"); + await Assertions.Expect(generic.ResultAlert).ToContainTextAsync("1 created"); + } + finally + { + foreach (var id in await Fixture.GetItemIdsAsync($"/api/books?search={Uri.EscapeDataString(title)}")) + { + await Fixture.DeleteItemAsync($"/api/books/{id}"); + } + } + } +} diff --git a/test/BlazorApp.PlaywrightTests/Support/GenericImportFixtureCsvBuilder.cs b/test/BlazorApp.PlaywrightTests/Support/GenericImportFixtureCsvBuilder.cs new file mode 100644 index 00000000..1f6875f6 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Support/GenericImportFixtureCsvBuilder.cs @@ -0,0 +1,25 @@ +using System; +using System.Text; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Support; + +/// +/// Builds a minimal one-row generic store/CSV import fixture for the import smoke test, using the real-world +/// Rakuten header shape. The row carries an explicit "Type" column (Book) so it's auto-selected without relying +/// on any heuristic, plus a per-call unique title and fresh order id so every run imports a genuinely new book +/// the test then deletes - never a dedup-hidden "already imported" row from a previous run. +/// Never use a real personal export as a fixture (same rule as the integration suite's own builders). +/// +internal static class GenericImportFixtureCsvBuilder +{ + public static byte[] Build(string bookTitle) + { + var orderId = $"GEN-{Guid.NewGuid():N}"; + var csv = $""" + Order Date,Order ID,ASIN,Product Name,Product Condition,Total Amount,Vendor,Type,Platform,Author + 2024-01-24,{orderId},GEN-ASIN-1,{bookTitle},New,10.49,Rakuten,Book,,E2e Test Author + + """; + return Encoding.UTF8.GetBytes(csv); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/GenericImportFixtureCsvBuilder.cs b/test/WebApi.IntegrationTests/Resources/GenericImportFixtureCsvBuilder.cs new file mode 100644 index 00000000..fdd141fd --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/GenericImportFixtureCsvBuilder.cs @@ -0,0 +1,42 @@ +using System.Text; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Builds a small, synthetic generic store/CSV import fixture for tests - never use a real personal export as +/// a test fixture (same rule as ). Uses the exact real-world Rakuten +/// header shape (Product Name, ASIN, Product Condition, Total Amount, Website...) so the test doubles as proof +/// the canonical column aliases resolve against a genuine export header. +/// +internal static class GenericImportFixtureCsvBuilder +{ + public const string Vendor = "Rakuten"; + + public const string BookTitle = "Keeptrack Generic Import Test Book"; + public const string BookAuthor = "Keeptrack Test Author"; + public const string BookOrderId = "GEN-ORD-1"; + public const string BookProductId = "GEN-ASIN-1"; + public const string BookCondition = "Used - Very Good"; + public const string BookWebsite = "https://fr.shopping.rakuten.com/book"; + + public const string MovieTitle = "Keeptrack Generic Import Test Movie"; + public const string MovieOrderId = "GEN-ORD-2"; + public const string MovieProductId = "GEN-ASIN-2"; + + public const string VideoGameTitle = "Keeptrack Generic Import Test Game"; + public const string VideoGamePlatform = "PS5"; + public const string VideoGameOrderId = "GEN-ORD-3"; + public const string VideoGameProductId = "GEN-ASIN-3"; + + public static byte[] Build() + { + var csv = $""" + Order Date,Order ID,ASIN,Product Name,Product Condition,Total Amount,Vendor,Website,Type,Platform,Author + 2021-06-12,{BookOrderId},{BookProductId},{BookTitle},{BookCondition},12.50,{Vendor},{BookWebsite},Book,,{BookAuthor} + 2022-01-05,{MovieOrderId},{MovieProductId},{MovieTitle},New,9.99,{Vendor},,Movie,, + 2022-03-20,{VideoGameOrderId},{VideoGameProductId},{VideoGameTitle},New,49.99,{Vendor},,VideoGame,{VideoGamePlatform}, + + """; + return Encoding.UTF8.GetBytes(csv); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/GenericImportResourceTest.cs b/test/WebApi.IntegrationTests/Resources/GenericImportResourceTest.cs new file mode 100644 index 00000000..6363048b --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/GenericImportResourceTest.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +public class GenericImportResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + [Fact] + public async Task PreviewThenCommit_CreatesOneItemPerTypeFromTheTypeColumn_AndDedupsOnReimport() + { + await Authenticate(); + + var csv = GenericImportFixtureCsvBuilder.Build(); + + var preview = await PostFileAsync>("/api/import/generic/preview", "file", csv, "orders.csv"); + + var bookRow = preview.Should().Contain(r => r.Title == GenericImportFixtureCsvBuilder.BookTitle).Subject; + // the "Type" column pre-selects each row's media type - no per-row guessing needed + bookRow.SuggestedMediaType.Should().Be(ImportMediaType.Book); + bookRow.Author.Should().Be(GenericImportFixtureCsvBuilder.BookAuthor); + bookRow.Vendor.Should().Be(GenericImportFixtureCsvBuilder.Vendor); + bookRow.Website.Should().Be(GenericImportFixtureCsvBuilder.BookWebsite); + bookRow.Condition.Should().Be(GenericImportFixtureCsvBuilder.BookCondition); + bookRow.AlreadyImported.Should().BeFalse(); + + var movieRow = preview.Should().Contain(r => r.Title == GenericImportFixtureCsvBuilder.MovieTitle).Subject; + movieRow.SuggestedMediaType.Should().Be(ImportMediaType.Movie); + + var videoGameRow = preview.Should().Contain(r => r.Title == GenericImportFixtureCsvBuilder.VideoGameTitle).Subject; + videoGameRow.SuggestedMediaType.Should().Be(ImportMediaType.VideoGame); + videoGameRow.Platform.Should().Be(GenericImportFixtureCsvBuilder.VideoGamePlatform); + + try + { + // a video game row with no platform must be rejected before anything is persisted + var invalidPlatformRequest = new GenericImportCommitRequestDto { Items = [ToCommitItem(videoGameRow, ImportMediaType.VideoGame, platform: null)] }; + await PostAsync("/api/import/generic/commit", invalidPlatformRequest, HttpStatusCode.BadRequest); + + // a row with no media type chosen must also be rejected before anything is persisted + var noTypeRequest = new GenericImportCommitRequestDto { Items = [ToCommitItem(bookRow, mediaType: null)] }; + await PostAsync("/api/import/generic/commit", noTypeRequest, HttpStatusCode.BadRequest); + + var commitRequest = new GenericImportCommitRequestDto + { + Items = + [ + ToCommitItem(bookRow, ImportMediaType.Book, author: bookRow.Author, year: 1969), + ToCommitItem(movieRow, ImportMediaType.Movie), + ToCommitItem(videoGameRow, ImportMediaType.VideoGame, platform: GenericImportFixtureCsvBuilder.VideoGamePlatform) + ] + }; + + var commitResult = await PostAsync("/api/import/generic/commit", commitRequest); + commitResult.BooksCreated.Should().Be(1); + commitResult.MoviesCreated.Should().Be(1); + commitResult.VideoGamesCreated.Should().Be(1); + commitResult.RowsImported.Should().Be(3); + commitResult.SkippedRowTitles.Should().BeEmpty(); + + var books = await GetAsync>($"/api/books?search={Uri.EscapeDataString(GenericImportFixtureCsvBuilder.BookTitle)}"); + var book = books.Items.Should().ContainSingle().Subject; + book.Year.Should().Be(1969); + book.Author.Should().Be(GenericImportFixtureCsvBuilder.BookAuthor); + book.OwnedVersions.Should().ContainSingle(); + book.OwnedVersions[0].Price.Should().Be(12.50m); + // Vendor field comes from the Vendor column; the Reference carries the Website column (independent). + book.OwnedVersions[0].Vendor.Should().Be(GenericImportFixtureCsvBuilder.Vendor); + book.OwnedVersions[0].Reference.Should().Contain(GenericImportFixtureCsvBuilder.BookOrderId); + book.OwnedVersions[0].Reference.Should().Contain(GenericImportFixtureCsvBuilder.BookWebsite); + // the condition is preserved on the owned copy's Product field rather than dropped + book.OwnedVersions[0].ProductName.Should().Be(GenericImportFixtureCsvBuilder.BookCondition); + book.Notes.Should().Be($"Title from {GenericImportFixtureCsvBuilder.Vendor}: {GenericImportFixtureCsvBuilder.BookTitle}"); + + var movies = await GetAsync>($"/api/movies?search={Uri.EscapeDataString(GenericImportFixtureCsvBuilder.MovieTitle)}"); + movies.Items.Should().ContainSingle().Which.OwnedVersions.Should().ContainSingle(); + + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericImportFixtureCsvBuilder.VideoGameTitle)}"); + var videoGame = videoGames.Items.Should().ContainSingle().Subject; + videoGame.Platforms.Should().ContainSingle(); + videoGame.Platforms[0].Platform.Should().Be(GenericImportFixtureCsvBuilder.VideoGamePlatform); + videoGame.Platforms[0].Reference.Should().Contain(GenericImportFixtureCsvBuilder.VideoGameOrderId); + + // re-preview after commit: every just-imported order line must now be flagged as already imported + var secondPreview = await PostFileAsync>("/api/import/generic/preview", "file", csv, "orders.csv"); + secondPreview.Should().Contain(r => r.Title == GenericImportFixtureCsvBuilder.BookTitle && r.AlreadyImported); + secondPreview.Should().Contain(r => r.Title == GenericImportFixtureCsvBuilder.MovieTitle && r.AlreadyImported); + secondPreview.Should().Contain(r => r.Title == GenericImportFixtureCsvBuilder.VideoGameTitle && r.AlreadyImported); + + // committing the exact same rows again must not duplicate anything, and must reconcile as all-skipped + var secondCommitResult = await PostAsync("/api/import/generic/commit", commitRequest); + secondCommitResult.BooksCreated.Should().Be(0); + secondCommitResult.BooksSkipped.Should().Be(1); + secondCommitResult.MoviesSkipped.Should().Be(1); + secondCommitResult.VideoGamesSkipped.Should().Be(1); + secondCommitResult.RowsImported.Should().Be(0); + secondCommitResult.SkippedRowTitles.Should().HaveCount(3); + + var booksAfterReimport = await GetAsync>($"/api/books?search={Uri.EscapeDataString(GenericImportFixtureCsvBuilder.BookTitle)}"); + booksAfterReimport.Items.Should().ContainSingle().Which.OwnedVersions.Should().ContainSingle(); + } + finally + { + await CleanUpAsync($"/api/books", GenericImportFixtureCsvBuilder.BookTitle, (BookDto b) => b.Id); + await CleanUpAsync($"/api/movies", GenericImportFixtureCsvBuilder.MovieTitle, (MovieDto m) => m.Id); + await CleanUpAsync($"/api/video-games", GenericImportFixtureCsvBuilder.VideoGameTitle, (VideoGameDto g) => g.Id); + } + } + + private async Task CleanUpAsync(string route, string title, Func getId) + where TDto : class + { + var page = await GetAsync>($"{route}?search={Uri.EscapeDataString(title)}"); + foreach (var item in page.Items) + { + var id = getId(item); + if (id is not null) + { + await DeleteAsync($"{route}/{id}"); + } + } + } + + private static GenericImportCommitItemDto ToCommitItem(GenericImportPreviewRowDto row, ImportMediaType? mediaType, int? year = null, string? author = null, string? platform = null) => new() + { + RowId = row.RowId, + Title = row.Title, + SourceTitle = row.Title, + MediaType = mediaType, + Year = year, + Author = author, + Isbn = row.Isbn, + Platform = platform, + Condition = row.Condition, + OrderId = row.OrderId, + ProductId = row.ProductId, + Vendor = row.Vendor, + Website = row.Website, + AcquiredAt = row.OrderDate, + Price = row.Price, + CopyType = row.CopyType + }; +} diff --git a/test/WebApi.UnitTests/Controllers/FreeTierTest.cs b/test/WebApi.UnitTests/Controllers/FreeTierTest.cs index 0434f941..b8d09b68 100644 --- a/test/WebApi.UnitTests/Controllers/FreeTierTest.cs +++ b/test/WebApi.UnitTests/Controllers/FreeTierTest.cs @@ -160,6 +160,7 @@ public async Task Post_NeverCapsAnAdmin() [InlineData(typeof(TvTimeImportController), "MemberOnly")] [InlineData(typeof(CarHistoryImportController), "MemberOnly")] [InlineData(typeof(HealthImportController), "MemberOnly")] + [InlineData(typeof(GenericImportController), "MemberOnly")] [InlineData(typeof(MovieController), null)] [InlineData(typeof(TvShowController), null)] [InlineData(typeof(EpisodeController), null)] diff --git a/test/WebApi.UnitTests/Services/GenericImportServiceTest.cs b/test/WebApi.UnitTests/Services/GenericImportServiceTest.cs new file mode 100644 index 00000000..eecdfffb --- /dev/null +++ b/test/WebApi.UnitTests/Services/GenericImportServiceTest.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Services; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Services; + +[Trait("Category", "UnitTests")] +public class GenericImportServiceTest +{ + private static MemoryStream ToStream(string csv) => new(Encoding.UTF8.GetBytes(csv)); + + // The header shape from a real Rakuten export the user reshapes in a spreadsheet - exercises every canonical + // alias at once (Product Name -> Title, ASIN -> Product Id, Product Condition -> Condition, Total Amount -> + // Price). Vendor (the store name -> the copy's Vendor field) and Website (the item URL -> the copy's + // Reference) are two separate columns feeding two different fields. + private const string RakutenCsv = """ + Order Date,Order ID,ASIN,Product Name,Product Condition,Total Amount,Vendor,Website,Type,Platform,Author + 2021-06-12,ORD-1,ASIN-1,The Left Hand of Darkness,Used - Good,12.50,Rakuten,https://fr.shopping.rakuten.com/a,Book,,Ursula K. Le Guin + 2022-01-05,ORD-2,ASIN-2,Elden Ring,New,49.99,Rakuten,https://fr.shopping.rakuten.com/b,VideoGame,PS5, + """; + + [Fact] + public void BuildPreview_ResolvesEveryCanonicalColumnAliasFromARealRakutenHeader() + { + var rows = GenericImportService.BuildPreview(ToStream(RakutenCsv), new HashSet()); + + var book = rows[0]; + book.Title.Should().Be("The Left Hand of Darkness"); + book.SuggestedMediaType.Should().Be(ImportMediaType.Book); + book.OrderId.Should().Be("ORD-1"); + book.ProductId.Should().Be("ASIN-1"); + book.Condition.Should().Be("Used - Good"); + book.Price.Should().Be(12.50m); + book.Vendor.Should().Be("Rakuten"); + book.Website.Should().Be("https://fr.shopping.rakuten.com/a"); + book.Author.Should().Be("Ursula K. Le Guin"); + book.OrderDate.Should().Be(new DateOnly(2021, 6, 12)); + } + + [Fact] + public void BuildPreview_ReadsTheWebsiteColumnIntoItsOwnFieldNotTheVendor() + { + // Website is a separate column feeding the copy's Reference, never the Vendor field. + const string csv = """ + Title,Type,Website + A Book,Book,https://example.com/item + """; + + var rows = GenericImportService.BuildPreview(ToStream(csv), new HashSet()); + + rows[0].Vendor.Should().BeNull(); + rows[0].Website.Should().Be("https://example.com/item"); + } + + [Theory] + [InlineData("Vendor")] + [InlineData("Store")] + public void BuildPreview_ReadsTheVendorFromAnyOfItsAcceptedColumnNames(string vendorHeader) + { + var csv = $""" + Title,Type,{vendorHeader} + A Book,Book,Rakuten + """; + + GenericImportService.BuildPreview(ToStream(csv), new HashSet())[0].Vendor.Should().Be("Rakuten"); + } + + [Fact] + public void BuildPreview_ReadsPlatformAndTypeForAVideoGameRow() + { + var rows = GenericImportService.BuildPreview(ToStream(RakutenCsv), new HashSet()); + + rows[1].SuggestedMediaType.Should().Be(ImportMediaType.VideoGame); + rows[1].Platform.Should().Be("PS5"); + } + + [Theory] + [InlineData("Book", ImportMediaType.Book)] + [InlineData("books", ImportMediaType.Book)] + [InlineData("Movie", ImportMediaType.Movie)] + [InlineData("Film", ImportMediaType.Movie)] + [InlineData("TV Show", ImportMediaType.TvShow)] + [InlineData("tvshow", ImportMediaType.TvShow)] + [InlineData("series", ImportMediaType.TvShow)] + [InlineData("Video Game", ImportMediaType.VideoGame)] + [InlineData("game", ImportMediaType.VideoGame)] + [InlineData("Gear", ImportMediaType.Gear)] + [InlineData("Collectible", ImportMediaType.Collectible)] + public void ParseMediaType_MapsTheNaturalSpellingsAUserWouldType(string raw, ImportMediaType expected) + { + GenericImportService.ParseMediaType(raw).Should().Be(expected); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("widget")] + public void ParseMediaType_ReturnsNull_ForBlankOrUnrecognizedValues(string raw) + { + GenericImportService.ParseMediaType(raw).Should().BeNull(); + } + + [Fact] + public void BuildPreview_LeavesSuggestedMediaTypeNull_WhenTheTypeColumnIsAbsentEntirely() + { + const string csv = """ + Title,Vendor + Some Item,Rakuten + """; + + var rows = GenericImportService.BuildPreview(ToStream(csv), new HashSet()); + + rows[0].Title.Should().Be("Some Item"); + rows[0].SuggestedMediaType.Should().BeNull(); + } + + [Fact] + public void BuildPreview_ParsesADigitalCopyFromTheCopyColumnAndCurrencyDecoratedPrice() + { + const string csv = """ + Title,Type,Copy,Price,Year + A Digital Movie,Movie,Digital,€9.99,2018 + """; + + var rows = GenericImportService.BuildPreview(ToStream(csv), new HashSet()); + + rows[0].CopyType.Should().Be(CopyType.Digital); + rows[0].Price.Should().Be(9.99m); + rows[0].Year.Should().Be(2018); + } + + [Fact] + public void BuildPreview_DefaultsCopyTypeToPhysical_WhenNoCopyColumnIsPresent() + { + const string csv = """ + Title,Type + A Physical Book,Book + """; + + GenericImportService.BuildPreview(ToStream(csv), new HashSet())[0].CopyType.Should().Be(CopyType.Physical); + } + + [Fact] + public void FormatReference_IncludesTheWebsiteLabelOrderIdAndProductId() + { + GenericImportService.FormatReference("https://rakuten.fr/a", "ORD-1", "ASIN-1", "The Left Hand of Darkness") + .Should().Be("https://rakuten.fr/a order ORD-1 (ASIN-1)"); + } + + [Fact] + public void FormatReference_FallsBackToTheTitle_WhenProductIdIsBlank() + { + GenericImportService.FormatReference("https://rakuten.fr/a", "ORD-1", null, "The Left Hand of Darkness") + .Should().Be("https://rakuten.fr/a order ORD-1 (The Left Hand of Darkness)"); + } + + [Fact] + public void FormatReference_Disambiguates_WhenTwoLinesShareOneOrderButDifferentProducts() + { + // A non-unique Website label is harmless: order id + product id are what disambiguate. + var first = GenericImportService.FormatReference("https://rakuten.fr", "ORD-9", "ASIN-A", "Bundle"); + var second = GenericImportService.FormatReference("https://rakuten.fr", "ORD-9", "ASIN-B", "Bundle"); + + first.Should().NotBe(second); + } + + [Fact] + public void BuildPreview_BuildsTheDedupReferenceFromTheWebsiteColumnNotTheVendor() + { + // The reference (and dedup key) uses the Website label, independent of the Vendor field. + var keyedOnWebsite = new HashSet + { + GenericImportService.FormatReference("https://fr.shopping.rakuten.com/a", "ORD-1", "ASIN-1", "The Left Hand of Darkness") + }; + GenericImportService.BuildPreview(ToStream(RakutenCsv), keyedOnWebsite)[0].AlreadyImported.Should().BeTrue(); + + var keyedOnVendor = new HashSet + { + GenericImportService.FormatReference("Rakuten", "ORD-1", "ASIN-1", "The Left Hand of Darkness") + }; + GenericImportService.BuildPreview(ToStream(RakutenCsv), keyedOnVendor)[0].AlreadyImported.Should().BeFalse(); + } + + [Fact] + public void BuildProvenanceNotes_IncludesTheVendorAndSourceTitle() + { + GenericImportService.BuildProvenanceNotes("Rakuten", "The Left Hand of Darkness", null) + .Should().Be("Title from Rakuten: The Left Hand of Darkness"); + } + + [Fact] + public void BuildProvenanceNotes_AddsAnIsbnLine_ForABook() + { + GenericImportService.BuildProvenanceNotes("Rakuten", "The Left Hand of Darkness", "9780441478125") + .Should().Be("Title from Rakuten: The Left Hand of Darkness\nISBN from Rakuten: 9780441478125"); + } +} From 520c98e3309f03ca2f2257b1bd36f5e291e812e5 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 28 Jul 2026 04:46:02 +0200 Subject: [PATCH 14/80] Fix UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed. The Reference field in the owned-copy editor (OwnedVersionFields.razor) was misaligned because when an Amazon ASIN is detected, the "Open on Amazon" link renders as a .kt-icon-btn that's 2.25rem (~36px) tall — taller than the ~30px input beside it. The flex row wrapping them used align-items-center, which vertically centered the shorter input inside the taller row, dropping it a few pixels below the Price/Acquired/Vendor inputs in the same row. Switching that row to align-items-start top-aligns the input so it lines up with its siblings again. --- .../Components/Inventory/Shared/OwnedVersionFields.razor | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor b/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor index eb816096..7b9d412f 100644 --- a/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor +++ b/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor @@ -40,7 +40,9 @@
-
+ @* align-items-start, not -center: the optional Amazon link is a taller (2.25rem) .kt-icon-btn, and + centering it would push this input a few px below the Price/Acquired/Vendor inputs in the same row. *@ +
@if (_showAmazonProductLink && AmazonReference.TryExtractAsin(Copy.Reference) is { } asin) From 06e5477b28c5e38412abdb2925dcff579b9bac98 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 28 Jul 2026 04:50:22 +0200 Subject: [PATCH 15/80] Fix button on thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: In the thumbnail (grid) view, the delete button lived inside .kt-grid-cover. On hover, that element gets transform: translateY(-3px) (app.css:479), and a transform creates a new stacking context. That trapped the button's z-index: 2 within the cover's context, so it could no longer sit above the card-level Bootstrap stretched-link (z-index: 1). The link painted over the whole cover — button visible, but the click landed on the link. The list-row view avoids this because its delete button is a direct sibling of the stretched-link with no transformed ancestor between them. Fix: Moved @Actions in ItemGridCard.razor out of .kt-grid-cover to be a direct child of .kt-grid-card — the same stacking context as the stretched-link — so its z-index: 2 genuinely wins. No CSS change needed: .kt-grid-delete is position: absolute; top/right, which now anchors to .kt-grid-card (still position-relative) and stays in the same top-right corner over the cover. --- src/BlazorApp/Components/Shared/ItemGridCard.razor | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/BlazorApp/Components/Shared/ItemGridCard.razor b/src/BlazorApp/Components/Shared/ItemGridCard.razor index ef236e78..5382e058 100644 --- a/src/BlazorApp/Components/Shared/ItemGridCard.razor +++ b/src/BlazorApp/Components/Shared/ItemGridCard.razor @@ -13,8 +13,11 @@ { } - @Actions
+ @* Actions (e.g. delete) sit as a direct child of the card, not inside .kt-grid-cover: the cover gets a + transform on hover, which would create a stacking context trapping the button's z-index below the + card-level stretched-link and making it unclickable. Kept a sibling of the link, its z-index wins. *@ + @Actions
@Title
@if (MetaContent is not null) From 1b3c7283b0658228b078dff43ac7a0c06edfd85a Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 28 Jul 2026 16:40:08 +0200 Subject: [PATCH 16/80] Improve video game detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: three loose stacked rows (state buttons, a bare fully-completed toggle, playthroughs) with unlabelled date pickers appearing inline with no context. After: - A
divider separates the shared owned-copy fields from the game-specific progress controls, so the two areas read as distinct groups. - State and Completion now sit side-by-side in a two-column row (col-md-6), each under an uppercase form-label matching the rest of the app's fields. On mobile they stack. - The bare date pickers get contextual muted hints — "Completed on" and "on" (kt-card-meta) — so a lone date box is no longer mysterious. Widened to 170px so full dates aren't cramped. - A second
sets off Playthroughs, which now shows a "No playthroughs recorded yet." empty state instead of just a bare "+ Add" button, and its remove ✕ got an aria-label for parity with the other remove buttons. Everything reuses your existing design tokens (form-label, kt-card-meta, .kt-icon-btn, Bootstrap grid), so it's consistent with the other detail pages. --- .../Inventory/Pages/VideoGameDetail.razor | 68 ++++++++++++------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor index 4455998f..4d07ef5b 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor @@ -145,39 +145,61 @@ else -
- @foreach (var state in VideoGames.VideoGameStates) - { - - } - @if (entry.State == "Completed") - { - - } -
+
+ +
+
+ +
+ @foreach (var state in VideoGames.VideoGameStates) + { + + } +
+ @if (entry.State == "Completed") + { +
+ Completed on + +
+ } +
-
- - @if (entry.IsFullyCompleted) - { - - } +
+ +
+ + @if (entry.IsFullyCompleted) + { +
+ on + +
+ } +
+
-
+
+ +
+ @if (entry.Playthroughs.Count == 0) + { +

No playthroughs recorded yet.

+ } @foreach (var playthrough in entry.Playthroughs) {
- - +
} From 620cfaa122a905480d8ea10e7c4fb016e3aab1bb Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 28 Jul 2026 21:48:54 +0200 Subject: [PATCH 17/80] Share media items with another user --- docs/share-collections-plan.md | 213 +++++++++++++ scripts/mongodb-create-index.js | 6 + .../Components/Account/Pages/Manage.razor | 11 + .../Inventory/Meta/AlbumMetaRow.razor | 26 ++ .../Inventory/Meta/BookMetaRow.razor | 34 +++ .../Inventory/Meta/MovieMetaRow.razor | 35 +++ .../Inventory/Meta/TvShowMetaRow.razor | 34 +++ .../Inventory/Meta/VideoGameMetaRow.razor | 32 ++ .../Components/Inventory/Pages/Albums.razor | 21 +- .../Components/Inventory/Pages/Books.razor | 29 +- .../Components/Inventory/Pages/Movies.razor | 29 +- .../Components/Inventory/Pages/TvShows.razor | 29 +- .../Inventory/Pages/VideoGames.razor | 20 +- .../Inventory/Pages/VideoGames.razor.cs | 7 - .../Inventory/Shared/InventoryList.razor | 85 ++++-- src/BlazorApp/Components/Layout/NavMenu.razor | 5 +- .../Shared/AddToCollectionIcon.razor | 13 + .../Components/Shared/Breadcrumb.razor | 10 + .../Components/Shared/ItemGridCard.razor | 7 +- .../Components/Sharing/ShareApiClient.cs | 26 ++ .../Sharing/SharedCategoryList.razor | 216 ++++++++++++++ .../Sharing/SharedCollectionPage.razor | 137 +++++++++ .../Sharing/SharedWithMeApiClient.cs | 60 ++++ .../Sharing/SharedWithMeListPage.razor | 68 +++++ .../Components/Sharing/SharingLabels.cs | 17 ++ .../Components/Sharing/SharingPage.razor | 194 ++++++++++++ .../Components/Wishlist/WishlistRow.cs | 3 + src/BlazorApp/Components/_Imports.razor | 2 + ...frastructureServiceCollectionExtensions.cs | 4 + src/Domain/Models/ShareCategory.cs | 18 ++ src/Domain/Models/ShareKind.cs | 12 + src/Domain/Models/ShareModel.cs | 38 +++ src/Domain/Repositories/IShareRepository.cs | 27 ++ .../Services/ShareCategoryClassifier.cs | 21 ++ src/Domain/Services/SharedItemCopyService.cs | 57 ++++ src/Domain/Services/SharedItemMatcher.cs | 58 ++++ src/Infrastructure.MongoDb/Entities/Share.cs | 32 ++ .../Mappers/ShareStorageMapper.cs | 17 ++ .../Repositories/ShareRepository.cs | 47 +++ src/WebApi.Contracts/Dto/ShareCategory.cs | 33 ++ src/WebApi.Contracts/Dto/ShareDto.cs | 42 +++ .../Dto/SharedCategoryPageDto.cs | 41 +++ .../Dto/SharedCollectionSummaryDto.cs | 20 ++ .../Controllers/ControllerBaseExtensions.cs | 17 ++ .../Controllers/DataCrudControllerBase.cs | 16 +- src/WebApi/Controllers/FreeTierQuota.cs | 36 +++ src/WebApi/Controllers/ShareController.cs | 71 +++++ .../Controllers/SharedWithMeController.cs | 281 ++++++++++++++++++ ...frastructureServiceCollectionExtensions.cs | 2 + src/WebApi/Mappers/ShareDtoMapper.cs | 33 ++ src/WebApi/Program.cs | 1 + .../Resources/ShareResourceTest.cs | 104 +++++++ .../Controllers/FreeTierTest.cs | 4 + .../Services/ShareCategoryClassifierTest.cs | 32 ++ .../Services/SharedItemCopyServiceTest.cs | 115 +++++++ .../Services/SharedItemMatcherTest.cs | 88 ++++++ 56 files changed, 2470 insertions(+), 166 deletions(-) create mode 100644 docs/share-collections-plan.md create mode 100644 src/BlazorApp/Components/Inventory/Meta/AlbumMetaRow.razor create mode 100644 src/BlazorApp/Components/Inventory/Meta/BookMetaRow.razor create mode 100644 src/BlazorApp/Components/Inventory/Meta/MovieMetaRow.razor create mode 100644 src/BlazorApp/Components/Inventory/Meta/TvShowMetaRow.razor create mode 100644 src/BlazorApp/Components/Inventory/Meta/VideoGameMetaRow.razor create mode 100644 src/BlazorApp/Components/Shared/AddToCollectionIcon.razor create mode 100644 src/BlazorApp/Components/Sharing/ShareApiClient.cs create mode 100644 src/BlazorApp/Components/Sharing/SharedCategoryList.razor create mode 100644 src/BlazorApp/Components/Sharing/SharedCollectionPage.razor create mode 100644 src/BlazorApp/Components/Sharing/SharedWithMeApiClient.cs create mode 100644 src/BlazorApp/Components/Sharing/SharedWithMeListPage.razor create mode 100644 src/BlazorApp/Components/Sharing/SharingLabels.cs create mode 100644 src/BlazorApp/Components/Sharing/SharingPage.razor create mode 100644 src/Domain/Models/ShareCategory.cs create mode 100644 src/Domain/Models/ShareKind.cs create mode 100644 src/Domain/Models/ShareModel.cs create mode 100644 src/Domain/Repositories/IShareRepository.cs create mode 100644 src/Domain/Services/ShareCategoryClassifier.cs create mode 100644 src/Domain/Services/SharedItemCopyService.cs create mode 100644 src/Domain/Services/SharedItemMatcher.cs create mode 100644 src/Infrastructure.MongoDb/Entities/Share.cs create mode 100644 src/Infrastructure.MongoDb/Mappers/ShareStorageMapper.cs create mode 100644 src/Infrastructure.MongoDb/Repositories/ShareRepository.cs create mode 100644 src/WebApi.Contracts/Dto/ShareCategory.cs create mode 100644 src/WebApi.Contracts/Dto/ShareDto.cs create mode 100644 src/WebApi.Contracts/Dto/SharedCategoryPageDto.cs create mode 100644 src/WebApi.Contracts/Dto/SharedCollectionSummaryDto.cs create mode 100644 src/WebApi/Controllers/FreeTierQuota.cs create mode 100644 src/WebApi/Controllers/ShareController.cs create mode 100644 src/WebApi/Controllers/SharedWithMeController.cs create mode 100644 src/WebApi/Mappers/ShareDtoMapper.cs create mode 100644 test/WebApi.IntegrationTests/Resources/ShareResourceTest.cs create mode 100644 test/WebApi.UnitTests/Services/ShareCategoryClassifierTest.cs create mode 100644 test/WebApi.UnitTests/Services/SharedItemCopyServiceTest.cs create mode 100644 test/WebApi.UnitTests/Services/SharedItemMatcherTest.cs diff --git a/docs/share-collections-plan.md b/docs/share-collections-plan.md new file mode 100644 index 00000000..45b7e098 --- /dev/null +++ b/docs/share-collections-plan.md @@ -0,0 +1,213 @@ +# Share collections with other Keeptrack users + +## Context + +Keeptrack today is strictly single-tenant: every query is scoped to the caller's `user_id` claim, and the only +cross-user sharing is the **anonymous wishlist link** (`WishlistController`, capability token, `[AllowAnonymous]`). + +The owner wants to share **whole categories** of their data with **specific family/friends who have Keeptrack +accounts**, read-only: + +- **Media** (movies, TV shows, books, albums, video games, …): read-only view, plus an "Add to my collection" + action that copies the item's identity into the recipient's own collection (no owned-copy element). +- **Personal / sensitive** (cars, houses, health): read-only view, **no copy**, and health is the strictest case. + +Two decisions already confirmed with the owner: +1. **Granularity = by whole category** (no per-item picker). +2. **Recipient = by account email** (a directed grant, not a capability link — nothing sensitive rides in a URL). + +The enabling fact: the repository layer already takes `ownerId` as a **plain parameter** +(`IDataRepository.FindAllAsync(ownerId, …)`, `FindOneAsync(id, ownerId)`), so reading another owner's data is a +controller/authorization concern only — no Mongo query changes needed. `WebApi/Program.cs` sets +`MapInboundClaims = false`, so the Firebase `email` claim is readable verbatim on `HttpContext.User`. + +## Architecture (decided) + +**The grant** — a new `share` collection, one document per (owner → recipient) grant: + +``` +ShareModel : IHasId + Id + OwnerId // sharer (the creating user's user_id) + OwnerDisplayName // denormalized from the creator's "name"/"email" claim, for recipient-side display + RecipientEmail // normalized lowercase — the friend's account email + IncludedCategories // List (Movies, TvShows, Books, Albums, VideoGames, Cars, Houses, Health, …) + Label // owner-only bookkeeping, like WishlistShareModel.Label + CreatedAt +``` + +- **No stored `Kind`.** Media-vs-Personal is derived from the category by a single static classifier + (`ShareCategory` → `Media | Personal`) in a Domain service, so the "is this copyable / is this sensitive" + rule lives in exactly one place. Health is its own category and never auto-included by a "select all". +- Indexes (`scripts/mongodb-create-index.js`, mirror the `wishlist_share` block): `share_owner` on `owner_id`, + and `share_recipient` on `recipient_email` (the recipient's lookup key). + +**Recipient views = dedicated read-only pages, NOT retrofitted editable pages.** The ~12 detail pages +(`MovieDetail.razor`, etc.) are bespoke, auto-save on every field change, and are dense with +`[PersistentState]`/prerender logic — threading a read-only mode through all of them (plus their API clients) +is invasive and high-risk. The established precedent is `SharedWishlistPage.razor`: a *separate*, lean, +read-only page that reuses the presentational sub-components (`ItemThumb`, `ItemGridCard`, `CastGrid`, +`WishlistRow`) rather than reusing the editable page. We follow that precedent. (Unlike SharedWishlist, these +pages are **authenticated**, so their API client uses the normal `AuthenticationTokenHandler` registration.) + +**Read path (server).** A `SharedWithMeController` (`[Authorize]`, any authenticated user — recipients may be +free-tier) that, for every read: +1. loads the grant by id, **verifies `RecipientEmail == caller email`** (server-side, never trust the id alone), +2. verifies the requested category is in `IncludedCategories`, +3. reads via the existing repository with `share.OwnerId` as the scope, maps with the existing DTO mapper, and + hydrates cover images exactly like `WishlistController.BuildWishlistAsync` does + (`ReferenceImageHydrator.HydrateAsync`). + +To avoid per-type duplication, extract a small generic helper +`ReadSharedPageAsync(ownerId, repo, mapper, hydrator, paging)` and call it per category — the +controller injects the repositories/mappers the same way `WishlistController` already does. + +**Copy (media only).** `POST /api/shared-with-me/{shareId}/{category}/{itemId}/copy` → +verify grant + category is **Media** + category in scope, `FindOneAsync(itemId, share.OwnerId)`, build a fresh +model carrying **only identity + reference link** (`Title`, `Year`, `ReferenceId`, creator fields where they +exist), stamp caller as `OwnerId`, drop owned copies / rating / favorite / notes / dates, `CreateAsync`. Keeping +`ReferenceId` means the copy is instantly cover-art/synopsis-linked (reference data is owner-less/shared). The +per-type "strip to identity" factory lives in a Domain service (`SharedItemCopyService`), mirroring the +model-construction delegates in `OwnedItemImportCommitCoordinator`. **The copy must re-apply the free-tier +quota** (extract the check from `DataCrudControllerBase.Post` into a reusable helper) so a free-tier recipient +copying a Movie/TvShow hits the same limit as a direct create. + +## Phasing (reviewable slices) + +- **Phase 1 — Foundation + media loop end-to-end.** Grant model→entity→repo→mapper→DTO + indexes + DI; + `ShareController` (owner CRUD of grants); `SharedWithMeController` read + copy for the **media** categories; + owner "Sharing" page; recipient "Shared with me" page (read-only list/grid reusing `WishlistRow`/`ItemThumb`) + with per-item "Add to my collection". Delivers the whole friend-facing loop. +- **Phase 2 — Personal / sensitive read views.** Car (+ history log), House (+ history), Health (records) as + dedicated **read-only detail** pages (a bare list isn't useful for these — the value is the detail). Health + gets an explicit "you are sharing your health journal with ``" confirmation on the owner side. +- **Phase 3 (optional, later).** Read-only *detail* pages for media (cover/synopsis/cast) if the list-level + view proves insufficient. + +## Detailed changes + +### Domain (`src/Domain`) +- `Models/ShareModel.cs`, `Models/ShareCategory.cs` (enum), `Repositories/IShareRepository.cs` + (`FindAllByOwnerIdAsync(ownerId)`, `FindAllByRecipientEmailAsync(email)`, `FindByIdAsync(id)`, + `CreateAsync`, `DeleteAsync(id, ownerId)` — model on `WishlistShareRepository`). +- `Services/ShareCategoryClassifier.cs` (category → Media/Personal; the one place that rule lives) and + `Services/SharedItemCopyService.cs` (per-media-type identity-only copy factory). Both `AddSingleton`, + unit-tested — same shape as `WatchNextService`. + +### Infrastructure (`src/Infrastructure.MongoDb`) +- `Entities/Share.cs` (`[BsonElement]` snake_case, model on `WishlistShare.cs`), + `Repositories/ShareRepository.cs`, `Mappers/ShareStorageMapper.cs` + (`IStorageMapper`). `ShareCategory` list reuses the Domain enum directly (Infra depends on + Domain), with `EnumRepresentationConvention` already registered. + +### WebApi (`src/WebApi`) +- Register repo + mapper in `DependencyInjection/InfrastructureServiceCollectionExtensions.cs` + (`AddSingleton()`, `TryAddScoped()`). +- `Controllers/ControllerBaseExtensions.cs`: add `GetEmail()` (reads the `"email"` claim; throws + `UnauthorizedAccessException` when absent). +- `Controllers/ShareController.cs` (`[Authorize]`, `api/shares`): GET/POST/DELETE owner grants; POST stamps + `OwnerId`/`OwnerDisplayName` from claims and normalizes `RecipientEmail`. +- `Controllers/SharedWithMeController.cs` (`[Authorize]`, `api/shared-with-me`): `GET /` (grants for caller + email → `SharedCollectionSummaryDto` list), `GET /{shareId}/{category}` (paged read via the shared helper), + `POST /{shareId}/{category}/{itemId}/copy`. Extract the map+hydrate read helper here (or a shared static), + and extract the free-tier quota check out of `DataCrudControllerBase.Post` so both the create path and the + copy path call it. +- `Mappers/ShareDtoMapper.cs` (`IDtoMapper`, registered in `Program.cs` with + `EnumMappingStrategy.ByName` for the category enum), plus the read-only Model→Dto mapper for the recipient + summary (model on the `WatchNext`/`Wishlist` one-directional mappers). + +### Contracts (`src/WebApi.Contracts`) +- `Dto/ShareDto.cs`, `CreateShareRequestDto` (RecipientEmail + IncludedCategories + optional Label), + `SharedCollectionSummaryDto` (owner display name, label, categories, share id), and a **duplicate** + `ShareCategory` enum here (Contracts can't reference Domain — same split as every other DTO/Domain enum, + member names identical, mapped `ByName`). + +### Blazor (`src/BlazorApp`) +- Clients in a new `Components/Sharing/` folder: `ShareApiClient` (owner grants) and `SharedWithMeApiClient` + (recipient reads + copy), both registered in `DependencyInjection/InfrastructureServiceCollectionExtensions.cs` + **with** auth (default `AddHttpClient<>` there already attaches the token handler — unlike `SharedWishlist`). +- `Pages/SharingPage.razor` (`/sharing`, owner side): create grant (recipient email + category checkboxes + grouped Media / Personal; Health checkbox carries a confirm), list + revoke grants. Model the share-panel + markup on `WishlistPage.razor`'s existing share UI. +- `Pages/SharedWithMePage.razor` (`/shared-with-me`, recipient side): list sharers/categories; opening a + category shows a read-only list/grid (reuse `WishlistRow` + `ItemThumb`/`ItemGridCard`) with an "Add to my + collection" button per media row. +- `NavMenu.razor`: add "Sharing" and "Shared with me" links (both inside the authenticated block; available to + all authenticated users, not gated to MemberOnly — recipients may be free-tier). +- Phase 2 adds `Components/Sharing/` read-only detail pages for Car/House/Health reusing their existing + read-only sub-components (history rows, records table, `SvgChartHelpers` charts). + +### Scripts +- `scripts/mongodb-create-index.js`: add the two `share` indexes next to the `wishlist_share` block. + +### Tests (per the quality bar — a test at each layer) +- Unit: `ShareCategoryClassifierTest`, `SharedItemCopyServiceTest` (identity-only copy drops owned/rating/notes, + keeps ReferenceId). +- Integration (`KestrelWebAppFactory`, model on `WishlistShareResourceTest`): `ShareResourceTest` — owner + create/list/revoke; a **second authenticated HttpClient** as the recipient reading the shared category and + copying a media item; assert a non-recipient email gets nothing; assert category-not-in-scope is refused; + assert the free-tier quota still applies to copies (the integration user is admin, so cover the quota in a + unit test like `FreeTierTest` does). +- Playwright (`E2E_ENABLED`): a `SharingSmokeTest` — owner shares a category, recipient (the same fixture user, + sharing to their own email) sees it read-only and copies one item. Add `data-testid`s only where + `GetByLabel` can't resolve, per the existing convention. + +## Security & edge cases +- Recipient match is **server-side by email on every read/copy** — the share id alone never grants access. +- Emails normalized lowercase on both store and match. A recipient whose provider yields **no `email` claim** + (e.g. a GitHub account with a private email) can't be matched — documented limitation; the owner shares to + the email the friend actually signs in with. +- Copy re-validates `Kind == Media` and scope server-side, and enforces the free-tier quota. +- Health is opt-in per grant, never bundled, with an owner-side confirmation. +- Revoking = delete one owner-scoped document; the recipient's next read returns nothing. + +## Verification +- `dotnet build` then `dotnet test` (unit + integration; integration needs local MongoDB + Firebase test creds + per CONTRIBUTING.md). +- Manual: run WebApi + BlazorApp, sign in, create a grant to a second account's email, sign in as that account, + confirm the shared category is visible read-only, "Add to my collection" creates a reference-linked copy with + no owned versions, and revoking removes access. +- Re-run the Playwright `SharingSmokeTest` (and `MobileScreenshotTest` for the two new pages) with `E2E_ENABLED=true`. + +## Progress log — post-review rework (current) + +The owner reviewed the first cut and it was reworked (see `.claude/plans/vectorized-bubbling-shore.md` for the +detailed revision plan). Done and building clean: +- **Members-only**: both `ShareController` + `SharedWithMeController` are `[Authorize(Policy="MemberOnly")]` + (free tier can't share or view shared content); `FreeTierTest` guards this. +- **Under the profile, not the nav**: sharing lives at `/account/manage/sharing`, + `/account/manage/shared-with-me`, `/account/manage/shared/{shareId}`; the profile nav item is a + prefix-matching `NavLink` so it stays current; `Breadcrumb` gained an optional parent crumb + (`Profile › Shared with me › `). +- **Full-featured read-only lists**: shared categories render via the real `InventoryList` (new `ReadOnly` + + `RowActions` + `EmptyText`), with search / rating-sort / favourites+owned filters / thumbnails / grid. Per-type + meta extracted to `Components/Inventory/Meta/*MetaRow.razor`, reused by owner pages and the shared view. +- **Shared-with-me picks the person** → opens their collection with category tabs (tab persisted in `?tab=`). +- **Add = SVG icon + tooltip** (`AddToCollectionIcon`), with a spinner and an "In collection" badge. +- **Dedup-safe add**: `SharedItemMatcher` (reuses `TitleNormalizer`) makes copy idempotent and annotates each + read page with `AlreadyInCollectionIds`. Covered by `SharedItemMatcherTest` + `ShareResourceTest`. +- **Fixed after owner UI review**: the shared list's `Search`/`Sort`/`View` were binding literal strings + (the CLAUDE.md `@`-prefix gotcha) — now `@`-prefixed; tab now persists in the URL. + +Still deferred: Playwright `SharingSmokeTest` (WSL E2E env); Phase 2 personal (car/house/health) read views. + +## Progress log — Phase 1 (superseded by the rework above) + +- **Phase 1: DONE (not committed).** Full media loop implemented and green at every layer: + - Domain: `ShareModel`, `ShareCategory`, `ShareKind`, `IShareRepository`, `ShareCategoryClassifier`, `SharedItemCopyService`. + - Infrastructure: `Share` entity, `ShareStorageMapper`, `ShareRepository`, DI + two `share` indexes in `scripts/mongodb-create-index.js`. + - Contracts: `ShareDto`/`CreateShareRequestDto`/`SharedCollectionSummaryDto` + duplicate `ShareCategory` enum. + - WebApi: `GetEmail()`/`GetDisplayName()` extensions, extracted `FreeTierQuota` helper (reused by CRUD create + copy), + `ShareDtoMapper`, `ShareController` (owner CRUD), `SharedWithMeController` (recipient read + media copy for Movies/TvShows/Books/Albums/VideoGames). + - Blazor: `ShareApiClient`, `SharedWithMeApiClient`, `SharingPage.razor` (`/sharing`), `SharedWithMePage.razor` (`/shared-with-me`), + `WishlistRow.FromAlbums`, two NavMenu links, DI registration. + - Tests: `ShareCategoryClassifierTest` (8) + `SharedItemCopyServiceTest` (4) + `FreeTierTest` policy rows (26 total) — all pass; + `ShareResourceTest` (3) — passes against real MongoDB + Firebase. Whole solution builds with 0 warnings. + - **Deferred within Phase 1:** the Playwright `SharingSmokeTest` was not written yet (runs in the user's WSL E2E env). +- **Phase 2 (not started):** personal/sensitive read-only detail views for Cars/Houses/Health + owner-side health confirmation. + The `ShareCategory` enum + classifier already include `Cars`/`Houses`/`Health`; the owner UI currently offers media categories only. + +### Note for the runner +`dotnet test` reported "zero tests ran" in this environment; run the built MTP exe directly instead, e.g. +`test/WebApi.UnitTests/bin/Debug/net10.0/Keeptrack.WebApi.UnitTests.exe --filter-query "/*/*/ShareResourceTest/*"`. +Integration tests need `FIREBASE_APIKEY`/`FIREBASE_USERNAME`/`FIREBASE_PASSWORD` env vars (from `Local.runsettings`) and a local MongoDB. diff --git a/scripts/mongodb-create-index.js b/scripts/mongodb-create-index.js index dba3c20b..986c12b4 100644 --- a/scripts/mongodb-create-index.js +++ b/scripts/mongodb-create-index.js @@ -188,6 +188,12 @@ ensureIndex( ensureIndex(db.wishlist_share, { owner_id: 1 }, { name: "wishlist_share_owner" }); ensureIndex(db.wishlist_share, { token: 1 }, { name: "wishlist_share_token", unique: true }); +// share: one document per (owner -> recipient email) directed grant of whole collection categories - an +// owner holds several at once (one per person, individually revocable), so owner_id is NOT unique. The +// recipient looks their grants up by their own authenticated email, so recipient_email gets its own index. +ensureIndex(db.share, { owner_id: 1 }, { name: "share_owner" }); +ensureIndex(db.share, { recipient_email: 1 }, { name: "share_recipient" }); + // tvshow_reference / movie_reference: shared, owner-less lookup tables (see CLAUDE.md) keyed by // matched_aliases (every (title, year) combination ever confirmed for that reference, not just its // canonical one - see MatchedAliases/ReferenceMatchModel in CLAUDE.md), the primary automatic-match key. diff --git a/src/BlazorApp/Components/Account/Pages/Manage.razor b/src/BlazorApp/Components/Account/Pages/Manage.razor index 162b95cf..ecd9d4df 100644 --- a/src/BlazorApp/Components/Account/Pages/Manage.razor +++ b/src/BlazorApp/Components/Account/Pages/Manage.razor @@ -12,6 +12,17 @@ + + +

Sharing

+

Share whole categories of your collection with family or friends, read-only.

+ +
+
+ @if (_preferences is not null) {

Preferences

diff --git a/src/BlazorApp/Components/Inventory/Meta/AlbumMetaRow.razor b/src/BlazorApp/Components/Inventory/Meta/AlbumMetaRow.razor new file mode 100644 index 00000000..6ed13518 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Meta/AlbumMetaRow.razor @@ -0,0 +1,26 @@ +@* The album list row's second line. Shared by the owner list page and the shared-collection view. *@ + +@if (!string.IsNullOrEmpty(Item.Artist)) +{ + @Item.Artist +} +@if (Item.Year > 0) +{ + @Item.Year +} +@if (Item.Rating is not null) +{ + +} +@if (Item.IsFavorite) +{ + Favorite +} +@if (Item.OwnedVersions.Count > 0) +{ + Owned +} + +@code { + [Parameter] public required AlbumDto Item { get; set; } +} diff --git a/src/BlazorApp/Components/Inventory/Meta/BookMetaRow.razor b/src/BlazorApp/Components/Inventory/Meta/BookMetaRow.razor new file mode 100644 index 00000000..c06520b7 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Meta/BookMetaRow.razor @@ -0,0 +1,34 @@ +@* The book list row's second line. Shared by the owner list page and the shared-collection view. *@ + +@if (Item.Year > 0) +{ + @Item.Year +} +@if (!string.IsNullOrEmpty(Item.Author)) +{ + @Item.Author +} +@if (Item.Rating is not null) +{ + +} +@if (Item.IsFavorite) +{ + Favorite +} +@if (Item.FirstReadAt is not null) +{ + Read +} +@if (Item.OwnedVersions.Count > 0) +{ + Owned +} +@if (Item.IsWishlisted) +{ + Wishlist +} + +@code { + [Parameter] public required BookDto Item { get; set; } +} diff --git a/src/BlazorApp/Components/Inventory/Meta/MovieMetaRow.razor b/src/BlazorApp/Components/Inventory/Meta/MovieMetaRow.razor new file mode 100644 index 00000000..0052e0eb --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Meta/MovieMetaRow.razor @@ -0,0 +1,35 @@ +@* The movie list row's second line (year / rating / flag pills). Extracted so the owner list page and the + read-only shared-collection view render the identical meta from one definition. *@ + +@if (Item.Year > 0) +{ + @Item.Year +} +@if (Item.Rating is not null) +{ + +} +@if (Item.IsFavorite) +{ + Favorite +} +@if (Item.FirstSeenAt is not null) +{ + Seen +} +@if (Item.WantToWatch) +{ + To watch +} +@if (Item.OwnedVersions.Count > 0) +{ + Owned +} +@if (Item.IsWishlisted) +{ + Wishlist +} + +@code { + [Parameter] public required MovieDto Item { get; set; } +} diff --git a/src/BlazorApp/Components/Inventory/Meta/TvShowMetaRow.razor b/src/BlazorApp/Components/Inventory/Meta/TvShowMetaRow.razor new file mode 100644 index 00000000..ffdc2b7c --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Meta/TvShowMetaRow.razor @@ -0,0 +1,34 @@ +@* The TV show list row's second line. Shared by the owner list page and the shared-collection view. *@ + +@if (Item.Year > 0) +{ + @Item.Year +} +@if (Item.State is not null) +{ + @Item.State +} +@if (Item.Rating is not null) +{ + +} +@if (Item.IsFavorite) +{ + Favorite +} +@if (Item.WantToWatch) +{ + To watch +} +@if (Item.OwnedVersions.Count > 0) +{ + Owned +} +@if (Item.IsWishlisted) +{ + Wishlist +} + +@code { + [Parameter] public required TvShowDto Item { get; set; } +} diff --git a/src/BlazorApp/Components/Inventory/Meta/VideoGameMetaRow.razor b/src/BlazorApp/Components/Inventory/Meta/VideoGameMetaRow.razor new file mode 100644 index 00000000..ed5d0b2b --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Meta/VideoGameMetaRow.razor @@ -0,0 +1,32 @@ +@* The video game list row's second line - year, per-platform badges (the game's copies), rating. Shared by + the owner list page and the shared-collection view. *@ + +@if (Item.Year > 0) +{ + @Item.Year +} +@foreach (var entry in Item.Platforms) +{ + + @entry.Platform@(string.IsNullOrEmpty(entry.State) ? "" : $" ({entry.State})") + +} +@if (Item.Rating is not null) +{ + +} +@* no Owned badge here - the per-platform badges above already are the game's copies *@ +@if (Item.IsWishlisted) +{ + Wishlist +} + +@code { + [Parameter] public required VideoGameDto Item { get; set; } + + /// + /// The kt-status-badge modifier class for a state value (see app.css) - same badge/color pattern + /// as the TV show status column. + /// + private static string StateBadgeClass(string state) => state.ToLowerInvariant().Replace(" ", "-"); +} diff --git a/src/BlazorApp/Components/Inventory/Pages/Albums.razor b/src/BlazorApp/Components/Inventory/Pages/Albums.razor index 33a41623..5f586343 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Albums.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Albums.razor @@ -37,26 +37,7 @@ - @if (!string.IsNullOrEmpty(album.Artist)) - { - @album.Artist - } - @if (album.Year > 0) - { - @album.Year - } - @if (album.Rating is not null) - { - - } - @if (album.IsFavorite) - { - Favorite - } - @if (album.OwnedVersions.Count > 0) - { - Owned - } +
diff --git a/src/BlazorApp/Components/Inventory/Pages/Books.razor b/src/BlazorApp/Components/Inventory/Pages/Books.razor index 18caee9b..e302e8ef 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Books.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Books.razor @@ -39,34 +39,7 @@ - @if (book.Year > 0) - { - @book.Year - } - @if (!string.IsNullOrEmpty(book.Author)) - { - @book.Author - } - @if (book.Rating is not null) - { - - } - @if (book.IsFavorite) - { - Favorite - } - @if (book.FirstReadAt is not null) - { - Read - } - @if (book.OwnedVersions.Count > 0) - { - Owned - } - @if (book.IsWishlisted) - { - Wishlist - } +
diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor b/src/BlazorApp/Components/Inventory/Pages/Movies.razor index a99885c1..f49de459 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor @@ -39,34 +39,7 @@ - @if (movie.Year > 0) - { - @movie.Year - } - @if (movie.Rating is not null) - { - - } - @if (movie.IsFavorite) - { - Favorite - } - @if (movie.FirstSeenAt is not null) - { - Seen - } - @if (movie.WantToWatch) - { - To watch - } - @if (movie.OwnedVersions.Count > 0) - { - Owned - } - @if (movie.IsWishlisted) - { - Wishlist - } +
diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor index 8dcf210e..598a4c0a 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor @@ -40,34 +40,7 @@ - @if (show.Year > 0) - { - @show.Year - } - @if (show.State is not null) - { - @show.State - } - @if (show.Rating is not null) - { - - } - @if (show.IsFavorite) - { - Favorite - } - @if (show.WantToWatch) - { - To watch - } - @if (show.OwnedVersions.Count > 0) - { - Owned - } - @if (show.IsWishlisted) - { - Wishlist - } +
diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor index d8faab49..a0191509 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor @@ -43,25 +43,7 @@ - @if (game.Year > 0) - { - @game.Year - } - @foreach (var entry in game.Platforms) - { - - @entry.Platform@(string.IsNullOrEmpty(entry.State) ? "" : $" ({entry.State})") - - } - @if (game.Rating is not null) - { - - } - @* no Owned badge here - the per-platform badges above already are the game's copies *@ - @if (game.IsWishlisted) - { - Wishlist - } +
diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs index 5d512866..8213251b 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor.cs @@ -12,13 +12,6 @@ public partial class VideoGames : InventoryPageBase internal static readonly string[] VideoGamePlatforms = ["PC", "PS1", "PS2", "PSP", "PS3", "PS4", "PS5", "Xbox 360", "Xbox One X", "Xbox Series X", "Nintendo 64", "WII", "Switch", "Switch 2"]; - /// - /// The kt-status-badge modifier class for a state value (see app.css) - same badge/color - /// pattern as TvShows.razor's status column, sharing its "current" modifier for the identical - /// in-progress meaning and adding the three states with no TV show equivalent. - /// - internal static string StateBadgeClass(string state) => state.ToLowerInvariant().Replace(" ", "-"); - [Inject] private VideoGameApiClient VideoGameApi { get; set; } = null!; protected override InventoryApiClientBase Api => VideoGameApi; diff --git a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor index f794d231..bc0eea4c 100644 --- a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor +++ b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor @@ -10,7 +10,7 @@ Message="@($"This will permanently delete this {ItemName ?? "item"}. This can't be undone.")" OnConfirm="ConfirmDeleteAsync" OnCancel="CancelDelete"/> -@if (ShowForm) +@if (!ReadOnly && ShowForm && FormTemplate is not null && Form is not null) {
New @(ItemName ?? "item")
@@ -30,7 +30,7 @@ {
- Loading your collection… + Loading…
} } @@ -38,8 +38,8 @@ else {
-

My @Title

- @if (!ShowForm) +

@if (!ReadOnly){My }@Title

+ @if (!ReadOnly && !ShowForm) { } @@ -87,13 +87,20 @@ else
@foreach (var item in Items) { - - + @if (ReadOnly) + { + @RowActions?.Invoke(item) + } + else + { + + } @MetaTemplate(item) @@ -113,13 +120,27 @@ else }
- @ItemTitle(item) + @if (ReadOnly) + { + @ItemTitle(item) + } + else + { + @ItemTitle(item) + }
@MetaTemplate(item)
- + @if (ReadOnly) + { + @RowActions?.Invoke(item) + } + else + { + + }
}
@@ -129,7 +150,7 @@ else {
-

Your collection is empty.

+

@(EmptyText ?? "Your collection is empty.")

} @@ -232,9 +253,27 @@ else [Parameter] public required string Title { get; set; } [Parameter] public string? ItemName { get; set; } [Parameter] public required List Items { get; set; } - [Parameter] public required TDto Form { get; set; } [Parameter] public required bool Loading { get; set; } + /// + /// Read-only mode: no "My" title prefix, no Add form/button, no per-row delete, and rows don't link to a + /// detail page. Used by the shared-collection view - a recipient browses someone else's items and can't + /// edit them, only (an "add to my collection" affordance). + /// + [Parameter] public bool ReadOnly { get; set; } + + /// + /// Per-row action rendered in place of the delete button when - the shared view + /// puts its add-to-collection icon here. Ignored unless . + /// + [Parameter] public RenderFragment? RowActions { get; set; } + + /// Empty-state message; defaults to the owner-facing "Your collection is empty." + [Parameter] public string? EmptyText { get; set; } + + // Add-form inputs, only used in the editable (owner) mode - optional so a read-only caller omits them. + [Parameter] public TDto? Form { get; set; } + /// /// True once a load attempt (fresh or restored from persisted prerender state) has finished, found /// or not - distinct from , which is delay-gated and only turns on for a load @@ -244,7 +283,7 @@ else /// empty-collection state. Defaults true so a hypothetical caller that never sets it renders as before. /// [Parameter] public bool Loaded { get; set; } = true; - [Parameter] public required bool ShowForm { get; set; } + [Parameter] public bool ShowForm { get; set; } [Parameter] public required string? Error { get; set; } [Parameter] public required string Search { get; set; } @@ -288,15 +327,17 @@ else /// [Parameter] public required RenderFragment MetaTemplate { get; set; } - [Parameter] public required RenderFragment FormTemplate { get; set; } + // Add-form/delete callbacks, only used in the editable (owner) mode - a default EventCallback is a safe + // no-op, so a read-only caller simply omits them. + [Parameter] public RenderFragment? FormTemplate { get; set; } [Parameter] public RenderFragment? Filters { get; set; } - [Parameter] public required EventCallback OnSave { get; set; } - [Parameter] public required EventCallback OnCancelForm { get; set; } - [Parameter] public required EventCallback OnShowAddForm { get; set; } + [Parameter] public EventCallback OnSave { get; set; } + [Parameter] public EventCallback OnCancelForm { get; set; } + [Parameter] public EventCallback OnShowAddForm { get; set; } [Parameter] public required EventCallback OnClearSearch { get; set; } [Parameter] public required EventCallback OnSearchKeyUp { get; set; } [Parameter] public required EventCallback OnGoToPage { get; set; } - [Parameter] public required EventCallback OnDelete { get; set; } + [Parameter] public EventCallback OnDelete { get; set; } [Parameter] public required EventCallback OnSearchChanged { get; set; } [Parameter] public required EventCallback OnSortChanged { get; set; } [Parameter] public EventCallback OnViewChanged { get; set; } diff --git a/src/BlazorApp/Components/Layout/NavMenu.razor b/src/BlazorApp/Components/Layout/NavMenu.razor index 0bfe4727..01c2042c 100644 --- a/src/BlazorApp/Components/Layout/NavMenu.razor +++ b/src/BlazorApp/Components/Layout/NavMenu.razor @@ -113,10 +113,11 @@ } - @if (_showModal) + @if (CanEdit && _showModal) {
@@ -226,10 +244,21 @@ else @code { [Parameter] public required string Id { get; set; } + /// Set when a recipient is viewing this car as shared with them (read-only). When null this is the + /// owner's own editable page. Read-only reads go through the ownership-scoped shared-with-me endpoints, since + /// the owner's own /api/cars endpoints are scoped to the caller's user_id and would 404 for a recipient. + [Parameter] public string? ShareId { get; set; } + + private bool CanEdit => ShareId is null; + + private string? _ownerName; + [Inject] private CarApiClient CarApi { get; set; } = null!; [Inject] private CarHistoryApiClient CarHistoryApi { get; set; } = null!; + [Inject] private SharedWithMeApiClient SharedApi { get; set; } = null!; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted // prerender state - both default false so the forced render Blazor triggers right after the @@ -298,13 +327,29 @@ else private async Task FetchAsync() { - Car = await CarApi.GetOneAsync(Id); - if (Car is not null) + if (CanEdit) + { + Car = await CarApi.GetOneAsync(Id); + if (Car is not null) + { + var result = await CarHistoryApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["CarId"] = Id }); + // most recent first - same journal convention as HealthProfileDetail/HouseDetail + History = result.Items.OrderByDescending(h => h.HistoryDate).ToList(); + Metrics = await CarApi.GetMetricsAsync(Id); + BuildDerivedState(); + } + return; + } + + // Recipient (read-only): one composite call returns the car, its full history and metrics, scoped to + // the sharer server-side after the grant is verified. + var detail = await SharedApi.GetCarAsync(ShareId!, Id); + if (detail is not null) { - var result = await CarHistoryApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["CarId"] = Id }); - // most recent first - same journal convention as HealthProfileDetail/HouseDetail - History = result.Items.OrderByDescending(h => h.HistoryDate).ToList(); - Metrics = await CarApi.GetMetricsAsync(Id); + Car = detail.Parent; + _ownerName = detail.OwnerDisplayName; + History = detail.Children.OrderByDescending(h => h.HistoryDate).ToList(); + Metrics = detail.Metrics; BuildDerivedState(); } } diff --git a/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor b/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor index 90823177..3203e983 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CarHistoryRow.razor @@ -16,12 +16,15 @@ } - -
- - -
- + @if (!ReadOnly) + { + +
+ + +
+ + } @code { @@ -31,6 +34,9 @@ [Parameter] public string? WarningMessage { get; set; } + /// Read-only mode (a recipient viewing a shared car) hides the edit/delete actions column. + [Parameter] public bool ReadOnly { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } [Parameter] public EventCallback OnDelete { get; set; } diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index 99cc1f97..502104b8 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -20,11 +20,19 @@ else if (Profile is null) } else { - + @if (CanEdit) + { + + } + else + { + + }
- +
@@ -39,12 +47,15 @@ else
- -
-
- - +
+ @if (CanEdit) + { +
+ + +
+ }
@@ -67,7 +78,10 @@ else

Journal

- + @if (CanEdit) + { + + }
@* Excel-like on purpose: no header row, no per-field labels, aligned columns at every width (kt-table-grid opts out of the mobile stacked-cards treatment) - date, specialty, name, a warning @@ -80,7 +94,7 @@ else
- +
No entries yet - add the first one above.
@(CanEdit ? "No entries yet - add the first one above." : "No entries recorded.")
@@ -100,7 +114,7 @@ else @foreach (var entry in _recordsByYear.GetValueOrDefault(_selectedYear, [])) { - + } @@ -138,7 +152,7 @@ else
} - @if (_showModal) + @if (CanEdit && _showModal) {
@@ -167,10 +181,20 @@ else @code { [Parameter] public required string Id { get; set; } + /// Set when a recipient is viewing this health profile as shared with them (read-only). When null + /// this is the owner's own editable page - see CarDetail.ShareId for the shared/owner data-source split. + [Parameter] public string? ShareId { get; set; } + + private bool CanEdit => ShareId is null; + + private string? _ownerName; + [Inject] private HealthProfileApiClient HealthProfileApi { get; set; } = null!; [Inject] private HealthRecordApiClient HealthRecordApi { get; set; } = null!; + [Inject] private SharedWithMeApiClient SharedApi { get; set; } = null!; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted // prerender state - both default false so the forced render Blazor triggers right after the @@ -229,13 +253,29 @@ else private async Task FetchAsync() { - Profile = await HealthProfileApi.GetOneAsync(Id); - if (Profile is not null) + if (CanEdit) + { + Profile = await HealthProfileApi.GetOneAsync(Id); + if (Profile is not null) + { + var result = await HealthRecordApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["HealthProfileId"] = Id }); + // most recent first - a journal reads with the newest entry on top, same call as HouseDetail + Records = result.Items.OrderByDescending(r => r.HistoryDate).ToList(); + Metrics = await HealthProfileApi.GetMetricsAsync(Id); + BuildDerivedState(); + } + return; + } + + // Recipient (read-only): one composite call returns the profile, its full journal and metrics, scoped to + // the sharer server-side after the grant is verified. + var detail = await SharedApi.GetHealthProfileAsync(ShareId!, Id); + if (detail is not null) { - var result = await HealthRecordApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["HealthProfileId"] = Id }); - // most recent first - a journal reads with the newest entry on top, same call as HouseDetail - Records = result.Items.OrderByDescending(r => r.HistoryDate).ToList(); - Metrics = await HealthProfileApi.GetMetricsAsync(Id); + Profile = detail.Parent; + _ownerName = detail.OwnerDisplayName; + Records = detail.Children.OrderByDescending(r => r.HistoryDate).ToList(); + Metrics = detail.Metrics; BuildDerivedState(); } } diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor b/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor index e6e6e362..802bd6a0 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthRecordRow.razor @@ -15,17 +15,23 @@ } - -
- - -
- + @if (!ReadOnly) + { + +
+ + +
+ + } @code { [Parameter] public required HealthRecordDto Entry { get; set; } + /// Read-only mode (a recipient viewing a shared health journal) hides the edit/delete actions column. + [Parameter] public bool ReadOnly { get; set; } + /// Computed by HealthMetricsService (via the parent's metrics), never re-derived here - /// the balance rule lives in exactly one place. [Parameter] public bool IsUnbalanced { get; set; } diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor index a9ae3206..863358da 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor @@ -20,18 +20,26 @@ else if (House is null) } else { - + @if (CanEdit) + { + + } + else + { + + }
- +
@foreach (var propertyType in Enum.GetValues()) { - + }
@@ -46,24 +54,27 @@ else
- +
- +
- +
- -
-
- - +
+ @if (CanEdit) + { +
+ + +
+ }
@@ -121,7 +132,10 @@ else

History

- + @if (CanEdit) + { + + }
@* kt-table-grid opts this table out of the mobile stacked-cards treatment (see HealthProfileDetail.razor's journal for the same pattern) - a real spreadsheet-like grid with only the most important columns @@ -134,7 +148,7 @@ else
- +
No history yet - add the first entry above.
@(CanEdit ? "No history yet - add the first entry above." : "No history recorded.")
@@ -158,13 +172,16 @@ else Cost Provider Description - + @if (CanEdit) + { + + } @foreach (var entry in _historyByYear.GetValueOrDefault(_selectedYear, [])) { - + } @@ -172,7 +189,7 @@ else
} - @if (_showModal) + @if (CanEdit && _showModal) {
@@ -201,10 +218,20 @@ else @code { [Parameter] public required string Id { get; set; } + /// Set when a recipient is viewing this house as shared with them (read-only). When null this is the + /// owner's own editable page - see CarDetail.ShareId for the shared/owner data-source split. + [Parameter] public string? ShareId { get; set; } + + private bool CanEdit => ShareId is null; + + private string? _ownerName; + [Inject] private HouseApiClient HouseApi { get; set; } = null!; [Inject] private HouseHistoryApiClient HouseHistoryApi { get; set; } = null!; + [Inject] private SharedWithMeApiClient SharedApi { get; set; } = null!; + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted // prerender state - both default false so the forced render Blazor triggers right after the @@ -263,13 +290,29 @@ else private async Task FetchAsync() { - House = await HouseApi.GetOneAsync(Id); - if (House is not null) + if (CanEdit) + { + House = await HouseApi.GetOneAsync(Id); + if (House is not null) + { + var result = await HouseHistoryApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["HouseId"] = Id }); + // most recent first - same journal convention as HealthProfileDetail/CarDetail + History = result.Items.OrderByDescending(h => h.HistoryDate).ToList(); + Metrics = await HouseApi.GetMetricsAsync(Id); + BuildDerivedState(); + } + return; + } + + // Recipient (read-only): one composite call returns the house, its full history and metrics, scoped to + // the sharer server-side after the grant is verified. + var detail = await SharedApi.GetHouseAsync(ShareId!, Id); + if (detail is not null) { - var result = await HouseHistoryApi.GetAsync("", 1, int.MaxValue, new Dictionary { ["HouseId"] = Id }); - // most recent first - same journal convention as HealthProfileDetail/CarDetail - History = result.Items.OrderByDescending(h => h.HistoryDate).ToList(); - Metrics = await HouseApi.GetMetricsAsync(Id); + House = detail.Parent; + _ownerName = detail.OwnerDisplayName; + History = detail.Children.OrderByDescending(h => h.HistoryDate).ToList(); + Metrics = detail.Metrics; BuildDerivedState(); } } diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor b/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor index 028f1be7..b1eef950 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseHistoryRow.razor @@ -8,17 +8,23 @@ @Entry.Cost?.ToString("F2") @Entry.Provider @Entry.Description - -
- - -
- + @if (!ReadOnly) + { + +
+ + +
+ + } @code { [Parameter] public required HouseHistoryDto Entry { get; set; } + /// Read-only mode (a recipient viewing a shared house) hides the edit/delete actions column. + [Parameter] public bool ReadOnly { get; set; } + [Parameter] public EventCallback OnEdit { get; set; } [Parameter] public EventCallback OnDelete { get; set; } diff --git a/src/BlazorApp/Components/Sharing/SharedCarDetailPage.razor b/src/BlazorApp/Components/Sharing/SharedCarDetailPage.razor new file mode 100644 index 00000000..bed4cf48 --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharedCarDetailPage.razor @@ -0,0 +1,13 @@ +@page "/account/manage/shared/{ShareId}/cars/{Id}" +@using Keeptrack.BlazorApp.Components.Inventory.Pages +@attribute [Authorize(Policy = "MemberOnly")] + +@* The recipient route for a shared car. Reuses the owner's own CarDetail page in read-only mode (ShareId set), + which loads through the ownership-scoped /api/shared-with-me endpoints instead of the caller-scoped /api/cars. *@ + + +@code { + [Parameter] public required string ShareId { get; set; } + + [Parameter] public required string Id { get; set; } +} diff --git a/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor b/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor index 83acb3bb..34b3e60d 100644 --- a/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor +++ b/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor @@ -91,6 +91,29 @@ else break; + + case ShareCategory.Cars: + + break; + + case ShareCategory.Houses: + + break; + + case ShareCategory.Health: + + break; } } @@ -134,4 +157,14 @@ else private void SelectTab(ShareCategory category) => Navigation.NavigateTo(Navigation.GetUriWithQueryParameters(new Dictionary { ["tab"] = category.ToString() })); + + // Personal-list projections. Kept as methods (not inline lambdas in the markup) because a $"" interpolation + // inside a razor attribute value can't be delimited by the same double quotes the attribute uses. + private static string? CarSubtitle(CarDto car) => $"{car.Manufacturer} {car.Model}".Trim(); + + private string CarHref(CarDto car) => $"/account/manage/shared/{ShareId}/cars/{car.Id}"; + + private string HouseHref(HouseDto house) => $"/account/manage/shared/{ShareId}/houses/{house.Id}"; + + private string HealthHref(HealthProfileDto profile) => $"/account/manage/shared/{ShareId}/health/{profile.Id}"; } diff --git a/src/BlazorApp/Components/Sharing/SharedHealthDetailPage.razor b/src/BlazorApp/Components/Sharing/SharedHealthDetailPage.razor new file mode 100644 index 00000000..a2cd0f4a --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharedHealthDetailPage.razor @@ -0,0 +1,13 @@ +@page "/account/manage/shared/{ShareId}/health/{Id}" +@using Keeptrack.BlazorApp.Components.Inventory.Pages +@attribute [Authorize(Policy = "MemberOnly")] + +@* The recipient route for a shared health profile. Reuses the owner's HealthProfileDetail page in read-only + mode (ShareId set). *@ + + +@code { + [Parameter] public required string ShareId { get; set; } + + [Parameter] public required string Id { get; set; } +} diff --git a/src/BlazorApp/Components/Sharing/SharedHouseDetailPage.razor b/src/BlazorApp/Components/Sharing/SharedHouseDetailPage.razor new file mode 100644 index 00000000..d9af8f8b --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharedHouseDetailPage.razor @@ -0,0 +1,12 @@ +@page "/account/manage/shared/{ShareId}/houses/{Id}" +@using Keeptrack.BlazorApp.Components.Inventory.Pages +@attribute [Authorize(Policy = "MemberOnly")] + +@* The recipient route for a shared house. Reuses the owner's HouseDetail page in read-only mode (ShareId set). *@ + + +@code { + [Parameter] public required string ShareId { get; set; } + + [Parameter] public required string Id { get; set; } +} diff --git a/src/BlazorApp/Components/Sharing/SharedPersonalList.razor b/src/BlazorApp/Components/Sharing/SharedPersonalList.razor new file mode 100644 index 00000000..921406b8 --- /dev/null +++ b/src/BlazorApp/Components/Sharing/SharedPersonalList.razor @@ -0,0 +1,77 @@ +@typeparam TDto where TDto : class, IHasId + +@* A read-only list of one shared personal category (cars/houses/health). Unlike media, personal items are + never copyable and each row opens a full read-only detail page (the value is in the detail, not the list), + so this is a plain list of links - same markup shape as SharedWithMeListPage's list of sharers. *@ + +@if (!_loaded) +{ +
+
+ Loading… +
+} +else if (_error is not null) +{ +
@_error
+} +else if (_items.Count == 0) +{ +
+
+

Nothing shared here yet.

+
+} +else +{ + +} + +@code { + /// Fetches the sharer's items of this category (read-only). + [Parameter] public required Func>> Fetch { get; set; } + + [Parameter] public required Func ItemTitle { get; set; } + + [Parameter] public Func? ItemSubtitle { get; set; } + + /// Builds the read-only detail route for one item (e.g. /account/manage/shared/{id}/cars/{itemId}). + [Parameter] public required Func DetailHref { get; set; } + + private List _items = []; + private bool _loaded; + private string? _error; + + protected override async Task OnParametersSetAsync() + { + _loaded = false; + try + { + _items = await Fetch(); + _error = null; + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _loaded = true; + } + } +} diff --git a/src/BlazorApp/Components/Sharing/SharedWithMeApiClient.cs b/src/BlazorApp/Components/Sharing/SharedWithMeApiClient.cs index d2fdf1d5..1496c0d1 100644 --- a/src/BlazorApp/Components/Sharing/SharedWithMeApiClient.cs +++ b/src/BlazorApp/Components/Sharing/SharedWithMeApiClient.cs @@ -26,12 +26,30 @@ public async Task> GetSharedWithMeAsync() public Task?> GetAlbumsAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "albums", query); public Task?> GetVideoGamesAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "video-games", query); + // Personal categories (cars/houses/health): list + full read-only detail, never copyable. + public async Task> GetCarsAsync(string shareId) => await GetListAsync(shareId, "cars"); + public async Task> GetHousesAsync(string shareId) => await GetListAsync(shareId, "houses"); + public async Task> GetHealthProfilesAsync(string shareId) => await GetListAsync(shareId, "health-profiles"); + + public Task?> GetCarAsync(string shareId, string carId) => + http.GetFromJsonAsync>($"/api/shared-with-me/{shareId}/cars/{carId}"); + public Task?> GetHouseAsync(string shareId, string houseId) => + http.GetFromJsonAsync>($"/api/shared-with-me/{shareId}/houses/{houseId}"); + public Task?> GetHealthProfileAsync(string shareId, string profileId) => + http.GetFromJsonAsync>($"/api/shared-with-me/{shareId}/health-profiles/{profileId}"); + public Task?> CopyMovieAsync(string shareId, string itemId) => CopyAsync(shareId, "movies", itemId); public Task?> CopyTvShowAsync(string shareId, string itemId) => CopyAsync(shareId, "tv-shows", itemId); public Task?> CopyBookAsync(string shareId, string itemId) => CopyAsync(shareId, "books", itemId); public Task?> CopyAlbumAsync(string shareId, string itemId) => CopyAsync(shareId, "albums", itemId); public Task?> CopyVideoGameAsync(string shareId, string itemId) => CopyAsync(shareId, "video-games", itemId); + private async Task> GetListAsync(string shareId, string segment) + { + var result = await http.GetFromJsonAsync>($"/api/shared-with-me/{shareId}/{segment}"); + return result ?? []; + } + private Task?> GetPageAsync(string shareId, string segment, SharedListQuery query) { var url = new StringBuilder($"/api/shared-with-me/{shareId}/{segment}?page={query.Page}&pageSize={query.PageSize}"); diff --git a/src/BlazorApp/Components/Sharing/SharingPage.razor b/src/BlazorApp/Components/Sharing/SharingPage.razor index ddf0b6c2..a724f086 100644 --- a/src/BlazorApp/Components/Sharing/SharingPage.razor +++ b/src/BlazorApp/Components/Sharing/SharingPage.razor @@ -26,7 +26,7 @@
- +
@foreach (var (category, text) in s_mediaCategories) { @@ -34,7 +34,15 @@ data-testid="share-category" @onclick="() => ToggleCategory(category)">@text }
-

Sharing personal data (cars, houses, health) is coming soon.

+ + +
+ @foreach (var (category, text) in s_personalCategories) + { + + } +
- @if (!string.IsNullOrEmpty(collectible.Brand)) - { - @collectible.Brand - } - @if (collectible.Year > 0) - { - @collectible.Year - } - @if (collectible.IsFavorite) - { - Favorite - } - @if (collectible.OwnedVersions.Count > 0) - { - Owned - } +
diff --git a/src/BlazorApp/Components/Inventory/Pages/Gear.razor b/src/BlazorApp/Components/Inventory/Pages/Gear.razor index 8831e846..cea5fff1 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Gear.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Gear.razor @@ -52,26 +52,7 @@ } - @if (!string.IsNullOrEmpty(gear.Brand)) - { - @gear.Brand - } - @if (!string.IsNullOrEmpty(gear.Category)) - { - @gear.Category - } - @if (gear.Year > 0) - { - @gear.Year - } - @if (gear.IsFavorite) - { - Favorite - } - @if (gear.OwnedVersions.Count > 0) - { - Owned - } +
diff --git a/src/BlazorApp/Components/Sharing/SharedCategoryList.razor b/src/BlazorApp/Components/Sharing/SharedCategoryList.razor index b77305e5..948a0dce 100644 --- a/src/BlazorApp/Components/Sharing/SharedCategoryList.razor +++ b/src/BlazorApp/Components/Sharing/SharedCategoryList.razor @@ -50,7 +50,11 @@ var id = item.Id!; var inCollection = _added.Contains(id) || _alreadyInCollection.Contains(id); } - @if (inCollection) + @if (Copy is null) + { + @* View-only category (collectibles/gear): a read-only list with no "add to my collection" action. *@ + } + else if (inCollection) { ✓ In collection } @@ -79,8 +83,11 @@ /// Fetches one page for the current query. [Parameter] public required Func?>> Fetch { get; set; } - /// Copies the item with the given id into the caller's own collection. - [Parameter] public required Func?>> Copy { get; set; } + /// + /// Copies the item with the given id into the caller's own collection. Null for view-only categories + /// (collectibles/gear) that have no shared reference to copy - the list then renders no per-row action. + /// + [Parameter] public Func?>>? Copy { get; set; } [Parameter] public required Func ItemTitle { get; set; } [Parameter] public Func? ItemImageUrl { get; set; } @@ -194,6 +201,11 @@ private async Task AddAsync(string id) { + if (Copy is null) + { + return; + } + _copyingId = id; try { diff --git a/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor b/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor index e22ef796..3cbc544a 100644 --- a/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor +++ b/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor @@ -92,6 +92,24 @@ else break; + case ShareCategory.Collectibles: + + + + break; + + case ShareCategory.Gears: + + + + break; + case ShareCategory.Cars: > GetSharedWithMeAsync() public Task?> GetAlbumsAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "albums", query); public Task?> GetVideoGamesAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "video-games", query); + // Collection categories (collectibles/gear): same read-only list as media, but view-only (never copyable). + public Task?> GetCollectiblesAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "collectibles", query); + public Task?> GetGearAsync(string shareId, SharedListQuery query) => GetPageAsync(shareId, "gear", query); + // Personal categories (cars/houses/health): list + full read-only detail, never copyable. public async Task> GetCarsAsync(string shareId) => await GetListAsync(shareId, "cars"); public async Task> GetHousesAsync(string shareId) => await GetListAsync(shareId, "houses"); diff --git a/src/BlazorApp/Components/Sharing/SharingLabels.cs b/src/BlazorApp/Components/Sharing/SharingLabels.cs index 6cc6a921..ac6aac03 100644 --- a/src/BlazorApp/Components/Sharing/SharingLabels.cs +++ b/src/BlazorApp/Components/Sharing/SharingLabels.cs @@ -12,6 +12,7 @@ public static class SharingLabels { ShareCategory.TvShows => "TV shows", ShareCategory.VideoGames => "Video games", + ShareCategory.Gears => "Gear", _ => category.ToString() }; } diff --git a/src/BlazorApp/Components/Sharing/SharingPage.razor b/src/BlazorApp/Components/Sharing/SharingPage.razor index a724f086..4d81ebd7 100644 --- a/src/BlazorApp/Components/Sharing/SharingPage.razor +++ b/src/BlazorApp/Components/Sharing/SharingPage.razor @@ -35,6 +35,15 @@ }
+ +
+ @foreach (var (category, text) in s_collectionCategories) + { + + } +
+
@foreach (var (category, text) in s_personalCategories) @@ -113,6 +122,14 @@ else (ShareCategory.VideoGames, "Video games") ]; + // Collection categories are view-only for the recipient (never copyable, no shared reference to copy) but + // render as an ordinary list, unlike the personal categories' read-only detail views. + private static readonly (ShareCategory Category, string Text)[] s_collectionCategories = + [ + (ShareCategory.Collectibles, "Collectibles"), + (ShareCategory.Gears, "Gear") + ]; + // Personal categories are view-only for the recipient (never copyable) - the classifier enforces that // server-side. Health is the strictest and is never bundled: it only toggles on after an explicit confirm. private static readonly (ShareCategory Category, string Text)[] s_personalCategories = diff --git a/src/Domain/Models/ShareCategory.cs b/src/Domain/Models/ShareCategory.cs index 41615ad9..ca34ad25 100644 --- a/src/Domain/Models/ShareCategory.cs +++ b/src/Domain/Models/ShareCategory.cs @@ -12,6 +12,8 @@ public enum ShareCategory Books, Albums, VideoGames, + Collectibles, + Gears, Cars, Houses, Health diff --git a/src/Domain/Models/ShareKind.cs b/src/Domain/Models/ShareKind.cs index 77a7cde7..c13663d4 100644 --- a/src/Domain/Models/ShareKind.cs +++ b/src/Domain/Models/ShareKind.cs @@ -1,12 +1,18 @@ namespace Keeptrack.Domain.Models; /// -/// Whether a is ordinary media (viewable and copyable into the recipient's -/// own collection) or personal/sensitive data (view-only, never copyable). Derived from the category by -/// - the single place that rule lives - never stored. +/// How a is presented to the recipient and whether it can be copied. Derived +/// from the category by - the single place that rule lives - +/// never stored. /// public enum ShareKind { + /// Reference-linked media (movies, TV, books, albums, games): a read-only list, copyable into the recipient's own collection. Media, + + /// Ordinary owned collections with no shared reference to copy (collectibles, gear): a read-only list, view-only. + Collection, + + /// Personal/sensitive data (cars, houses, health): read-only detail views, never copyable. Personal } diff --git a/src/Domain/Services/ShareCategoryClassifier.cs b/src/Domain/Services/ShareCategoryClassifier.cs index 8a3a206e..cf7c6cc7 100644 --- a/src/Domain/Services/ShareCategoryClassifier.cs +++ b/src/Domain/Services/ShareCategoryClassifier.cs @@ -3,19 +3,21 @@ namespace Keeptrack.Domain.Services; /// -/// The single place the "is this category ordinary media or personal/sensitive" rule lives. Media is -/// viewable and copyable into the recipient's own collection; personal data (cars, houses, health) is -/// view-only and never copyable. Both the copy endpoint's gate and the owner UI's grouping derive from -/// this, so the rule can never drift between them. +/// The single place the "how is this category presented, and can it be copied" rule lives. Media is a +/// read-only list copyable into the recipient's own collection; ordinary collections (collectibles, gear) +/// are a read-only list but view-only (no shared reference identity to copy); personal data (cars, houses, +/// health) is a read-only detail view and never copyable. Both the copy endpoint's gate and the owner UI's +/// grouping derive from this, so the rule can never drift between them. /// public static class ShareCategoryClassifier { public static ShareKind KindOf(ShareCategory category) => category switch { ShareCategory.Cars or ShareCategory.Houses or ShareCategory.Health => ShareKind.Personal, + ShareCategory.Collectibles or ShareCategory.Gears => ShareKind.Collection, _ => ShareKind.Media }; - /// Media categories can be copied; personal ones never can. + /// Only reference-linked media can be copied; collections and personal data never can. public static bool IsCopyable(ShareCategory category) => KindOf(category) == ShareKind.Media; } diff --git a/src/WebApi.Contracts/Dto/ShareCategory.cs b/src/WebApi.Contracts/Dto/ShareCategory.cs index b9410aac..576435e6 100644 --- a/src/WebApi.Contracts/Dto/ShareCategory.cs +++ b/src/WebApi.Contracts/Dto/ShareCategory.cs @@ -22,6 +22,12 @@ public enum ShareCategory /// The caller's video games. VideoGames, + /// The caller's collectibles (view-only list, never copyable). + Collectibles, + + /// The caller's gear (view-only list, never copyable). + Gears, + /// The caller's cars (personal - view-only, never copyable). Cars, diff --git a/src/WebApi/Controllers/SharedWithMeController.cs b/src/WebApi/Controllers/SharedWithMeController.cs index 4c4a69ce..cb8210f2 100644 --- a/src/WebApi/Controllers/SharedWithMeController.cs +++ b/src/WebApi/Controllers/SharedWithMeController.cs @@ -27,6 +27,8 @@ public class SharedWithMeController( IBookRepository bookRepository, IAlbumRepository albumRepository, IVideoGameRepository videoGameRepository, + ICollectibleRepository collectibleRepository, + IGearRepository gearRepository, IMovieReferenceRepository movieReferenceRepository, ITvShowReferenceRepository tvShowReferenceRepository, IBookReferenceRepository bookReferenceRepository, @@ -37,6 +39,8 @@ public class SharedWithMeController( IDtoMapper bookMapper, IDtoMapper albumMapper, IDtoMapper videoGameMapper, + IDtoMapper collectibleMapper, + IDtoMapper gearMapper, ICarRepository carRepository, ICarHistoryRepository carHistoryRepository, IHouseRepository houseRepository, @@ -106,6 +110,21 @@ public Task>> GetVideoGames(str ReadAsync(shareId, DomainShareCategory.VideoGames, videoGameRepository, videoGameMapper, filter, paging, VideoGameKey, items => HydrateWithCustomOverrideAsync(items, videoGameReferenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl)); + // ---- collection reads (collectibles/gear: same read-only list as media, but view-only - no shared + // reference to hydrate a cover from and no copy, so a leaner paged read than the media path) ---- + + [HttpGet("{shareId}/collectibles")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public Task>> GetCollectibles(string shareId, [FromQuery] PagedRequest paging, [FromQuery] CollectibleDto filter) => + ReadOwnedListAsync(shareId, DomainShareCategory.Collectibles, collectibleRepository, collectibleMapper, filter, paging); + + [HttpGet("{shareId}/gear")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public Task>> GetGear(string shareId, [FromQuery] PagedRequest paging, [FromQuery] GearDto filter) => + ReadOwnedListAsync(shareId, DomainShareCategory.Gears, gearRepository, gearMapper, filter, paging); + // ---- copy into the caller's own collection (media only, idempotent) ---- [HttpPost("{shareId}/movies/{itemId}/copy")] @@ -290,6 +309,38 @@ private async Task>> ReadAsync + /// A view-only shared list (collectibles/gear): the same paged read as the media path - honouring the + /// recipient's search/sort/favourite/owned query - but with no reference-image hydration (these types + /// carry their own tenant image) and no copy dedup (they're never copyable), so + /// stays empty. Unlike + /// the DTO need not be an . + /// + private async Task>> ReadOwnedListAsync( + string shareId, DomainShareCategory category, + IDataRepository repository, IDtoMapper mapper, + TDto filter, PagedRequest paging) + where TModel : class, IHasIdAndOwnerId + where TDto : IHasId, new() + { + var share = await ResolveGrantAsync(shareId, category); + if (share is null) + { + return NotFound(); + } + + var page = await repository.FindAllAsync(share.OwnerId, paging.Page, paging.PageSize, paging.Search, mapper.ToModel(filter), paging.Sort); + var dtoPage = page.Map(mapper.ToDto); + + return Ok(new SharedCategoryPageDto + { + Items = dtoPage.Items, + TotalCount = page.TotalCount, + Page = page.Page, + PageSize = page.PageSize + }); + } + private async Task>> CopyAsync( string shareId, DomainShareCategory category, string itemId, IDataRepository repository, IDtoMapper mapper, diff --git a/test/WebApi.IntegrationTests/Resources/ShareResourceTest.cs b/test/WebApi.IntegrationTests/Resources/ShareResourceTest.cs index 8f077259..9dcd2a50 100644 --- a/test/WebApi.IntegrationTests/Resources/ShareResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/ShareResourceTest.cs @@ -128,6 +128,51 @@ public async Task PersonalShare_IsReadableAsListAndReadOnlyDetail_ButNeverCopyab } } + [Fact] + public async Task CollectionShare_IsReadableAsAFilterableList_ButNeverCopyable() + { + await Authenticate(); + var ownEmail = FirebaseConfiguration.Username; + var tag = Guid.NewGuid().ToString("N"); + + var favourite = await PostAsync("/api/collectibles", new CollectibleDto { Title = $"ShareColFav-{tag}", Brand = "Lego", Year = 2015, IsFavorite = true }); + var plain = await PostAsync("/api/collectibles", new CollectibleDto { Title = $"ShareColPlain-{tag}", Year = 2018 }); + + var share = await PostAsync("/api/shares", new CreateShareRequestDto + { + RecipientEmail = ownEmail, + IncludedCategories = [ShareCategory.Collectibles] + }); + + try + { + (await GetAsync>("/api/shared-with-me")) + .Should().Contain(s => s.ShareId == share.Id && s.IncludedCategories.Contains(ShareCategory.Collectibles)); + + // the shared collectibles read as a normal paged list, searchable like the owner's own list + var page = await GetAsync>($"/api/shared-with-me/{share.Id}/collectibles?search={tag}"); + page.Items.Should().Contain(c => c.Id == favourite.Id).And.Contain(c => c.Id == plain.Id); + // a view-only category never advertises copy-ability + page.AlreadyInCollectionIds.Should().BeEmpty(); + + // the favourites filter narrows to the favourite only + var favPage = await GetAsync>($"/api/shared-with-me/{share.Id}/collectibles?search={tag}&IsFavorite=true"); + favPage.Items.Should().Contain(c => c.Id == favourite.Id).And.NotContain(c => c.Id == plain.Id); + + // a category not in scope is an indistinguishable 404 + await GetAsync($"/api/shared-with-me/{share.Id}/gear", HttpStatusCode.NotFound); + + // collections are never copyable - there is deliberately no copy route for them + await PostNoContentAsync($"/api/shared-with-me/{share.Id}/collectibles/{favourite.Id}/copy", new { }, HttpStatusCode.NotFound); + } + finally + { + await DeleteAsync($"/api/collectibles/{favourite.Id}"); + await DeleteAsync($"/api/collectibles/{plain.Id}"); + await DeleteAsync($"/api/shares/{share.Id}"); + } + } + [Fact] public async Task ShareToADifferentEmail_IsNotVisibleToOthers() { diff --git a/test/WebApi.UnitTests/Services/ShareCategoryClassifierTest.cs b/test/WebApi.UnitTests/Services/ShareCategoryClassifierTest.cs index a621a491..5ffc9df5 100644 --- a/test/WebApi.UnitTests/Services/ShareCategoryClassifierTest.cs +++ b/test/WebApi.UnitTests/Services/ShareCategoryClassifierTest.cs @@ -20,6 +20,15 @@ public void MediaCategories_AreMedia_AndCopyable(ShareCategory category) ShareCategoryClassifier.IsCopyable(category).Should().BeTrue(); } + [Theory] + [InlineData(ShareCategory.Collectibles)] + [InlineData(ShareCategory.Gears)] + public void CollectionCategories_AreCollection_AndNeverCopyable(ShareCategory category) + { + ShareCategoryClassifier.KindOf(category).Should().Be(ShareKind.Collection); + ShareCategoryClassifier.IsCopyable(category).Should().BeFalse(); + } + [Theory] [InlineData(ShareCategory.Cars)] [InlineData(ShareCategory.Houses)] From 25798c9efb421e8b16a19975b53e85cf3c8de3f1 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 28 Jul 2026 23:28:08 +0200 Subject: [PATCH 21/80] Order tabs in sharing view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was happening The shared collection's tabs were rendered straight from share.IncludedCategories, whose stored order reflects the order the owner clicked the category buttons when creating the share. That's because SharingPage.razor builds the grant from a HashSet (IncludedCategories = [.. _selected]), so click order leaks through to storage and then to the recipient's tabs. Fix Added a single canonical ordering helper, SharingLabels.Ordered(...), whose order matches the left nav menu (NavMenu.razor): Movies, TV shows, Books, Albums, Video games, Cars, Houses, Health, Collectibles, Gear. (Note the enum's own declaration order differs — it lists Collectibles/Gear before Cars/Houses/Health — so sorting by the enum wouldn't have matched the menu.) Applied it everywhere categories are displayed, so nothing depends on the stored click order anymore: - SharedCollectionPage.razor — the recipient's tab bar, and the default-selected tab fallback (so the landing tab is the first one shown). - SharingPage.razor — the owner's "Active shares" category summary. - SharedWithMeListPage.razor — the recipient's "shared with me" list summary. The stored data is untouched (categories are stored by name), this is purely a display-order change. One note: SharingLabels lives in BlazorApp, which has no unit-test project (tests are WebApi/integration/Playwright only), so the ordering helper isn't covered by an automated test — it's straightforward display logic. --- .../Sharing/SharedCollectionPage.razor | 4 +-- .../Sharing/SharedWithMeListPage.razor | 2 +- .../Components/Sharing/SharingLabels.cs | 28 +++++++++++++++++++ .../Components/Sharing/SharingPage.razor | 2 +- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor b/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor index 3cbc544a..ec26b04b 100644 --- a/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor +++ b/src/BlazorApp/Components/Sharing/SharedCollectionPage.razor @@ -27,7 +27,7 @@ else
- @foreach (var category in _summary.IncludedCategories) + @foreach (var category in SharingLabels.Ordered(_summary.IncludedCategories)) {
From fbfb328290d7939fba07992050f4323adfb023ee Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 29 Jul 2026 10:10:44 +0200 Subject: [PATCH 22/80] Fix custom image display in wishlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wishlist wasn't showing books' custom images because WishlistController.BuildWishlistAsync only hydrated the cover from the linked reference document, never applying the tenant-owned CustomImageUrl override that books (and video games) carry. The override logic (hydrate reference cover, then let a non-empty CustomImageUrl win) was already duplicated in four places — BookController, VideoGameController, AlbumController, and a private helper in SharedWithMeController — and simply missing from the wishlist. Rather than add a fifth copy, I: - Promoted the helper into the shared ReferenceImageHydrator.HydrateWithCustomOverrideAsync (the one place that already owns the reference-image batch-lookup logic). - Routed every call site through it: the three CRUD controllers' OnListMappedAsync, SharedWithMeController's three reads (removing its now-redundant private helper), and — the actual fix — the wishlist's Books and VideoGames. Movies and TV shows in the wishlist stay on the plain HydrateAsync path since neither has a CustomImageUrl concept. Net result: the algorithm now lives once, and custom book/game covers render in the wishlist (and shared wishlist) just as they do on the list pages. --- src/WebApi/Controllers/AlbumController.cs | 10 ++----- src/WebApi/Controllers/BookController.cs | 10 ++----- .../Controllers/ReferenceImageHydrator.cs | 25 ++++++++++++++++ .../Controllers/SharedWithMeController.cs | 29 ++----------------- src/WebApi/Controllers/VideoGameController.cs | 10 ++----- src/WebApi/Controllers/WishlistController.cs | 6 ++-- 6 files changed, 38 insertions(+), 52 deletions(-) diff --git a/src/WebApi/Controllers/AlbumController.cs b/src/WebApi/Controllers/AlbumController.cs index 2c8722f2..58376d22 100644 --- a/src/WebApi/Controllers/AlbumController.cs +++ b/src/WebApi/Controllers/AlbumController.cs @@ -25,14 +25,8 @@ public class AlbumController( /// its own set overrides that afterward - see /// . /// - protected override async Task OnListMappedAsync(List dtos) - { - await ReferenceImageHydrator.HydrateAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl); - foreach (var dto in dtos.Where(d => !string.IsNullOrEmpty(d.CustomImageUrl))) - { - dto.ImageUrl = dto.CustomImageUrl; - } - } + protected override Task OnListMappedAsync(List dtos) => + ReferenceImageHydrator.HydrateWithCustomOverrideAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl); /// /// Fires a best-effort background Discogs match for the new album - see . diff --git a/src/WebApi/Controllers/BookController.cs b/src/WebApi/Controllers/BookController.cs index b978c64e..28eab3f6 100644 --- a/src/WebApi/Controllers/BookController.cs +++ b/src/WebApi/Controllers/BookController.cs @@ -26,14 +26,8 @@ public class BookController( /// (not shared via /, /// which the other four reference-linked types also implement, with no equivalent override field). /// - protected override async Task OnListMappedAsync(List dtos) - { - await ReferenceImageHydrator.HydrateAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl); - foreach (var dto in dtos.Where(d => !string.IsNullOrEmpty(d.CustomImageUrl))) - { - dto.ImageUrl = dto.CustomImageUrl; - } - } + protected override Task OnListMappedAsync(List dtos) => + ReferenceImageHydrator.HydrateWithCustomOverrideAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl); /// /// Fires a best-effort background Open Library match for the new book - see . diff --git a/src/WebApi/Controllers/ReferenceImageHydrator.cs b/src/WebApi/Controllers/ReferenceImageHydrator.cs index 735ab265..d3b6fe6b 100644 --- a/src/WebApi/Controllers/ReferenceImageHydrator.cs +++ b/src/WebApi/Controllers/ReferenceImageHydrator.cs @@ -1,4 +1,5 @@ using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; namespace Keeptrack.WebApi.Controllers; @@ -35,4 +36,28 @@ public static async Task HydrateAsync( } } } + + /// + /// Reference-image hydration plus the tenant's own CustomImageUrl override (book/album/game only), + /// which takes priority over the linked reference's cover. Shared by those types' list controllers, the + /// shared-with-me reads and the wishlist so the "hydrate then override" order lives in exactly one place. + /// + public static async Task HydrateWithCustomOverrideAsync( + IReadOnlyList dtos, + Func, Task>> findReferencesByIds, + Func referenceImageUrl, + Func customImageUrl) + where TDto : IReferenceLinkedDto + where TReference : IHasId + { + await HydrateAsync(dtos, findReferencesByIds, referenceImageUrl); + foreach (var dto in dtos) + { + var custom = customImageUrl(dto); + if (!string.IsNullOrEmpty(custom)) + { + dto.ImageUrl = custom; + } + } + } } diff --git a/src/WebApi/Controllers/SharedWithMeController.cs b/src/WebApi/Controllers/SharedWithMeController.cs index cb8210f2..2f220dcd 100644 --- a/src/WebApi/Controllers/SharedWithMeController.cs +++ b/src/WebApi/Controllers/SharedWithMeController.cs @@ -94,21 +94,21 @@ public Task>> GetTvShows(string sh [ProducesResponseType(404)] public Task>> GetBooks(string shareId, [FromQuery] PagedRequest paging, [FromQuery] BookDto filter) => ReadAsync(shareId, DomainShareCategory.Books, bookRepository, bookMapper, filter, paging, BookKey, - items => HydrateWithCustomOverrideAsync(items, bookReferenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl)); + items => ReferenceImageHydrator.HydrateWithCustomOverrideAsync(items, bookReferenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl)); [HttpGet("{shareId}/albums")] [ProducesResponseType(200)] [ProducesResponseType(404)] public Task>> GetAlbums(string shareId, [FromQuery] PagedRequest paging, [FromQuery] AlbumDto filter) => ReadAsync(shareId, DomainShareCategory.Albums, albumRepository, albumMapper, filter, paging, AlbumKey, - items => HydrateWithCustomOverrideAsync(items, albumReferenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl)); + items => ReferenceImageHydrator.HydrateWithCustomOverrideAsync(items, albumReferenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl)); [HttpGet("{shareId}/video-games")] [ProducesResponseType(200)] [ProducesResponseType(404)] public Task>> GetVideoGames(string shareId, [FromQuery] PagedRequest paging, [FromQuery] VideoGameDto filter) => ReadAsync(shareId, DomainShareCategory.VideoGames, videoGameRepository, videoGameMapper, filter, paging, VideoGameKey, - items => HydrateWithCustomOverrideAsync(items, videoGameReferenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl)); + items => ReferenceImageHydrator.HydrateWithCustomOverrideAsync(items, videoGameReferenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl)); // ---- collection reads (collectibles/gear: same read-only list as media, but view-only - no shared // reference to hydrate a cover from and no copy, so a leaner paged read than the media path) ---- @@ -454,27 +454,4 @@ private async Task>> ReadPersonalListAsync var children = await childRepository.FindAllAsync(share.OwnerId, 1, int.MaxValue, null, makeChildFilter(parentId, share.OwnerId)); return (parent, children.Items, share.OwnerDisplayName); } - - /// - /// Reference-image hydration plus the tenant's own CustomImageUrl override (book/album/game - /// only), matching what those types' own list controllers do - the recipient sees the same cover. - /// - private static async Task HydrateWithCustomOverrideAsync( - IReadOnlyList dtos, - Func, Task>> findReferencesByIds, - Func referenceImageUrl, - Func customImageUrl) - where TDto : IReferenceLinkedDto - where TReference : IHasId - { - await ReferenceImageHydrator.HydrateAsync(dtos, findReferencesByIds, referenceImageUrl); - foreach (var dto in dtos) - { - var custom = customImageUrl(dto); - if (!string.IsNullOrEmpty(custom)) - { - dto.ImageUrl = custom; - } - } - } } diff --git a/src/WebApi/Controllers/VideoGameController.cs b/src/WebApi/Controllers/VideoGameController.cs index e8e9485c..c538d106 100644 --- a/src/WebApi/Controllers/VideoGameController.cs +++ b/src/WebApi/Controllers/VideoGameController.cs @@ -25,14 +25,8 @@ public class VideoGameController( /// its own set overrides that afterward - see /// . /// - protected override async Task OnListMappedAsync(List dtos) - { - await ReferenceImageHydrator.HydrateAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl); - foreach (var dto in dtos.Where(d => !string.IsNullOrEmpty(d.CustomImageUrl))) - { - dto.ImageUrl = dto.CustomImageUrl; - } - } + protected override Task OnListMappedAsync(List dtos) => + ReferenceImageHydrator.HydrateWithCustomOverrideAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl); /// /// Fires a best-effort background RAWG match for the new game - see . diff --git a/src/WebApi/Controllers/WishlistController.cs b/src/WebApi/Controllers/WishlistController.cs index ec56c023..018befe5 100644 --- a/src/WebApi/Controllers/WishlistController.cs +++ b/src/WebApi/Controllers/WishlistController.cs @@ -114,8 +114,10 @@ private async Task BuildWishlistAsync(string ownerId) await ReferenceImageHydrator.HydrateAsync(result.Movies, movieReferenceRepository.FindByIdsAsync, x => x.ImageUrl); await ReferenceImageHydrator.HydrateAsync(result.TvShows, tvShowReferenceRepository.FindByIdsAsync, x => x.ImageUrl); - await ReferenceImageHydrator.HydrateAsync(result.Books, bookReferenceRepository.FindByIdsAsync, x => x.ImageUrl); - await ReferenceImageHydrator.HydrateAsync(result.VideoGames, videoGameReferenceRepository.FindByIdsAsync, x => x.ImageUrl); + // Book/video game carry a tenant-owned CustomImageUrl that overrides the reference cover, same as their + // own list controllers - hydrate-then-override so a custom cover shows here too (movies/TV shows have none). + await ReferenceImageHydrator.HydrateWithCustomOverrideAsync(result.Books, bookReferenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl); + await ReferenceImageHydrator.HydrateWithCustomOverrideAsync(result.VideoGames, videoGameReferenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl); return result; } From 1fd68483d54746fa8a300fa5d7cc3d0edb89fc25 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 29 Jul 2026 10:18:57 +0200 Subject: [PATCH 23/80] Add test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix Books' (and video games') tenant-owned CustomImageUrl override was applied on the per-type list endpoints but not in the wishlist, so custom covers silently fell back to the reference cover (or nothing). - Promoted the "hydrate reference cover, then let CustomImageUrl win" logic into a shared ReferenceImageHydrator.HydrateWithCustomOverrideAsync — it previously existed as a private copy in SharedWithMeController plus three near-identical inline copies in Book/VideoGame/Album controllers. - Routed all of them through it, plus the actual bug fix in WishlistController for Books and VideoGames (movies/TV shows stay on the plain path — they have no CustomImageUrl). Tests New WishlistResourceTest with two cases asserting a CustomImageUrl beats the linked reference cover in the wishlist payload, for books and video games. Both pass against local MongoDB. Docs - Fixed the now-inaccurate BookController.OnListMappedAsync doc comment (it claimed the override was "Book-specific, not shared via ReferenceImageHydrator" — the opposite of what it now is). - CLAUDE.md doesn't document CustomImageUrl, so nothing needed changing there for the feature itself. The test-filter gotcha you hit Documented in CLAUDE.md's commands section: --settings Local.runsettings and --filter-method can't be combined — --settings forces legacy VSTest mode, which rejects the Microsoft.Testing.Platform filter flags and silently runs zero tests (exit 5, "error: 1"). Added the working recipe: load the runsettings' env vars into the shell, then filter without --settings. A full unfiltered run can still use --settings. One thing I did not do: I left the AlbumResourceTest/BookResourceTest/VideoGameResourceTest existing override tests untouched since they still pass and cover the list-endpoint path. The shared-with-me endpoints already had override coverage via their own reads. If you'd like belt-and-suspenders coverage for the shared wishlist anonymous path (/api/wishlist/shared/{token}) specifically, I can add that too — but it exercises the identical BuildWishlistAsync code path, so I judged it redundant. --- CLAUDE.md | 14 ++- src/WebApi/Controllers/BookController.cs | 7 +- .../Resources/WishlistResourceTest.cs | 101 ++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 test/WebApi.IntegrationTests/Resources/WishlistResourceTest.cs diff --git a/CLAUDE.md b/CLAUDE.md index f1211dda..467ab62b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ dotnet test dotnet test test/WebApi.UnitTests/WebApi.UnitTests.csproj dotnet test test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj -# run a single test by fully qualified name +# run a single test by fully qualified name (--filter-method / --filter-class / --filter-namespace, '*' wildcard allowed) dotnet test --filter-method "Keeptrack.WebApi.UnitTests.Services.WatchNextServiceTest.ComputeInProgressShows_IncludesShowWithAConfirmedAiredUnwatchedNextEpisode" # build container images @@ -51,6 +51,18 @@ Integration tests also need Firebase test-user credentials and MongoDB connectio Provide them as environment variables, or in a `Local.runsettings` file at the repository root (see `CONTRIBUTING.md` for the template). Never commit this file. +**Gotcha:** `--settings Local.runsettings` and `--filter-method`/`--filter-class` cannot be combined. +`--settings` switches `dotnet test` into legacy VSTest mode, which rejects the Microsoft.Testing.Platform simple-filter flags and silently runs zero tests (exit code 5, "error: 1"). +To run a *filtered* subset of the integration tests, load the runsettings' env vars into the shell instead of passing `--settings`, then filter in MTP mode: + +```powershell +[xml]$rs = Get-Content Local.runsettings +$rs.RunSettings.RunConfiguration.EnvironmentVariables.ChildNodes | Where-Object { $_.NodeType -eq 'Element' } | ForEach-Object { Set-Item -Path "env:$($_.Name)" -Value $_.InnerText } +dotnet test test/WebApi.IntegrationTests/WebApi.IntegrationTests.csproj --filter-method "Keeptrack.WebApi.IntegrationTests.Resources.WishlistResourceTest.*" +``` + +A full run with no filter can still use `--settings Local.runsettings` as before. + ## Architecture The solution follows a layered / clean-architecture style split across small, single-purpose projects (`src/*`), with `Domain` at the center and no project referencing "outward": diff --git a/src/WebApi/Controllers/BookController.cs b/src/WebApi/Controllers/BookController.cs index 28eab3f6..8bef0f1b 100644 --- a/src/WebApi/Controllers/BookController.cs +++ b/src/WebApi/Controllers/BookController.cs @@ -22,9 +22,10 @@ public class BookController( /// /// Hydrates each page item's cover image from its linked reference document - one batched lookup per /// page (see ), keyed by the id-bearing documents only. A book with - /// its own set overrides that afterward - this is Book-specific - /// (not shared via /, - /// which the other four reference-linked types also implement, with no equivalent override field). + /// its own set overrides that afterward, via the shared + /// . Only Book/Album/VideoGame carry a + /// CustomImageUrl; the other reference-linked types (movie, TV show) have no equivalent override and + /// use the plain path instead. /// protected override Task OnListMappedAsync(List dtos) => ReferenceImageHydrator.HydrateWithCustomOverrideAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl, x => x.CustomImageUrl); diff --git a/test/WebApi.IntegrationTests/Resources/WishlistResourceTest.cs b/test/WebApi.IntegrationTests/Resources/WishlistResourceTest.cs new file mode 100644 index 00000000..3bfb8860 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/WishlistResourceTest.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// The wishlist aggregates several media types into one payload and hydrates each item's cover the same way +/// the individual list endpoints do. Book/video game carry a tenant-owned CustomImageUrl that must +/// override the linked reference's own cover here too - this used to be applied only on the per-type list +/// controllers, never on the wishlist, so a custom cover silently vanished on the wishlist and the shared view. +/// +public class WishlistResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + [Fact] + public async Task Wishlist_AppliesCustomImageUrlOverrideForBooks() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + + var reference = await referenceRepository.UpsertAsync(new BookReferenceModel + { + Title = "Some Reference Title", + TitleNormalized = "some reference title", + ExternalIds = new Dictionary { ["googlebooks"] = $"gb-{Guid.NewGuid():N}" }, + ImageUrl = "https://example.com/reference-cover.jpg" + }); + + await Authenticate(); + const string customImageUrl = "https://example.com/custom-book-cover.jpg"; + var created = await PostAsync("/api/books", new BookDto + { + Title = $"WishlistCustomCoverBook-{Guid.NewGuid():N}", + Author = "Some Author", + ReferenceId = reference.Id, + CustomImageUrl = customImageUrl, + IsWishlisted = true + }); + + try + { + var wishlist = await GetAsync("/api/wishlist"); + var item = wishlist.Books.Should().ContainSingle(b => b.Id == created.Id).Subject; + item.ImageUrl.Should().Be(customImageUrl); + } + finally + { + await DeleteAsync($"/api/books/{created.Id}"); + var referenceCollection = scope.ServiceProvider.GetRequiredService().GetCollection("book_reference"); + await referenceCollection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, reference.Id), TestContext.Current.CancellationToken); + } + } + + [Fact] + public async Task Wishlist_AppliesCustomImageUrlOverrideForVideoGames() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + + var reference = await referenceRepository.UpsertAsync(new VideoGameReferenceModel + { + Title = "Some Reference Title", + TitleNormalized = "some reference title", + ExternalIds = new Dictionary { ["rawg"] = $"rawg-{Guid.NewGuid():N}" }, + ImageUrl = "https://example.com/reference-cover.jpg" + }); + + await Authenticate(); + const string customImageUrl = "https://example.com/custom-game-cover.jpg"; + var created = await PostAsync("/api/video-games", new VideoGameDto + { + Title = $"WishlistCustomCoverGame-{Guid.NewGuid():N}", + ReferenceId = reference.Id, + CustomImageUrl = customImageUrl, + IsWishlisted = true + }); + + try + { + var wishlist = await GetAsync("/api/wishlist"); + var item = wishlist.VideoGames.Should().ContainSingle(g => g.Id == created.Id).Subject; + item.ImageUrl.Should().Be(customImageUrl); + } + finally + { + await DeleteAsync($"/api/video-games/{created.Id}"); + var referenceCollection = scope.ServiceProvider.GetRequiredService().GetCollection("videogame_reference"); + await referenceCollection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, reference.Id), TestContext.Current.CancellationToken); + } + } +} From 14fd026f7a5fe84b94fec8c8f03b33db82b73251 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 29 Jul 2026 10:33:26 +0200 Subject: [PATCH 24/80] UI title refresh improvement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary of this round - Shorter messages (ReferenceRefreshMessage.cs): No match found → No match, Unlinked - no match → Unlinked, Already linked → No change. Linked! unchanged. - Narrower slot (app.css): reserved width 8.5rem → 6rem, which comfortably fits all four messages now. - Mobile: no reservation — inside the existing @media (max-width: 767px) block, .kt-title-toast-slot collapses to width: auto; flex: 0 1 auto, so on phones the (already small) title keeps its full width and the slot only takes room while a message is actually showing. Desktop now keeps the title stable with a modest 6rem reserved gap; mobile behaves as it did before, minus the permanent squeeze. --- .../Inventory/Pages/AlbumDetail.razor | 10 ++++++---- .../Components/Inventory/Pages/BookDetail.razor | 10 ++++++---- .../Inventory/Pages/MovieDetail.razor | 10 ++++++---- .../Inventory/Pages/TvShowDetail.razor | 10 ++++++---- .../Inventory/Pages/VideoGameDetail.razor | 10 ++++++---- .../Shared/ReferenceRefreshMessage.cs | 6 +++--- src/BlazorApp/wwwroot/app.css | 17 +++++++++++++++++ 7 files changed, 50 insertions(+), 23 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor index 901a887d..d64175cf 100644 --- a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor @@ -27,6 +27,12 @@ else
+ + @if (_refreshMessage is not null) + { + @_refreshMessage + } +
diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor index 5cb77545..e89854dd 100644 --- a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -27,6 +27,12 @@ else
+ + @if (_refreshMessage is not null) + { + @_refreshMessage + } +
diff --git a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor index 76c8a5a0..fc11db12 100644 --- a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor @@ -27,6 +27,12 @@ else
+ + @if (_refreshMessage is not null) + { + @_refreshMessage + } +
diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor index 917d37db..83db64a9 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor @@ -27,6 +27,12 @@ else
+ + @if (_refreshMessage is not null) + { + @_refreshMessage + } +
diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor index 4d07ef5b..b09b61b2 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor @@ -27,6 +27,12 @@ else
+ + @if (_refreshMessage is not null) + { + @_refreshMessage + } +
diff --git a/src/BlazorApp/Components/Shared/ReferenceRefreshMessage.cs b/src/BlazorApp/Components/Shared/ReferenceRefreshMessage.cs index d4d5facf..620a19e4 100644 --- a/src/BlazorApp/Components/Shared/ReferenceRefreshMessage.cs +++ b/src/BlazorApp/Components/Shared/ReferenceRefreshMessage.cs @@ -12,12 +12,12 @@ public static (string Message, string Style) Compute(string? previousReferenceId if (string.IsNullOrEmpty(newReferenceId)) { return string.IsNullOrEmpty(previousReferenceId) - ? ("No match found", "neutral") - : ("Unlinked - no match", "danger"); + ? ("No match", "neutral") + : ("Unlinked", "danger"); } return newReferenceId == previousReferenceId - ? ("Already linked", "neutral") + ? ("No change", "neutral") : ("Linked!", "success"); } } diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index 5d02101f..a4867b75 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -644,6 +644,20 @@ a.kt-item-row { color: inherit; text-decoration: none; } .kt-inline-toast.danger { background: var(--kt-danger-bg); color: var(--kt-danger); } @keyframes kt-toast-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: translateY(0); } } +/* fixed-width reserved slot for the transient refresh-reference toast, sitting to the left of the + refresh/unlink icons. The width is reserved permanently (even with no message) so the responsive + kt-title-input never resizes - and the icon buttons never shift - as a message appears and then + auto-dismisses a few seconds later. A longer-than-usual message overflows leftward over the title's + tail rather than widening the slot, so the geometry stays constant regardless of message length. */ +.kt-title-toast-slot { + flex: 0 0 auto; + width: 6rem; + display: flex; + align-items: center; + justify-content: flex-end; + overflow: visible; +} + /* small "more options" popup anchored under a kt-icon-btn (e.g. a track row's "⋮" menu) - the row it's anchored to needs position:relative so this absolute box lands directly under the button, not the page. */ .kt-menu { @@ -1009,6 +1023,9 @@ a.kt-item-row { color: inherit; text-decoration: none; } /* the title group must wrap to its own full-width row - its desktop flex:1 + min-width:0 otherwise lets it shrink to (near) zero width beside the flag toggles instead of ever wrapping */ .kt-title-row { flex-basis: 100%; } + /* don't permanently reserve toast space on mobile - the title is already narrow here, so let the slot + collapse to nothing when empty and only take room while a message is actually showing */ + .kt-title-toast-slot { width: auto; flex: 0 1 auto; } .kt-table-wrap { border-radius: var(--kt-radius); } .table thead { display: none; } .table, .table tbody, .table tr, .table td { display: block; width: 100%; } From 5fc8e8d0140b197e98d5818444290b1a00f33c5f Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 29 Jul 2026 10:45:00 +0200 Subject: [PATCH 25/80] Fix 'RemoteNavigationManager' has not been initialized error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause The error is a DI scope mismatch, not a Blazor-lifecycle timing issue. AuthenticationTokenHandler is wired into every API client with AddHttpMessageHandler(). IHttpClientFactory builds the message-handler pipeline in its own handler scope, separate from the component render scope. So the NavigationManager you constructor-injected into the handler was a fresh RemoteNavigationManager that the renderer never called .Initialize() on. That instance is fine as long as nobody touches it — but RedirectToLogin() reads navigationManager.Uri. That path only fires when the Firebase token is stale/expired (past the ~1h token life, before the client-side refresh caught up) and the API returns 401, or when there's no token. Exactly the intermittent condition you saw. During the SSR/prerender pass (WaitForNonStreamingPendingTasks in your stack trace), .Uri on the uninitialized manager throws RemoteNavigationManager has not been initialized, the framework's clean-redirect path never runs, and you get the red error until you refresh (a refresh re-issues the token via the cookie, so the 401 doesn't recur). Fix src/BlazorApp/Components/Account/AuthenticationTokenHandler.cs: resolve NavigationManager from httpContext.RequestServices instead of the constructor. That's the request's own scope — the one the endpoint renderer already initialized before running OnInitializedAsync — so NavigateTo throws the proper NavigationException the framework converts into a real redirect to login. httpContext is already guaranteed non-null on this path (the guard at the top of SendAsync), and I made RedirectToLogin static since it no longer needs instance state. Net effect: a stale token now cleanly bounces the user to the login page (re-running Firebase sign-in and re-issuing the cookie) instead of showing the red error. Worth noting I kept the change surgical. Two things I'd flag rather than silently change: - This handler throws InvalidOperationException("HttpContext is not available") when HttpContext is null, which is the case inside a live interactive circuit. So these authenticated API calls effectively only succeed during the SSR/prerender pass. That's a broader design characteristic, not something this bug requires touching — but if you ever see failures on interactive re-fetches (paging/search after the circuit is live), that's the thing to look at. - I didn't add a test: this is a rendering-scope/SSR behavior that a mocked unit test can't reproduce (it needs a real prerender host), and per your quality notes a mock here would prove nothing. If you want coverage, the right home is a Playwright smoke test that forces a stale token and asserts the login redirect --- .../Account/AuthenticationTokenHandler.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/BlazorApp/Components/Account/AuthenticationTokenHandler.cs b/src/BlazorApp/Components/Account/AuthenticationTokenHandler.cs index ed436387..ebfa0502 100644 --- a/src/BlazorApp/Components/Account/AuthenticationTokenHandler.cs +++ b/src/BlazorApp/Components/Account/AuthenticationTokenHandler.cs @@ -3,12 +3,12 @@ using System.Net.Http.Headers; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; namespace Keeptrack.BlazorApp.Components.Account; public class AuthenticationTokenHandler( - IHttpContextAccessor httpContextAccessor, - NavigationManager navigationManager) + IHttpContextAccessor httpContextAccessor) : DelegatingHandler { private const string AuthorizationScheme = "Bearer"; @@ -20,7 +20,7 @@ protected override async Task SendAsync(HttpRequestMessage var httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is not available"); var token = await httpContext.GetTokenAsync(FirebaseTokenName); - if (token is null) RedirectToLogin(); + if (token is null) RedirectToLogin(httpContext); request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationScheme, token); @@ -35,15 +35,22 @@ protected override async Task SendAsync(HttpRequestMessage if (response.StatusCode == HttpStatusCode.Unauthorized) { response.Dispose(); - RedirectToLogin(); + RedirectToLogin(httpContext); } return response; } [DoesNotReturn] - private void RedirectToLogin() + private static void RedirectToLogin(HttpContext httpContext) { + // The NavigationManager is resolved from the request's own DI scope, NOT constructor-injected: + // IHttpClientFactory builds this DelegatingHandler in a separate handler scope whose + // RemoteNavigationManager the renderer never initialized, so its .Uri / .NavigateTo throw + // "RemoteNavigationManager has not been initialized" during the SSR/prerender pass (the intermittent + // red error a page refresh worked around). The request scope's NavigationManager is the one the + // endpoint renderer already initialized before running OnInitializedAsync, so it is safe to use here. + var navigationManager = httpContext.RequestServices.GetRequiredService(); var returnUrl = Uri.EscapeDataString(navigationManager.Uri); // forceLoad so the browser does a full navigation to the login page (re-running the Firebase sign-in // flow and re-issuing the cookie) instead of an in-circuit render that would keep the dead session. From 42ecee2930883fe9993d51a9b1603ae4eded9d6c Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 29 Jul 2026 11:01:45 +0200 Subject: [PATCH 26/80] Add Playwright tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix src/BlazorApp/Components/Account/AuthenticationTokenHandler.cs — resolve NavigationManager from httpContext.RequestServices (the request's render scope, where the endpoint renderer already initialized it) instead of constructor-injecting it (which came from IHttpClientFactory's separate handler scope, where the RemoteNavigationManager was never initialized). That uninitialized instance is what threw RemoteNavigationManager has not been initialized on .Uri during the SSR/prerender pass whenever a stale token produced a 401 — the red error you had to refresh past. Now the properly-initialized manager throws the NavigationException the framework converts into a clean redirect to login. The test test/BlazorApp.PlaywrightTests/Smoke/StaleTokenRedirectSmokeTest.cs (+ a ForgeStaleTokenMemberCookie() helper on End2EndFixture). It forges an auth cookie with a valid member principal but a Firebase token WebApi rejects, hits the exact reported page (/account/manage/shared/{id}), and asserts a redirect to login. Verified both directions: - With the fix: passes (~5–12s). - With the fix reverted: fails, reproducing your exact stack trace (SharedCollectionPage.OnInitializedAsync → WaitForNonStreamingPendingTasks → uninitialized RemoteNavigationManager). Recipe updated I rewrote the e2e-run memory so this doesn't cost time again. The checks I ran (provider keys, Mongo port, browsers) were the recipe's gap — it now leads with "preconditions are already satisfied, don't re-verify," states plainly that no provider keys need setting (the host loads real ones from appsettings.Development.json; placeholder x values are pointless), and gives a copy-paste PowerShell block for a read-only single-class run. One thing I'll flag again since it's adjacent: this handler throws HttpContext is not available when HttpContext is null (inside a live circuit), so these authenticated API calls effectively only succeed during SSR. Not in scope for this bug, but worth knowing if interactive re-fetches ever misbehave. --- .../Hosting/End2EndFixture.cs | 55 +++++++++++++++++++ .../Smoke/StaleTokenRedirectSmokeTest.cs | 47 ++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 test/BlazorApp.PlaywrightTests/Smoke/StaleTokenRedirectSmokeTest.cs diff --git a/test/BlazorApp.PlaywrightTests/Hosting/End2EndFixture.cs b/test/BlazorApp.PlaywrightTests/Hosting/End2EndFixture.cs index 4163c64c..1c040143 100644 --- a/test/BlazorApp.PlaywrightTests/Hosting/End2EndFixture.cs +++ b/test/BlazorApp.PlaywrightTests/Hosting/End2EndFixture.cs @@ -5,15 +5,20 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using FirebaseAdmin.Auth; +using Keeptrack.BlazorApp.Components.Account; using Keeptrack.BlazorApp.PlaywrightTests.Hosting; using Keeptrack.BlazorApp.PlaywrightTests.Support; using Keeptrack.Testing.Shared.Firebase; using Keeptrack.Testing.Shared.Hosting; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Microsoft.Playwright; using Xunit; @@ -252,6 +257,56 @@ private sealed record PagedItemIds(List Items); private sealed record ItemId(string? Id); + /// + /// Forges a Blazor auth cookie whose principal is a fully-authorized member but whose stored Firebase token + /// is a value WebApi rejects (a malformed JWT), reproducing a live session whose Firebase ID token has gone + /// stale (expired or revoked) while the 8h auth cookie is still valid. The next server-rendered page's API + /// call then comes back 401, driving AuthenticationTokenHandler.RedirectToLogin during the + /// SSR/prerender pass - the path that used to throw "RemoteNavigationManager has not been initialized", + /// showing a red error until the user manually refreshed. + /// Returns null in live mode (E2E_TARGET_URL): forging the ticket needs the in-process Blazor host's + /// own data-protection keys, which a remote deployment can't expose - the test self-skips there. + /// + public Cookie? ForgeStaleTokenMemberCookie() + { + if (_blazorFactory is null) return null; + + var cookieOptions = _blazorFactory.Services + .GetRequiredService>() + .Get(CookieAuthenticationDefaults.AuthenticationScheme); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, _ephemeralUserUid ?? "e2e-forged-uid"), + new(ClaimTypes.Name, SignedInEmail), + new(ClaimTypes.Email, SignedInEmail), + // admin satisfies the MemberOnly policy the shared-collection page requires, so the page renders and + // makes its API call rather than being bounced by [Authorize] first (which would exercise the wrong + // redirect path - the cookie-challenge one, not the stale-token one under test). + new("role", "admin"), + }; + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)); + + var properties = new AuthenticationProperties + { + IsPersistent = true, + IssuedUtc = DateTimeOffset.UtcNow, + ExpiresUtc = DateTimeOffset.UtcNow.AddHours(1), + }; + properties.StoreTokens([ + new AuthenticationToken { Name = AuthenticationTokenHandler.FirebaseTokenName, Value = "stale.firebase.token" } + ]); + + var ticket = new AuthenticationTicket(principal, properties, CookieAuthenticationDefaults.AuthenticationScheme); + + return new Cookie + { + Name = cookieOptions.Cookie.Name!, + Value = cookieOptions.TicketDataFormat!.Protect(ticket), + Url = BlazorBaseUrl, + }; + } + public async ValueTask DisposeAsync() { _apiHttpClient?.Dispose(); diff --git a/test/BlazorApp.PlaywrightTests/Smoke/StaleTokenRedirectSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/StaleTokenRedirectSmokeTest.cs new file mode 100644 index 00000000..7c2f40ab --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Smoke/StaleTokenRedirectSmokeTest.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using Keeptrack.BlazorApp.PlaywrightTests.Hosting; +using Keeptrack.BlazorApp.PlaywrightTests.Pages; +using Microsoft.Playwright; +using Xunit; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; + +/// +/// Regression guard for the "'RemoteNavigationManager' has not been initialized" red error: a signed-in +/// session whose Firebase ID token has gone stale (the 8h auth cookie still valid, but the ~1h token expired +/// or revoked) must be cleanly redirected to login when a server-rendered page's API call comes back 401 - +/// not crash the render and force a manual page refresh. Before the fix, AuthenticationTokenHandler +/// resolved its NavigationManager from the wrong DI scope (the IHttpClientFactory handler scope, whose +/// RemoteNavigationManager the renderer never initialized), so reading its .Uri during the SSR/prerender +/// pass threw instead of redirecting. +/// +[Trait("Category", "E2eTests")] +[Trait("Mode", "Readonly")] +public class StaleTokenRedirectSmokeTest(End2EndFixture fixture) : SmokeTestBase(fixture) +{ + [Fact] + public async Task StaleFirebaseToken_On401_RedirectsToLogin_InsteadOfCrashingTheServerRender() + { + var cookie = Fixture.ForgeStaleTokenMemberCookie(); + Assert.SkipWhen(cookie is null, "Forging a stale-token cookie needs the in-process Blazor host (self-hosted integration mode only)."); + + // A fresh context with none of the run's signed-in storage state - this test supplies its own forged cookie + // (a valid member principal carrying a Firebase token WebApi will reject), same clean-context approach as AuthSmokeTest. + await using var context = await NewContext(new BrowserNewContextOptions + { + BaseURL = Fixture.BlazorBaseUrl, + IgnoreHTTPSErrors = true + }); + await context.AddCookiesAsync([cookie!]); + var page = await context.NewPageAsync(); + + // The shared-collection page (the one in the original report) loads via OnInitializedAsync with no + // try/catch, so the 401 propagates straight into the redirect path under test - unlike the inventory + // list pages, which swallow it into an inline error message. The ShareId need not exist: the API call is + // rejected on the stale bearer token before any lookup runs. + await page.GotoAsync($"/account/manage/shared/{Guid.NewGuid():N}"); + + await new LoginPage(page).WaitForReadyAsync(); + } +} From 8c69a8422b7527fb209aa28d3f9c278380bb9112 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 29 Jul 2026 11:11:05 +0200 Subject: [PATCH 27/80] Add playwright test for collection sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was already there The sharing feature already had a committed SharingSmokeTest (from c89eae2 Share car/house/health) plus its page objects, covering two of the three sharing shapes: - Media (Movies) — copyable list with the "In collection" badge - Personal (Cars) — list plus a read-only detail page The actual gap The plan's progress log flagged one deferred Playwright leg: the collection categories (Collectibles + Gear), which are a genuinely third shape — a full read-only list, but view-only (no shared reference to copy, so no "add to my collection" action and no read-only detail page). Nothing exercised those two tabs. What I added 1. SharedCollectionViewPage.AddButton(itemTitle) — a page-object locator for the per-row data-testid="share-add" action (kept on the page object per your locator-discipline convention). 2. SharingSmokeTest.ShareCollections_RecipientSeesReadOnlyListWithNoAddAction — self-shares Collectibles + Gear through the owner UI, then as the recipient opens each tab and asserts the item is listed read-only with the add button absent (ToHaveCountAsync(0)) and no "In collection" badge. That absence is the whole point — it's what separates a view-only collection tab from a copyable media tab. 3. Updated the plan's progress log to mark the leg as written. test/BlazorApp.PlaywrightTests builds clean (0 warnings). Like every other smoke test it self-skips unless E2E_ENABLED=true, so I couldn't run it here — it needs your WSL E2E env (MongoDB + Firebase creds + browsers). Per your run recipe, that's where it should actually execute. I've left everything uncommitted for you to review first. One note on scope: the existing test covers Cars but not Houses/Health for the personal shape. Those three go through the identical SharedPersonalList + read-only-detail mechanism, so Cars is representative — I didn't add redundant House/Health legs. Say the word if you'd rather have explicit coverage for those too. --- docs/share-collections-plan.md | 6 +- .../Pages/SharedCollectionViewPage.cs | 6 ++ .../Smoke/SharingSmokeTest.cs | 59 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/share-collections-plan.md b/docs/share-collections-plan.md index 453f1768..3231127c 100644 --- a/docs/share-collections-plan.md +++ b/docs/share-collections-plan.md @@ -237,7 +237,11 @@ That is a genuinely third shape: media is a copyable list, personal is a view-on - **Tests**: `ShareCategoryClassifierTest` gained a `Collection`-kind/never-copyable theory for both categories; `ShareResourceTest.CollectionShare_IsReadableAsAFilterableList_ButNeverCopyable` (integration) covers the paged/searchable/favourite-filtered read, empty `AlreadyInCollectionIds`, category-not-in-scope 404, and the copy route being absent (404). Whole solution builds with 0 warnings; the classifier unit test passes. -Deferred (same as the other phases): a Playwright leg for the two collection tabs, run in the WSL `E2E_ENABLED` env. +- **Playwright leg for the collection tabs: WRITTEN.** `SharingSmokeTest.ShareCollections_RecipientSeesReadOnlyListWithNoAddAction` + self-shares Collectibles + Gear, then asserts each tab lists its item read-only with **no** per-row "add to my + collection" action (`SharedCollectionViewPage.AddButton` → `ToHaveCountAsync(0)`) and no "In collection" badge - + the view-only-list distinction from the copyable media tabs. Builds clean; deferred to the WSL `E2E_ENABLED` env + to actually run, like every other smoke test. ## Progress log — Phase 1 (superseded by the rework above) diff --git a/test/BlazorApp.PlaywrightTests/Pages/SharedCollectionViewPage.cs b/test/BlazorApp.PlaywrightTests/Pages/SharedCollectionViewPage.cs index 05444169..80e2451c 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/SharedCollectionViewPage.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/SharedCollectionViewPage.cs @@ -26,6 +26,12 @@ public async Task SelectTabAsync(string label) /// The "In collection" badge shown for a shared media item the caller already owns. public ILocator InCollectionBadge(string itemTitle) => Row(itemTitle).GetByText("In collection"); + /// + /// The per-row "add to my collection" action. Present on media rows (copyable), absent on collection + /// (collectibles/gear) rows, which are view-only - a test asserts ToHaveCountAsync(0) to pin that. + /// + public ILocator AddButton(string itemTitle) => Row(itemTitle).GetByTestId("share-add"); + /// Opens a personal item's read-only detail page (the personal rows are links). public async Task OpenCarAsync(string name) { diff --git a/test/BlazorApp.PlaywrightTests/Smoke/SharingSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/SharingSmokeTest.cs index 95e8c1fa..bba33553 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/SharingSmokeTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/SharingSmokeTest.cs @@ -85,6 +85,65 @@ public async Task ShareMediaAndPersonal_RecipientSeesReadOnlyViews() } } + /// + /// The collection categories (Collectibles, Gear) are the third sharing shape, distinct from the other two: + /// a full read-only list (search / sort / filters, like media) but with no shared reference to copy - so no + /// per-row "add to my collection" action and no "In collection" badge, and no read-only detail page either + /// (unlike personal). This pins that view-only-list distinction, self-shared to the fixture's own email. + /// + [Fact] + public async Task ShareCollections_RecipientSeesReadOnlyListWithNoAddAction() + { + SkipIfReadOnly(); + + var tag = Guid.NewGuid().ToString("N")[..8]; + var collectibleTitle = $"E2e Share Collectible {tag}"; + var gearTitle = $"E2e Share Gear {tag}"; + var label = $"E2e Coll {tag}"; + var api = Fixture.ApiHttpClient; + + var collectible = await CreateAsync(api, "api/collectibles", new CollectibleDto { Title = collectibleTitle, Brand = "Lego", Year = 2020 }); + var gear = await CreateAsync(api, "api/gear", new GearDto { Title = gearTitle, Brand = "Sony", Year = 2021 }); + + string? shareId = null; + try + { + // Owner creates the grant (Collectibles + Gear) through the profile UI's "Collections" group. + var sharing = await new SharingOwnerPage(Page).OpenAsync(); + await sharing.FillRecipientEmailAsync(Fixture.SignedInEmail); + await sharing.FillLabelAsync(label); + await sharing.ToggleCategoryAsync("Collectibles"); + await sharing.ToggleCategoryAsync("Gear"); + await sharing.CreateShareAsync(); + await Assertions.Expect(sharing.ActiveShareRow(label)).ToBeVisibleAsync(); + + shareId = await FindShareIdByLabelAsync(api, label); + + var sharedWithMe = await new SharedWithMePage(Page).OpenAsync(); + var collection = await sharedWithMe.OpenCollectionByIdAsync(shareId); + + // Collectibles: listed read-only, with no "add to my collection" action and no "In collection" badge. + await collection.SelectTabAsync("Collectibles"); + await Assertions.Expect(collection.Row(collectibleTitle)).ToBeVisibleAsync(); + await Assertions.Expect(collection.AddButton(collectibleTitle)).ToHaveCountAsync(0); + await Assertions.Expect(collection.InCollectionBadge(collectibleTitle)).ToHaveCountAsync(0); + + // Gear: the same view-only-list shape. + await collection.SelectTabAsync("Gear"); + await Assertions.Expect(collection.Row(gearTitle)).ToBeVisibleAsync(); + await Assertions.Expect(collection.AddButton(gearTitle)).ToHaveCountAsync(0); + } + finally + { + if (shareId is not null) + { + await Fixture.DeleteItemAsync($"api/shares/{shareId}"); + } + await Fixture.DeleteItemAsync($"api/collectibles/{collectible.Id}"); + await Fixture.DeleteItemAsync($"api/gear/{gear.Id}"); + } + } + private static async Task CreateAsync(HttpClient api, string path, T body) { var response = await api.PostAsJsonAsync(path, body, TestContext.Current.CancellationToken); From cf96098f0e7c79a624608363c403d74725e7d9ff Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 29 Jul 2026 14:51:07 +0200 Subject: [PATCH 28/80] Remove tv show want to watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Done You were right — the WantToWatch flag was dead weight on TV shows. WatchNextService.ComputeInProgressShows drives shows purely from State == Current + a confirmed unseen episode and never looked at the flag; there was no "shows to watch" surface for it to feed either. It's a genuinely movie-only concept, which is left untouched. Removed across every layer (build clean, 288 unit tests green): - Model / Entity / DTO — TvShowModel, TvShow (want_to_watch BSON), TvShowDto. Mapperly stayed balanced, so the build itself is the proof nothing was missed. - Repository filter — the input.WantToWatch clause in TvShowRepository.GetFilter (it wasn't even exposed as a list filter button). - UI — the "To watch" badge in TvShowMetaRow and the "Watchlist" toggle button + ToggleWantToWatchAsync in TvShowDetail. - TV Time import — ShowEnrichment no longer maps the export's for_later status into shows; the now-unused ShowStatusCsvParser.ForLaterStatus constant went too (parser test keeps its for_later row coverage via the literal, since the parser still must read that column faithfully for favorite). - Index — dropped tvshow_want_to_watch from mongodb-create-index.js, with a note explaining why. Data cleanup: added scripts/unset-tvshow-want-to-watch.js (idempotent $unset, same run-once style as the poster-url migration) to strip the orphaned field from existing tvshow documents — run once per environment. Docs: updated the stale CLAUDE.md import line and added a short design note recording that WantToWatch is movie-only by design, so it doesn't get reintroduced as a flag later (if you ever want a "shows I want to start" view, it should be a real Watch Next section, not a flag with no reader). --- CLAUDE.md | 8 +++++++- scripts/mongodb-create-index.js | 7 ++----- scripts/unset-tvshow-want-to-watch.js | 19 +++++++++++++++++++ .../Inventory/Meta/TvShowMetaRow.razor | 4 ---- .../Inventory/Pages/TvShowDetail.razor | 8 -------- src/Domain/Models/TvShowModel.cs | 2 -- src/Infrastructure.MongoDb/Entities/TvShow.cs | 3 --- .../Repositories/TvShowRepository.cs | 1 - src/WebApi.Contracts/Dto/TvShowDto.cs | 2 -- .../Import/Parsers/ShowStatusCsvParser.cs | 2 -- src/WebApi/Import/TvTimeImportService.cs | 6 ++---- .../Import/Parsers/ShowStatusCsvParserTest.cs | 4 +++- 12 files changed, 33 insertions(+), 33 deletions(-) create mode 100644 scripts/unset-tvshow-want-to-watch.js diff --git a/CLAUDE.md b/CLAUDE.md index 467ab62b..8dbf11b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -567,6 +567,12 @@ That heuristic guessed without confirming an episode existed; this checks a real Toggling that flag on a movie's own detail page doesn't clear it on watch (unlike the TV Time import's "towatch" event handling, which never flags an already-watched movie in the first place). So the exclusion has to happen at read time here instead of relying on the flag never going stale. +**`WantToWatch` is a movie-only concept - TV shows deliberately don't have it.** A TV show reaches Watch Next through `ComputeInProgressShows` (`State == Current` plus a confirmed unseen episode), which never consulted a want-to-watch flag, +and there is no "shows to watch" section for a not-yet-started show to surface in either. +The flag briefly existed on `TvShowModel`/entity/DTO (populated by the TV Time import's "for_later" status and a detail-page "Watchlist" toggle) but had no consuming feature, so it was removed across every layer along with the `tvshow_want_to_watch` index - +existing documents are cleaned up by the one-off `scripts/unset-tvshow-want-to-watch.js`. +Don't reintroduce it as a plain flag; if a "shows I want to start" surface is ever wanted, build it as a real Watch Next section, not a dead flag. + `TvShowDetail.razor`'s episode checklist filters `_reference.Episodes` to `AirDate is null || AirDate <= today` before grouping into seasons. This is the same air-date filter `WatchNextService` already applies for its "next episode" calc. An episode TMDB lists with a future air date (a confirmed-but-unaired next season, e.g. a renewal announced months ahead) hasn't happened yet from the viewer's perspective - it shouldn't appear as a checkbox to mark watched. @@ -702,7 +708,7 @@ The list page's filter buttons are a different control with different semantics The enum type itself keeps its `TvShowStatus` name - only the property that holds it moved to `State`, since `VideoGameModel.State` has no equivalent enum to rename against. Unlike the `PosterUrl`→`ImageUrl` rename below, this one needed **no** data migration: `TvShow`'s entity property kept an explicit `[BsonElement("status")]` pointing at the unchanged storage name, so existing documents (confirmed directly against the real dev database - `status: 'Finished'` reads back correctly through the renamed `State` property) deserialize with no script required. -`TvTimeImportService`/`ShowStatusCsvParser`'s `ShowStatusRecord.Status` is a same-named but *entirely unrelated* field - TV Time's own CSV column for favorite/for_later, mapped to `IsFavorite`/`WantToWatch`,never to this enum - +`TvTimeImportService`/`ShowStatusCsvParser`'s `ShowStatusRecord.Status` is a same-named but *entirely unrelated* field - TV Time's own CSV column for favorite/for_later, mapped to `IsFavorite` (the "for_later" value has no counterpart for shows and is not imported), never to this enum - so the import pipeline needed no changes at all for this rename; verified by tracing every consumer before renaming, not just running the test suite. `WatchNextService`/`WatchNextController`'s `Status == TvShowStatus.Current` checks were updated to `State == TvShowStatus.Current` and covered by `WatchNextServiceTest`, which still passes. diff --git a/scripts/mongodb-create-index.js b/scripts/mongodb-create-index.js index 986c12b4..13a67298 100644 --- a/scripts/mongodb-create-index.js +++ b/scripts/mongodb-create-index.js @@ -77,11 +77,8 @@ ensureIndex( { owner_id: 1, is_favorite: 1 }, { name: "tvshow_favorite", partialFilterExpression: { is_favorite: true } } ); -ensureIndex( - db.tvshow, - { owner_id: 1, want_to_watch: 1 }, - { name: "tvshow_want_to_watch", partialFilterExpression: { want_to_watch: true } } -); +// tvshow has no want_to_watch index: the flag was removed (Watch Next drives shows from State/episodes, +// not a want-to-watch flag - that's a movie-only concept). See scripts/unset-tvshow-want-to-watch.js. // album / book: same sparse-flag partial-index rationale as movie/tvshow above. ensureIndex( db.album, diff --git a/scripts/unset-tvshow-want-to-watch.js b/scripts/unset-tvshow-want-to-watch.js new file mode 100644 index 00000000..bcc96189 --- /dev/null +++ b/scripts/unset-tvshow-want-to-watch.js @@ -0,0 +1,19 @@ +// One-off data cleanup: the `want_to_watch` flag was removed from TV shows. Watch Next drives shows +// from the tenant's own `State`/episode history (an in-progress show with a confirmed unseen episode), +// never from a want-to-watch flag - so the flag had no consuming feature for shows and was dropped +// (it stays a movie-only concept). See CLAUDE.md. The matching `tvshow_want_to_watch` partial index +// was removed from scripts/mongodb-create-index.js. +// +// This unsets the now-orphaned `want_to_watch` field on existing tvshow documents (populated by earlier +// TV Time imports mapping the export's "for_later" status, or by the removed detail-page toggle). Leaving +// the field would just be dead data the app no longer reads. +// +// Idempotent: only touches documents that still have the field, so re-running is a safe no-op. +// +// Run once per environment that has TV show data, e.g.: +// mongosh "mongodb://localhost:27017/keeptrack_dev" scripts/unset-tvshow-want-to-watch.js +const result = db.tvshow.updateMany( + { want_to_watch: { $exists: true } }, + { $unset: { want_to_watch: "" } } +); +print(`tvshow: unset want_to_watch on ${result.modifiedCount} document(s)`); diff --git a/src/BlazorApp/Components/Inventory/Meta/TvShowMetaRow.razor b/src/BlazorApp/Components/Inventory/Meta/TvShowMetaRow.razor index ffdc2b7c..49778b45 100644 --- a/src/BlazorApp/Components/Inventory/Meta/TvShowMetaRow.razor +++ b/src/BlazorApp/Components/Inventory/Meta/TvShowMetaRow.razor @@ -16,10 +16,6 @@ { Favorite } -@if (Item.WantToWatch) -{ - To watch -} @if (Item.OwnedVersions.Count > 0) { Owned diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor index 83db64a9..eb5eac75 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor @@ -51,7 +51,6 @@ else
-
@@ -315,13 +314,6 @@ else await TvShowApi.UpdateAsync(_show); } - private async Task ToggleWantToWatchAsync() - { - if (_show is null) return; - _show.WantToWatch = !_show.WantToWatch; - await TvShowApi.UpdateAsync(_show); - } - private async Task SaveShowAsync() { if (_show is null) return; diff --git a/src/Domain/Models/TvShowModel.cs b/src/Domain/Models/TvShowModel.cs index bcf34788..fcd10954 100644 --- a/src/Domain/Models/TvShowModel.cs +++ b/src/Domain/Models/TvShowModel.cs @@ -33,8 +33,6 @@ public class TvShowModel : IHasIdAndOwnerId, IHasTvTimeId public bool IsFavorite { get; set; } - public bool WantToWatch { get; set; } - public List OwnedVersions { get; set; } = []; /// diff --git a/src/Infrastructure.MongoDb/Entities/TvShow.cs b/src/Infrastructure.MongoDb/Entities/TvShow.cs index 1de45e19..4d4091a3 100644 --- a/src/Infrastructure.MongoDb/Entities/TvShow.cs +++ b/src/Infrastructure.MongoDb/Entities/TvShow.cs @@ -40,9 +40,6 @@ public class TvShow : IHasIdAndOwnerId [BsonElement("is_favorite")] public bool IsFavorite { get; set; } - [BsonElement("want_to_watch")] - public bool WantToWatch { get; set; } - [BsonElement("owned_versions")] public List OwnedVersions { get; set; } = []; diff --git a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs index 5ad7ad41..fac16d71 100644 --- a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs @@ -29,7 +29,6 @@ protected override FilterDefinition GetFilter(string ownerId, string? se var filter = builder.Eq(f => f.OwnerId, ownerId); if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Title.Contains(search, System.StringComparison.CurrentCultureIgnoreCase)); if (input.IsFavorite) filter &= builder.Eq(f => f.IsFavorite, true); - if (input.WantToWatch) filter &= builder.Eq(f => f.WantToWatch, true); if (input.State is not null) filter &= builder.Eq(f => f.State, input.State); // "owned" means at least one owned version - see MovieRepository.GetFilter if (input.IsOwned) filter &= builder.SizeGt(f => f.OwnedVersions, 0); diff --git a/src/WebApi.Contracts/Dto/TvShowDto.cs b/src/WebApi.Contracts/Dto/TvShowDto.cs index 3dc78458..b66413ee 100644 --- a/src/WebApi.Contracts/Dto/TvShowDto.cs +++ b/src/WebApi.Contracts/Dto/TvShowDto.cs @@ -47,8 +47,6 @@ public class TvShowDto : IHasId, IReferenceLinkedDto public bool IsFavorite { get; set; } - public bool WantToWatch { get; set; } - /// /// Every owned copy of this show - the show counts as owned when this list is non-empty. /// diff --git a/src/WebApi/Import/Parsers/ShowStatusCsvParser.cs b/src/WebApi/Import/Parsers/ShowStatusCsvParser.cs index 8becacde..c54774ac 100644 --- a/src/WebApi/Import/Parsers/ShowStatusCsvParser.cs +++ b/src/WebApi/Import/Parsers/ShowStatusCsvParser.cs @@ -22,8 +22,6 @@ public static class ShowStatusCsvParser { public const string FavoriteStatus = "favorite"; - public const string ForLaterStatus = "for_later"; - public static List Parse(Stream csvStream) { using var reader = new StreamReader(csvStream); diff --git a/src/WebApi/Import/TvTimeImportService.cs b/src/WebApi/Import/TvTimeImportService.cs index be6816cd..2de17b57 100644 --- a/src/WebApi/Import/TvTimeImportService.cs +++ b/src/WebApi/Import/TvTimeImportService.cs @@ -483,21 +483,20 @@ public void MarkMatched(TModel model) } /// - /// Per-show rating/favorite/want-to-watch/notes, keyed by TV Time's show id. Built once from + /// Per-show rating/favorite/notes, keyed by TV Time's show id. Built once from /// tv_show_rate.csv/user_show_special_status.csv/show_comment.csv and applied both to shows found /// via followed_tv_show.csv and to shows discovered only through episode watch history. + /// TV Time's "for_later" status has no Keeptrack counterpart for shows and is intentionally not imported. /// private sealed class ShowEnrichment( Dictionary ratingByShowId, HashSet favoriteShowIds, - HashSet wantToWatchShowIds, Dictionary notesByShowId) { public static ShowEnrichment Build(List showRatings, List showStatuses, List showComments) => new( showRatings.GroupBy(r => r.TvShowId).ToDictionary(g => g.Key, g => g.Last().Rating), showStatuses.Where(s => s.Status == ShowStatusCsvParser.FavoriteStatus).Select(s => s.TvShowId).ToHashSet(), - showStatuses.Where(s => s.Status == ShowStatusCsvParser.ForLaterStatus).Select(s => s.TvShowId).ToHashSet(), showComments.GroupBy(c => c.TvShowId).ToDictionary(g => g.Key, g => FormatComments(g.Select(c => (c.CreatedAt, c.Comment))))); public void ApplyTo(TvShowModel show, string? tvShowId) @@ -506,7 +505,6 @@ public void ApplyTo(TvShowModel show, string? tvShowId) if (ratingByShowId.TryGetValue(tvShowId, out var rating)) show.Rating = rating; if (favoriteShowIds.Contains(tvShowId)) show.IsFavorite = true; - if (wantToWatchShowIds.Contains(tvShowId)) show.WantToWatch = true; if (notesByShowId.TryGetValue(tvShowId, out var notes)) show.Notes = notes; } } diff --git a/test/WebApi.UnitTests/Import/Parsers/ShowStatusCsvParserTest.cs b/test/WebApi.UnitTests/Import/Parsers/ShowStatusCsvParserTest.cs index 9cf8a098..231d3ee1 100644 --- a/test/WebApi.UnitTests/Import/Parsers/ShowStatusCsvParserTest.cs +++ b/test/WebApi.UnitTests/Import/Parsers/ShowStatusCsvParserTest.cs @@ -20,7 +20,9 @@ public void Parse_ReturnsFavoriteAndForLaterStatuses() var result = ShowStatusCsvParser.Parse(CsvTestHelper.ToStream(csv)); result.Should().HaveCount(2); - result.Single(r => r.TvShowId == "70626").Status.Should().Be(ShowStatusCsvParser.ForLaterStatus); + // "for_later" has no Keeptrack counterpart for shows, so it's not imported - but the parser must + // still read the raw status faithfully (the column also carries "favorite", which is imported). + result.Single(r => r.TvShowId == "70626").Status.Should().Be("for_later"); result.Single(r => r.TvShowId == "70761").Status.Should().Be(ShowStatusCsvParser.FavoriteStatus); } } From 32d1201f62c4334c0a5a7e06ef6ff8ba2fec1ef0 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 29 Jul 2026 15:12:04 +0200 Subject: [PATCH 29/80] Optimize tv show in watch next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary #1 — Killed the N+1 reference lookup (WatchNextController.cs) The old code awaited a separate FindByIdAsync per in-progress show (N serial round trips to tvshow_reference). Now it collects the candidate shows' reference ids and does one batched FindByIdsAsync, then maps references back per show — the same batched pattern the movie-poster hydration one line below already used. #2 (cheap) — Stopped fetching the whole episode history (EpisodeRepository + IEpisodeRepository) The old code pulled every episode the owner has (int.MaxValue), then WatchNextService discarded every non-Current show's episodes in memory — cost scaled with total lifetime watch history. Added a batched, owner-scoped FindByShowIdsAsync(ownerId, showIds); the controller now fetches episodes only for the Current+linked shows that can actually appear in the result. The owner_id + tv_show_id IN(...) filter rides the leading fields of the existing episode_last_watched index. Output is behaviorally identical — non-Current and unlinked shows were already excluded downstream. Tests - New EpisodeRepositoryTest (integration, real MongoDB): verifies FindByShowIdsAsync returns only the requested shows' episodes, is owner-scoped (a different owner tracking the same show id is excluded), and returns empty for no ids. Both pass. - Updated the FakeEpisodeRepository in TvTimeImportServiceIdempotencyTest to implement the new interface member. - Full unit suite (288) green; the two new integration tests green against your local Mongo. Not done (as agreed): the aggregation version of #2 (server-side max (season,episode) per show) and reference caching — hold those unless this isn't enough. If you want to sanity-check the win, the Watch Next page load should now issue a small constant number of queries regardless of how many finished shows/episodes are in the collection. One note: ITvShowReferenceRepository.FindByIdAsync is no longer called from this controller but is still used elsewhere, so I left it in place. the movies tab is already in good shape, unlike the show-reference path we just fixed. I checked all three layers: What's already correct 1. Server-side filter, with a matching index. WantToWatch = true is pushed into the Mongo query (MovieRepository.GetFilter:34), and there's a dedicated partial index movie_want_to_watch (owner_id, want_to_watch, filtered to want_to_watch: true) backing exactly that shape. So it fetches only the watchlist, never the whole movie collection. 2. Batched poster hydration, no N+1. ReferenceImageHydrator.HydrateAsync collects all reference ids and does a single FindByIdsAsync — the same batched pattern I just applied to the shows. No per-movie round trip. So the two things that mattered on the shows side (a server-side filter and avoiding N+1) are both already right here. Two marginal nits (I'd leave both) - The "already seen" exclusion runs in memory. FilterMoviesToWatch drops movies with FirstSeenAt != null in C#, even though GetFilter already supports pushing it down via IsUnseen = true (line 37). Pushing it to the DB would transfer slightly fewer docs — but the set is already just the watchlist (small), the FirstSeenAt condition wouldn't be index-covered anyway (only want_to_watch is in the partial index), and FilterMoviesToWatch is a documented, unit-tested method that exists specifically because the flag goes stale. Net payoff ≈ zero. Not worth the change. - A wasted count query — but this is not movie-specific and not really an inefficiency of the movies tab per se: FindAllAsync always runs a CountDocumentsAsync before the Find to populate PagedResult metadata that Watch Next discards. Both the shows fetch and the movies fetch pay it (2 round trips where 1 would do). It's indexed on owner_id and cheap; removing it would mean adding a non-paged read method to the repos — more API surface than the tiny saving justifies. Bottom line: the movies tab doesn't have the kind of scaling problem the episode/reference path had — its cost is bounded by watchlist size, not by total library size. I wouldn't change anything there. If you want me to push the seen-filter down anyway (turning FilterMoviesToWatch into a DB filter) I can, but I'd consider it cosmetic. --- src/Domain/Repositories/IEpisodeRepository.cs | 9 +++ .../Repositories/EpisodeRepository.cs | 12 ++++ src/WebApi/Controllers/WatchNextController.cs | 25 ++++--- .../Resources/EpisodeRepositoryTest.cs | 72 +++++++++++++++++++ .../TvTimeImportServiceIdempotencyTest.cs | 6 +- 5 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 test/WebApi.IntegrationTests/Resources/EpisodeRepositoryTest.cs diff --git a/src/Domain/Repositories/IEpisodeRepository.cs b/src/Domain/Repositories/IEpisodeRepository.cs index 9c642d57..4d6a1be5 100644 --- a/src/Domain/Repositories/IEpisodeRepository.cs +++ b/src/Domain/Repositories/IEpisodeRepository.cs @@ -1,7 +1,16 @@ +using System.Collections.Generic; +using System.Threading.Tasks; using Keeptrack.Domain.Models; namespace Keeptrack.Domain.Repositories; public interface IEpisodeRepository : IDataRepository { + /// + /// Batched read of every episode belonging to any of the given shows (owner-scoped). + /// Backs Watch Next, which only needs episodes for the (small) set of shows that can actually appear in + /// its result - fetching the whole owner's episode history and discarding non-current shows in memory + /// scaled with total watch history instead of with in-progress shows. An empty set returns no query. + /// + Task> FindByShowIdsAsync(string ownerId, IReadOnlyCollection tvShowIds); } diff --git a/src/Infrastructure.MongoDb/Repositories/EpisodeRepository.cs b/src/Infrastructure.MongoDb/Repositories/EpisodeRepository.cs index 3653356e..cbee11b2 100644 --- a/src/Infrastructure.MongoDb/Repositories/EpisodeRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/EpisodeRepository.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Threading.Tasks; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; @@ -19,4 +21,14 @@ protected override FilterDefinition GetFilter(string ownerId, string? s if (!string.IsNullOrEmpty(input.TvShowId)) filter &= builder.Eq(f => f.TvShowId, input.TvShowId); return filter; } + + public async Task> FindByShowIdsAsync(string ownerId, IReadOnlyCollection tvShowIds) + { + if (tvShowIds.Count == 0) return []; + var builder = Builders.Filter; + // owner_id + tv_show_id In(...) matches the leading fields of the episode_last_watched index (owner_id, tv_show_id, watched_at). + var filter = builder.Eq(f => f.OwnerId, ownerId) & builder.In(f => f.TvShowId, tvShowIds); + var entities = await GetCollection().Find(filter).ToListAsync(); + return mapper.ToModels(entities); + } } diff --git a/src/WebApi/Controllers/WatchNextController.cs b/src/WebApi/Controllers/WatchNextController.cs index cac76b10..24bce1f8 100644 --- a/src/WebApi/Controllers/WatchNextController.cs +++ b/src/WebApi/Controllers/WatchNextController.cs @@ -28,23 +28,28 @@ public async Task> Get() var shows = await tvShowRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, new TvShowModel { OwnerId = ownerId, Title = string.Empty }); - var episodes = await episodeRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, - new EpisodeModel { OwnerId = ownerId, TvShowId = string.Empty, SeasonNumber = 0, EpisodeNumber = 0 }); var moviesToWatch = await movieRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, new MovieModel { OwnerId = ownerId, Title = string.Empty, WantToWatch = true }); // only current shows with a reference link can possibly appear in the result (see WatchNextService), - // so only those need their (small, bounded) episode guide fetched - var referencesByShowId = new Dictionary(); - foreach (var show in shows.Items.Where(s => s.State == Domain.Models.TvShowStatus.Current && !string.IsNullOrEmpty(s.ReferenceId))) - { - var reference = await tvShowReferenceRepository.FindByIdAsync(show.ReferenceId!); - if (reference is not null) referencesByShowId[show.Id!] = reference; - } + // so only those shows need their episodes and their (small, bounded) reference episode guide fetched. + var candidateShows = shows.Items + .Where(s => s.State == Domain.Models.TvShowStatus.Current && !string.IsNullOrEmpty(s.ReferenceId)) + .ToList(); + + var episodes = await episodeRepository.FindByShowIdsAsync(ownerId, candidateShows.Select(s => s.Id!).ToList()); + + // one batched lookup instead of one round trip per show (see ReferenceImageHydrator below for the same pattern). + var referencesById = (await tvShowReferenceRepository.FindByIdsAsync( + candidateShows.Select(s => s.ReferenceId!).Distinct().ToList())) + .ToDictionary(r => r.Id!); + var referencesByShowId = candidateShows + .Where(s => referencesById.ContainsKey(s.ReferenceId!)) + .ToDictionary(s => s.Id!, s => referencesById[s.ReferenceId!]); var result = new WatchNextDto { - InProgressShows = WatchNextService.ComputeInProgressShows(shows.Items, episodes.Items, referencesByShowId) + InProgressShows = WatchNextService.ComputeInProgressShows(shows.Items, episodes, referencesByShowId) .Select(inProgressShowMapper.ToDto).ToList(), MoviesToWatch = WatchNextService.FilterMoviesToWatch(moviesToWatch.Items).Select(movieMapper.ToDto).ToList() }; diff --git a/test/WebApi.IntegrationTests/Resources/EpisodeRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/EpisodeRepositoryTest.cs new file mode 100644 index 00000000..3fc7b330 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/EpisodeRepositoryTest.cs @@ -0,0 +1,72 @@ +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 Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises against real MongoDB. This is the batched, +/// owner-scoped multi-show read Watch Next uses instead of pulling the whole owner's episode history and +/// discarding non-current shows in memory - verified against a real database, not mocks, since it's a +/// hand-written Mongo filter (the class of code that has hidden bugs before, see docs/code-quality-findings.md). +/// +public class EpisodeRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +{ + [Fact] + public async Task FindByShowIdsAsync_ReturnsOnlyTheRequestedShowsEpisodes_ScopedToTheOwner() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var ownerId = $"owner-{Guid.NewGuid()}"; + var otherOwnerId = $"owner-{Guid.NewGuid()}"; + var wantedShowId = $"show-{Guid.NewGuid()}"; + var otherShowId = $"show-{Guid.NewGuid()}"; + + var wanted = await repository.CreateAsync(NewEpisode(ownerId, wantedShowId, 1, 1)); + var wantedSecond = await repository.CreateAsync(NewEpisode(ownerId, wantedShowId, 1, 2)); + // same owner, a show that was NOT requested - must be excluded + var unrelatedShow = await repository.CreateAsync(NewEpisode(ownerId, otherShowId, 1, 1)); + // a different owner tracking the very same show id - must be excluded (owner scoping) + var otherOwner = await repository.CreateAsync(NewEpisode(otherOwnerId, wantedShowId, 1, 1)); + + try + { + var found = await repository.FindByShowIdsAsync(ownerId, [wantedShowId]); + + found.Select(e => e.Id).Should().BeEquivalentTo([wanted.Id, wantedSecond.Id]); + } + finally + { + foreach (var id in new[] { wanted.Id!, wantedSecond.Id!, unrelatedShow.Id!, otherOwner.Id! }) + { + await repository.DeleteAsync(id, id == otherOwner.Id ? otherOwnerId : ownerId); + } + } + } + + [Fact] + public async Task FindByShowIdsAsync_ReturnsEmpty_WhenNoShowIdsAreGiven() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + + var found = await repository.FindByShowIdsAsync($"owner-{Guid.NewGuid()}", []); + + found.Should().BeEmpty(); + } + + private static EpisodeModel NewEpisode(string ownerId, string showId, int season, int episode) => new() + { + OwnerId = ownerId, + TvShowId = showId, + SeasonNumber = season, + EpisodeNumber = episode, + WatchedAt = DateOnly.FromDateTime(DateTime.Today) + }; +} diff --git a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs index c252b434..ef801df2 100644 --- a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs +++ b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs @@ -190,5 +190,9 @@ public Task SetReferenceLinkAsync(string title, int? year, string referenc } private sealed class FakeEpisodeRepository() - : InMemoryRepository((episode, input) => episode.TvShowId == input.TvShowId), IEpisodeRepository; + : InMemoryRepository((episode, input) => episode.TvShowId == input.TvShowId), IEpisodeRepository + { + public Task> FindByShowIdsAsync(string ownerId, IReadOnlyCollection tvShowIds) => + Task.FromResult(Items.Where(e => e.OwnerId == ownerId && tvShowIds.Contains(e.TvShowId)).ToList()); + } } From c40758b81ce137bd80ac95e00c8834b6ba6a4c9b Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 29 Jul 2026 17:02:00 +0200 Subject: [PATCH 30/80] Scoped change to the Watch Next thumbnail (grid) view for the TV shows tab. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was happening: the caption's second line (.kt-grid-meta) is a wrapping flex row. A short episode title ("Exit Point") fit on the same row as the Next: SXXEXX badge, while longer titles wrapped onto the next line. The uneven spacing came from .kt-card-badge's margin-bottom: 0.75rem. The fix (in WatchNextPage.razor + app.css, scoped via a new kt-watchnext-grid class so nothing else changes): - .kt-grid-meta becomes a centered column, so the episode title always drops below the Next: badge — the layout you preferred. - Uniform 0.3rem gap between title → badge → episode title (removed the badge's margin-bottom inside this scope). - Centered the caption (title, badge, episode title) horizontally under the cover. The change is limited to the Watch Next thumbnail grid — the list view and all other inventory grids are untouched (.kt-card-badge is only used on this page). --- .../Components/WatchNext/WatchNextPage.razor | 2 +- src/BlazorApp/wwwroot/app.css | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/BlazorApp/Components/WatchNext/WatchNextPage.razor b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor index 9e82c93f..73ef3edb 100644 --- a/src/BlazorApp/Components/WatchNext/WatchNextPage.razor +++ b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor @@ -45,7 +45,7 @@ else if (Data is not null)
@if (_view == "grid") { -
+
@foreach (var show in Data.InProgressShows) { diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index a4867b75..c1e3cf66 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -509,6 +509,17 @@ a.kt-item-row { color: inherit; text-decoration: none; } font-size: 0.75rem; color: var(--kt-text-muted); } +/* Watch Next thumbnails: stack the "Next: SxxExx" badge and the episode title in a centered column so a + short episode title drops below the badge instead of sitting beside it, and the whole caption stays + evenly spaced and horizontally centered under the cover. */ +.kt-watchnext-grid .kt-grid-caption { text-align: center; } +.kt-watchnext-grid .kt-grid-meta { + flex-direction: column; + align-items: center; + gap: 0.3rem; + margin-top: 0.3rem; +} +.kt-watchnext-grid .kt-card-badge { margin-bottom: 0; } @media (max-width: 575.98px) { .kt-item-grid { grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); gap: 0.9rem 0.6rem; padding: 0.5rem 0.75rem 1.25rem; } .kt-item-grid.square { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); } From df3df027a40e5c9a89cd10af8712f982647fd029 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 29 Jul 2026 17:40:10 +0200 Subject: [PATCH 31/80] Update tv show status to current if new episodes appear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A finished-show status reconciliation that runs on the reference-sync cadence: after each pass refreshes the TMDB episode guides, it reopens any Finished show whose guide now lists an aired episode beyond the last one watched. New/changed pieces: 1. WatchNextService.FindNextAiredEpisode(...) — extracted the "next aired episode after last-watched" lookup out of ComputeInProgressShows into a shared pure helper. Both the Watch Next calc and the reconciliation use it, so there's one algorithm, not two. 2. ITvShowRepository.FindFinishedLinkedShowsAsync() (+ Mongo impl) — the one new query: cross-tenant, unscoped, returns only Finished shows with a reference link. Your contract is enforced at the query — Stopped/unset status are never candidates, so a new season can't reopen a show the tenant deliberately stopped tracking. 3. TvShowStatusReconciliationService (WebApi/ReferenceData/) — the orchestration twin of ReferenceSyncService: batch-loads references and per-owner episodes (reusing Watch Next's exact batched reads), flips matching shows to Current, skips shows with no recorded episodes (no last-watched to compare → don't guess), and is per-show resilient (one failure logged and skipped, not fatal). 4. Plugged into both sync entry points — inline after SyncStaleReferencesAsync in ReferenceSyncBackgroundService (inside the same lease-held tick, so it acts on fresh data and never races replicas) and in the admin sync-now path, so both report the same result. 5. ReferenceSyncResultDto.FinishedShowsReopened — the count, surfaced on the admin Reference Data page's sync-result panel and in the background log line. 6. 8 unit tests covering: reopens on a newer aired episode; leaves alone when caught up / when the newer episode hasn't aired / when there are no recorded episodes / when the reference doc is missing; per-tenant episode isolation on a shared reference; resilience past a failed write; and the empty-candidates short-circuit. One behavioral note to keep in mind: a show marked Finished where the tenant never actually watched the final aired episode will reopen to Current, since an unwatched aired episode genuinely exists. --- .../ReferenceDataAdminPage.razor | 1 + src/Domain/Repositories/ITvShowRepository.cs | 10 + src/Domain/Services/WatchNextService.cs | 24 ++- .../Repositories/TvShowRepository.cs | 11 ++ .../Dto/ReferenceSyncResultDto.cs | 7 + src/WebApi/Program.cs | 1 + .../ReferenceDataAdminController.cs | 3 + .../ReferenceSyncBackgroundService.cs | 10 +- .../TvShowStatusReconciliationService.cs | 105 +++++++++++ .../TvTimeImportServiceIdempotencyTest.cs | 3 + .../TvShowStatusReconciliationServiceTest.cs | 173 ++++++++++++++++++ 11 files changed, 339 insertions(+), 9 deletions(-) create mode 100644 src/WebApi/ReferenceData/TvShowStatusReconciliationService.cs create mode 100644 test/WebApi.UnitTests/ReferenceData/TvShowStatusReconciliationServiceTest.cs diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor index 89c070ac..de01f83f 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor @@ -154,6 +154,7 @@ Books: @_syncResult.BooksChecked checked, @_syncResult.BooksUpdated updated. Video games: @_syncResult.VideoGamesChecked checked, @_syncResult.VideoGamesUpdated updated. Albums: @_syncResult.AlbumsChecked checked, @_syncResult.AlbumsUpdated updated. + Finished shows reopened as current: @_syncResult.FinishedShowsReopened.
} @if (_syncError is not null) diff --git a/src/Domain/Repositories/ITvShowRepository.cs b/src/Domain/Repositories/ITvShowRepository.cs index 3efc2d9c..d49c6ff3 100644 --- a/src/Domain/Repositories/ITvShowRepository.cs +++ b/src/Domain/Repositories/ITvShowRepository.cs @@ -21,4 +21,14 @@ public interface ITvShowRepository : IDataRepository /// yet - feeds the admin curation queue. ///
Task> FindDistinctUnresolvedTitleYearsAsync(); + + /// + /// Every tenant's shows marked that carry a reference link - the + /// candidates the periodic finished-show status reconciliation re-checks against their (freshly synced) + /// reference episode guide. Cross-tenant and unscoped, like : it's + /// driven by a background pass, not an owner request. Only Finished shows are returned, never + /// or an unset status - those mean the tenant has deliberately stopped + /// tracking, so a new season must not reopen them. + /// + Task> FindFinishedLinkedShowsAsync(); } diff --git a/src/Domain/Services/WatchNextService.cs b/src/Domain/Services/WatchNextService.cs index c35ff80c..b5fba337 100644 --- a/src/Domain/Services/WatchNextService.cs +++ b/src/Domain/Services/WatchNextService.cs @@ -37,13 +37,7 @@ public static List ComputeInProgressShows( if (!referencesByShowId.TryGetValue(group.Key, out var reference)) return null; - var nextEpisode = reference.Episodes - .Where(e => e.SeasonNumber > lastWatched.SeasonNumber - || (e.SeasonNumber == lastWatched.SeasonNumber && e.EpisodeNumber > lastWatched.EpisodeNumber)) - .Where(e => e.AirDate is null || e.AirDate <= today) - .OrderBy(e => e.SeasonNumber) - .ThenBy(e => e.EpisodeNumber) - .FirstOrDefault(); + var nextEpisode = FindNextAiredEpisode(lastWatched.SeasonNumber, lastWatched.EpisodeNumber, reference, today); if (nextEpisode is null) return null; return new InProgressShowModel @@ -64,6 +58,22 @@ public static List ComputeInProgressShows( .ToList(); } + /// + /// The first episode in the reference guide that comes after the last-watched / + /// and has already aired ( unset or in the past relative to ), or null if none exists. + /// Ordered by (season, episode), not by title or air-date order. + /// Shared by (which surfaces this as the "next to watch" episode for a current show) + /// and by the finished-show status reconciliation (which treats a non-null result as "a newer episode exists, so this finished show should reopen as current"). + /// + public static ReferenceEpisodeModel? FindNextAiredEpisode(int lastWatchedSeason, int lastWatchedEpisode, TvShowReferenceModel reference, DateOnly today) => + reference.Episodes + .Where(e => e.SeasonNumber > lastWatchedSeason + || (e.SeasonNumber == lastWatchedSeason && e.EpisodeNumber > lastWatchedEpisode)) + .Where(e => e.AirDate is null || e.AirDate <= today) + .OrderBy(e => e.SeasonNumber) + .ThenBy(e => e.EpisodeNumber) + .FirstOrDefault(); + /// /// A movie flagged "want to watch" that has since been marked seen shouldn't linger in the watchlist - /// mirrors MovieTrackingEventsCsvParser's "towatch" handling, which never flags an already-watched movie in the first place. diff --git a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs index fac16d71..ddde4641 100644 --- a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs @@ -51,6 +51,17 @@ public async Task SetReferenceLinkAsync(string title, int? year, string re return result.ModifiedCount; } + public async Task> FindFinishedLinkedShowsAsync() + { + var builder = Builders.Filter; + // "linked" is the inverse of UnresolvedFilter: a real reference id, not null and not the legacy empty-string sentinel. + var filter = builder.Eq(f => f.State, TvShowStatus.Finished) + & builder.Ne(f => f.ReferenceId, null) + & builder.Ne(f => f.ReferenceId, string.Empty); + var entities = await GetCollection().Find(filter).ToListAsync(); + return mapper.ToModels(entities); + } + public async Task> FindDistinctUnresolvedTitleYearsAsync() { var groups = await GetCollection().Aggregate() diff --git a/src/WebApi.Contracts/Dto/ReferenceSyncResultDto.cs b/src/WebApi.Contracts/Dto/ReferenceSyncResultDto.cs index acf90529..2d7bb835 100644 --- a/src/WebApi.Contracts/Dto/ReferenceSyncResultDto.cs +++ b/src/WebApi.Contracts/Dto/ReferenceSyncResultDto.cs @@ -48,4 +48,11 @@ public class ReferenceSyncResultDto /// is always fully re-fetched - this count is always equal to . /// public int AlbumsUpdated { get; set; } + + /// + /// How many finished TV shows were reopened as "current" because their (freshly synced) reference episode + /// guide now lists an aired episode beyond the last one watched - see the finished-show status + /// reconciliation that runs right after the reference refresh. + /// + public int FinishedShowsReopened { get; set; } } diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index 3ec4b4a4..a67e482d 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -107,6 +107,7 @@ }).AddProviderResilienceHandler(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddMongoDbInfrastructure(configuration); builder.Services.AddOpenApiWithBearerAuth(configuration); diff --git a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs index f7288202..9da484fc 100644 --- a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs +++ b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs @@ -204,11 +204,14 @@ private async Task RunSyncJobAsync(Guid jobId) { using var scope = scopeFactory.CreateScope(); var scopedSyncService = scope.ServiceProvider.GetRequiredService(); + var scopedReconciliationService = scope.ServiceProvider.GetRequiredService(); var scopedJobStore = scope.ServiceProvider.GetRequiredService>(); try { var result = await scopedSyncService.SyncStaleReferencesAsync(TimeSpan.Zero, stage => scopedJobStore.UpdateStageAsync(jobId, stage)); + // 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.CompleteAsync(jobId, ReferenceSyncStage.Completed, result); } catch (Exception ex) diff --git a/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs b/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs index c80bdd51..67b489ea 100644 --- a/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs +++ b/src/WebApi/ReferenceData/ReferenceSyncBackgroundService.cs @@ -66,10 +66,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var syncService = scope.ServiceProvider.GetRequiredService(); var result = await syncService.SyncStaleReferencesAsync(s_staleAfter, cancellationToken: stoppingToken); + + // reconcile finished shows against the just-refreshed reference episode guides, in the same + // lease-held tick so it never runs against stale data or races another replica. + var reconciliationService = scope.ServiceProvider.GetRequiredService(); + result.FinishedShowsReopened = await reconciliationService.ReconcileFinishedShowsAsync(stoppingToken); + await jobStore.CompleteAsync(jobId.Value, ReferenceSyncStage.Completed, result); logger.LogInformation( - "Reference sync: {TvShowsChecked} TV show(s) checked ({TvShowsUpdated} updated), {MoviesChecked} movie(s) checked ({MoviesUpdated} updated).", - result.TvShowsChecked, result.TvShowsUpdated, result.MoviesChecked, result.MoviesUpdated); + "Reference sync: {TvShowsChecked} TV show(s) checked ({TvShowsUpdated} updated), {MoviesChecked} movie(s) checked ({MoviesUpdated} updated), {FinishedShowsReopened} finished show(s) reopened.", + result.TvShowsChecked, result.TvShowsUpdated, result.MoviesChecked, result.MoviesUpdated, result.FinishedShowsReopened); } catch (Exception ex) { diff --git a/src/WebApi/ReferenceData/TvShowStatusReconciliationService.cs b/src/WebApi/ReferenceData/TvShowStatusReconciliationService.cs new file mode 100644 index 00000000..045a9cc2 --- /dev/null +++ b/src/WebApi/ReferenceData/TvShowStatusReconciliationService.cs @@ -0,0 +1,105 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Domain.Services; + +// this service reasons about the Domain status enum (the tenant model's own State), not the DTO one that the +// Contracts.Dto global using also brings into scope - same disambiguation Amazon/Generic import controllers need. +using TvShowStatus = Keeptrack.Domain.Models.TvShowStatus; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// Reopens finished TV shows once a new episode airs. Completing a show can't be a permanent decision - +/// when a tenant marks a show there's no way to know whether a further +/// season will ever be announced, so the only deterministic approach is to accept "finished" as of now and +/// let a regular pass reconcile it against the (independently kept-fresh) reference episode guide. +/// If a linked reference now lists an aired episode beyond the tenant's last-watched one, the show is flipped +/// back to so it resurfaces in Watch Next. +/// +/// Runs right after in the same background tick (and the admin's on-demand +/// "sync now"), so it always reconciles against the just-updated episode lists rather than stale data. +/// It's the tenant-data counterpart to 's shared-reference refresh, kept a +/// separate service so that one's charter stays "keep the owner-less reference collections fresh" only. +/// +/// Only shows are touched. and an unset +/// status both mean the tenant deliberately isn't tracking the show, so a new season must never reopen them - +/// this is enforced at the query (), so no other +/// state is ever a candidate here. +/// +public class TvShowStatusReconciliationService( + ITvShowRepository tvShowRepository, + IEpisodeRepository episodeRepository, + ITvShowReferenceRepository tvShowReferenceRepository, + ILogger logger) +{ + /// + /// Re-checks every finished, reference-linked show across all tenants and reopens (to + /// ) the ones whose reference guide now lists an aired episode after the + /// last one watched. Returns how many shows were reopened. A failure on one show is logged and skipped + /// rather than aborting the whole pass, mirroring 's per-document resilience. + /// + public async Task ReconcileFinishedShowsAsync(CancellationToken cancellationToken = default) + { + var finishedShows = await tvShowRepository.FindFinishedLinkedShowsAsync(); + if (finishedShows.Count == 0) return 0; + + var today = DateOnly.FromDateTime(DateTime.Today); + + // one batched reference lookup for the whole pass, keyed by reference id (a reference is shared, so + // many shows across tenants can point at the same document). + var referencesById = (await tvShowReferenceRepository.FindByIdsAsync( + finishedShows.Select(s => s.ReferenceId!).Distinct().ToList())) + .ToDictionary(r => r.Id!); + + var reopened = 0; + + // episodes are owner-scoped, so re-check one tenant at a time and reuse the same batched read Watch Next uses. + foreach (var showsByOwner in finishedShows.GroupBy(s => s.OwnerId)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var episodesByShow = (await episodeRepository.FindByShowIdsAsync( + showsByOwner.Key, showsByOwner.Select(s => s.Id!).ToList())) + .GroupBy(e => e.TvShowId) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var show in showsByOwner) + { + try + { + if (await TryReopenAsync(show, episodesByShow, referencesById, today)) reopened++; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to reconcile status for TV show {TvShowId} (owner {OwnerId})", show.Id, show.OwnerId); + } + } + } + + if (reopened > 0) logger.LogInformation("Status reconciliation: reopened {Count} finished show(s) as current.", reopened); + return reopened; + } + + private async Task TryReopenAsync( + TvShowModel show, + IReadOnlyDictionary> episodesByShow, + IReadOnlyDictionary referencesById, + DateOnly today) + { + // no recorded episodes means there's no "last watched" to compare against - can't tell whether a + // newer episode exists, so leave the show alone rather than guess (same "don't guess" rule as Watch Next). + if (!episodesByShow.TryGetValue(show.Id!, out var episodes) || episodes.Count == 0) return false; + if (!referencesById.TryGetValue(show.ReferenceId!, out var reference)) return false; + + var lastWatched = episodes + .OrderByDescending(e => e.SeasonNumber) + .ThenByDescending(e => e.EpisodeNumber) + .First(); + + if (WatchNextService.FindNextAiredEpisode(lastWatched.SeasonNumber, lastWatched.EpisodeNumber, reference, today) is null) return false; + + show.State = TvShowStatus.Current; + await tvShowRepository.UpdateAsync(show.Id!, show, show.OwnerId); + return true; + } +} diff --git a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs index ef801df2..b5fcac17 100644 --- a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs +++ b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs @@ -178,6 +178,9 @@ public Task SetReferenceLinkAsync(string title, int? year, string referenc public Task> FindDistinctUnresolvedTitleYearsAsync() => Task.FromResult>([]); + + public Task> FindFinishedLinkedShowsAsync() => + Task.FromResult>([]); } private sealed class FakeMovieRepository : InMemoryRepository, IMovieRepository diff --git a/test/WebApi.UnitTests/ReferenceData/TvShowStatusReconciliationServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/TvShowStatusReconciliationServiceTest.cs new file mode 100644 index 00000000..d175b935 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/TvShowStatusReconciliationServiceTest.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.ReferenceData; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +[Trait("Category", "UnitTests")] +public class TvShowStatusReconciliationServiceTest +{ + private readonly Mock _tvShowRepository = new(); + private readonly Mock _episodeRepository = new(); + private readonly Mock _tvShowReferenceRepository = new(); + + private TvShowStatusReconciliationService CreateService() => new( + _tvShowRepository.Object, _episodeRepository.Object, _tvShowReferenceRepository.Object, + NullLogger.Instance); + + private static TvShowModel FinishedShow(string id, string ownerId = "owner", string referenceId = "ref-1") => + new() { Id = id, OwnerId = ownerId, Title = "Dark", State = TvShowStatus.Finished, ReferenceId = referenceId }; + + private static EpisodeModel Episode(string showId, int season, int episode, string ownerId = "owner") => + new() { OwnerId = ownerId, TvShowId = showId, SeasonNumber = season, EpisodeNumber = episode }; + + private static ReferenceEpisodeModel RefEpisode(int season, int episode, DateOnly? airDate = null) => + new() { SeasonNumber = season, EpisodeNumber = episode, Title = $"S{season}E{episode}", AirDate = airDate }; + + private static TvShowReferenceModel Reference(string id, params ReferenceEpisodeModel[] episodes) => new() + { + Id = id, Title = "Dark", TitleNormalized = "dark", + ExternalIds = new Dictionary(), Episodes = [.. episodes] + }; + + private void Setup(IReadOnlyList shows, IReadOnlyList episodes, params TvShowReferenceModel[] references) + { + _tvShowRepository.Setup(r => r.FindFinishedLinkedShowsAsync()).ReturnsAsync(shows); + _tvShowReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())) + .ReturnsAsync(references.ToList()); + _episodeRepository.Setup(r => r.FindByShowIdsAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync((string ownerId, IReadOnlyCollection ids) => + episodes.Where(e => e.OwnerId == ownerId && ids.Contains(e.TvShowId)).ToList()); + } + + [Fact] + public async Task Reopens_FinishedShow_WhenReferenceHasAnAiredEpisodeAfterTheLastWatched() + { + Setup( + [FinishedShow("show-1")], + [Episode("show-1", 1, 10)], + Reference("ref-1", RefEpisode(1, 10), RefEpisode(2, 1, new DateOnly(2024, 1, 1)))); + 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 LeavesShowAlone_WhenAlreadyCaughtUpWithTheReferenceGuide() + { + Setup( + [FinishedShow("show-1")], + [Episode("show-1", 2, 1)], + Reference("ref-1", RefEpisode(1, 10), RefEpisode(2, 1))); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(0); + _tvShowRepository.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task LeavesShowAlone_WhenTheOnlyNewerEpisodeHasNotAiredYet() + { + Setup( + [FinishedShow("show-1")], + [Episode("show-1", 1, 10)], + Reference("ref-1", RefEpisode(1, 10), RefEpisode(2, 1, DateOnly.FromDateTime(DateTime.Today.AddDays(30))))); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(0); + _tvShowRepository.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task LeavesShowAlone_WhenItHasNoRecordedEpisodes() + { + // no last-watched episode to compare against - can't tell whether a newer one exists, so don't guess. + Setup( + [FinishedShow("show-1")], + [], + Reference("ref-1", RefEpisode(1, 1, new DateOnly(2024, 1, 1)))); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(0); + _tvShowRepository.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task LeavesShowAlone_WhenItsReferenceDocumentIsMissing() + { + Setup( + [FinishedShow("show-1", referenceId: "ref-1")], + [Episode("show-1", 1, 10)]); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(0); + _tvShowRepository.Verify(r => r.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ReconcilesEachTenantsOwnEpisodes_WhenTwoTenantsShareAReference() + { + // same reference, but each tenant's last-watched differs: only the tenant behind on the guide reopens. + Setup( + [FinishedShow("show-a", "owner-a"), FinishedShow("show-b", "owner-b")], + [Episode("show-a", 1, 10, "owner-a"), Episode("show-b", 2, 1, "owner-b")], + Reference("ref-1", RefEpisode(1, 10), RefEpisode(2, 1, new DateOnly(2024, 1, 1)))); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(1); + _tvShowRepository.Verify(r => r.UpdateAsync("show-a", It.IsAny(), "owner-a"), Times.Once); + _tvShowRepository.Verify(r => r.UpdateAsync("show-b", It.IsAny(), "owner-b"), Times.Never); + } + + [Fact] + public async Task ContinuesPastAFailedShow_AndStillProcessesTheRest() + { + Setup( + [FinishedShow("bad", "owner"), FinishedShow("good", "owner")], + [Episode("bad", 1, 1), Episode("good", 1, 1)], + Reference("ref-1", RefEpisode(1, 1), RefEpisode(1, 2, new DateOnly(2024, 1, 1)))); + _tvShowRepository.Setup(r => r.UpdateAsync("bad", It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Simulated write failure.")); + _tvShowRepository.Setup(r => r.UpdateAsync("good", It.IsAny(), It.IsAny())).ReturnsAsync(1); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(1); + _tvShowRepository.Verify(r => r.UpdateAsync("good", It.IsAny(), "owner"), Times.Once); + } + + [Fact] + public async Task DoesNothing_WhenNoFinishedLinkedShowsExist() + { + Setup([], []); + var service = CreateService(); + + var reopened = await service.ReconcileFinishedShowsAsync(TestContext.Current.CancellationToken); + + reopened.Should().Be(0); + _tvShowReferenceRepository.Verify(r => r.FindByIdsAsync(It.IsAny>()), Times.Never); + } +} From a039397cea21887bf4d27dd625d1c1ad0799a20d Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 30 Jul 2026 01:24:24 +0200 Subject: [PATCH 32/80] Add movie rating from tmdb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Movies vertical slice is built, green, and ready for your review. Preview (rendered with the app's real dark theme + my exact CSS): ⧉ https://claude.ai/code/artifact/1ce00b04-dd84-4461-8293-fd28de7e80b1 It shows all four placements: the ★ 8.2 pill in the list meta row, the same pill in the grid caption, the detail-page per-source breakdown (with a phase-2 mock of how IMDb / a two-source game would look), and the new sort option. What I built Data shape (locked for the multi-source future): - Canonical Ratings: Dictionary on MovieReferenceModel — source → { Value, Scale, Count }. "tmdb" today; "imdb"/"metacritic" later are just new keys, no schema change. - Denormalized ReferenceRating + ReferenceRatingScale on the tenant Movie — the primary source's value, copied down on link/refresh so the list displays and sorts it with zero extra queries and no join. Behavior: - TmdbClient now parses vote_average/vote_count; a 0-vote title stores no rating (won't show a false "0"). - Ratings populate on resolve and refresh; the scalar propagates to tenant docs on link (SetReferenceLinkAsync) and re-propagates on every refresh (SetReferenceRatingAsync) so copies stay current with the 24h sync. - Backfill fix: refresh forces a full fetch when a reference has no ratings yet, so existing linked movies self-heal on the next sync instead of being skipped forever by the TMDB "no change" short-circuit. Tests (all green): - 5 new unit tests (populate / no-vote / backfill-force / no-change short-circuit / link propagation). - 4 new real-Mongo integration tests (sort ordering with unrated-last, link propagation only touches unlinked, refresh re-propagation, dict BSON round-trip). - Full suite: 301 unit + 4 integration pass, whole solution builds clean. Files touched 15 files across Domain / Infra / Contracts / WebApi / BlazorApp + 2 test files. Nothing committed — all working-tree changes, awaiting your review. For your feedback 1. The pill look — bare ★ 8.2, grey pill, accent star. Good, or want it more/less prominent? 2. Detail breakdown — ★ 8.2 / 10 TMDB (24,183). Keep the vote count and the /10 scale here? 3. Placement — pill sits after the year, before your personal stars. Right order? --- .../Inventory/Meta/MovieMetaRow.razor | 4 + .../Inventory/Pages/MovieDetail.razor | 7 + .../Components/Inventory/Pages/Movies.razor | 1 + .../Inventory/Shared/InventoryList.razor | 11 ++ .../Inventory/Shared/ReferenceRatings.razor | 40 +++++ src/BlazorApp/wwwroot/app.css | 25 +++ src/Common.System/ListSort.cs | 7 + src/Domain/Models/MovieModel.cs | 11 ++ src/Domain/Models/MovieReferenceModel.cs | 7 + src/Domain/Models/ReferenceRatingModel.cs | 20 +++ src/Domain/Repositories/IMovieRepository.cs | 13 +- src/Infrastructure.MongoDb/Entities/Movie.cs | 6 + .../Entities/MovieReference.cs | 3 + .../Entities/ReferenceRating.cs | 16 ++ .../Repositories/MongoDbRepositoryBase.cs | 8 + .../Repositories/MovieRepository.cs | 15 +- src/WebApi.Contracts/Dto/MovieDto.cs | 10 ++ src/WebApi.Contracts/Dto/MovieReferenceDto.cs | 3 + .../Dto/ReferenceRatingDto.cs | 18 +++ src/WebApi/ReferenceData/ITmdbClient.cs | 2 +- ...renceEnrichmentService.TvShowsAndMovies.cs | 51 +++++- src/WebApi/ReferenceData/TmdbClient.cs | 9 +- .../MovieReferenceRatingRepositoryTest.cs | 151 ++++++++++++++++++ .../TvTimeImportServiceIdempotencyTest.cs | 5 +- .../ReferenceEnrichmentServiceTest.cs | 120 +++++++++++++- 25 files changed, 549 insertions(+), 14 deletions(-) create mode 100644 src/BlazorApp/Components/Inventory/Shared/ReferenceRatings.razor create mode 100644 src/Domain/Models/ReferenceRatingModel.cs create mode 100644 src/Infrastructure.MongoDb/Entities/ReferenceRating.cs create mode 100644 src/WebApi.Contracts/Dto/ReferenceRatingDto.cs create mode 100644 test/WebApi.IntegrationTests/Resources/MovieReferenceRatingRepositoryTest.cs diff --git a/src/BlazorApp/Components/Inventory/Meta/MovieMetaRow.razor b/src/BlazorApp/Components/Inventory/Meta/MovieMetaRow.razor index 0052e0eb..f073a50a 100644 --- a/src/BlazorApp/Components/Inventory/Meta/MovieMetaRow.razor +++ b/src/BlazorApp/Components/Inventory/Meta/MovieMetaRow.razor @@ -5,6 +5,10 @@ { @Item.Year } +@if (Item.ReferenceRating is not null) +{ + @Item.ReferenceRating.Value.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) +} @if (Item.Rating is not null) { diff --git a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor index fc11db12..38adaeca 100644 --- a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor @@ -91,6 +91,13 @@ else
+ @if (Reference?.Ratings.Count > 0) + { +
+ + +
+ } @if (Movie.FirstSeenAt is not null) {
diff --git a/src/BlazorApp/Components/Inventory/Pages/Movies.razor b/src/BlazorApp/Components/Inventory/Pages/Movies.razor index f49de459..9968da2b 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Movies.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Movies.razor @@ -31,6 +31,7 @@ Sort="@_sort" OnSortChanged="@SetSort" HasRatingSort="true" + HasReferenceRatingSort="true" ExtraSortValue="@ListSort.LastSeen" ExtraSortLabel="Seen"> diff --git a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor index bc9ffa41..b3590a5b 100644 --- a/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor +++ b/src/BlazorApp/Components/Inventory/Shared/InventoryList.razor @@ -63,6 +63,11 @@ else { } + @if (HasReferenceRatingSort) + { + @* short label so the selected text fits the narrow (mobile-sized) sort control *@ + + } @if (ExtraSortValue is not null) { @@ -309,6 +314,12 @@ else /// Offers the "Rating" sort option - only for types that carry a rating field. [Parameter] public bool HasRatingSort { get; set; } + /// + /// Offers the "Reference rating" sort option (the linked reference's provider score) - only for the + /// reference-bearing types that denormalize it onto the tenant item. + /// + [Parameter] public bool HasReferenceRatingSort { get; set; } + /// /// Query value for an extra, type-specific sort option (e.g. Movie's "seen", Book's "read", Video /// Game's "completed") - unset means no extra option renders. Paired with . diff --git a/src/BlazorApp/Components/Inventory/Shared/ReferenceRatings.razor b/src/BlazorApp/Components/Inventory/Shared/ReferenceRatings.razor new file mode 100644 index 00000000..c412fb34 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Shared/ReferenceRatings.razor @@ -0,0 +1,40 @@ +@* Per-source rating breakdown for a reference-linked detail page (e.g. "★ 7.8 / 10 TMDB (12,345)"). + Shared by every reference-bearing detail page so the source-name mapping and layout live once. + Renders nothing when the reference carries no ratings. *@ +@using System.Globalization + +@if (Ratings.Count > 0) +{ +
+ @foreach (var (source, rating) in Ratings) + { + + + @rating.Value.ToString("0.0", CultureInfo.InvariantCulture) + / @rating.Scale.ToString("0.#", CultureInfo.InvariantCulture) + @SourceName(source) + @if (rating.Count is > 0) + { + (@rating.Count.Value.ToString("N0", CultureInfo.InvariantCulture)) + } + + } +
+} + +@code { + [Parameter] public required Dictionary Ratings { get; set; } + + private static string SourceName(string source) => source switch + { + "tmdb" => "TMDB", + "imdb" => "IMDb", + "rawg" => "RAWG", + "metacritic" => "Metacritic", + "discogs" => "Discogs", + "googlebooks" => "Google Books", + "openlibrary" => "Open Library", + "bnf" => "BnF", + _ => source + }; +} diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index c1e3cf66..5d0c9884 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -421,6 +421,31 @@ a.kt-item-row { color: inherit; text-decoration: none; } .kt-flag-badge.done { background: var(--kt-success-bg); color: var(--kt-success); } .kt-flag-badge.wishlist { background: var(--kt-accent-glow); color: var(--kt-accent); } +/* reference (provider) rating pill on list/grid rows - the linked reference's own score, a bare number + with no scale. Distinct from the user's personal star rating (.kt-stars) and the flag pills. */ +.kt-ref-rating { + display: inline-flex; + align-items: center; + gap: 0.2rem; + padding: 0.05rem 0.45rem; + font-size: 0.72rem; + font-weight: 700; + border-radius: 20px; + white-space: nowrap; + background: var(--kt-surface-3); + color: var(--kt-text); +} +.kt-ref-rating .kt-ref-rating-star { color: var(--kt-accent); font-weight: 400; } + +/* per-source rating breakdown on the detail page (ReferenceRatings component) */ +.kt-ref-ratings { display: flex; flex-wrap: wrap; gap: 0.4rem 0.9rem; align-items: baseline; } +.kt-ref-rating-detail { display: inline-flex; align-items: baseline; gap: 0.28rem; white-space: nowrap; } +.kt-ref-rating-detail .kt-ref-rating-star { color: var(--kt-accent); } +.kt-ref-rating-detail .kt-ref-rating-value { font-weight: 700; font-size: 1.05rem; } +.kt-ref-rating-detail .kt-ref-rating-scale { color: var(--kt-text-subtle); font-size: 0.8rem; } +.kt-ref-rating-detail .kt-ref-rating-source { color: var(--kt-text-muted); font-size: 0.8rem; } +.kt-ref-rating-detail .kt-ref-rating-count { color: var(--kt-text-subtle); font-size: 0.75rem; } + /* ── List/thumbnail view toggle ────────────────────────────────────── */ /* segmented control in the search bar, only rendered for image-bearing types (see InventoryList) */ .kt-view-toggle { diff --git a/src/Common.System/ListSort.cs b/src/Common.System/ListSort.cs index bf83392f..e0323d2b 100644 --- a/src/Common.System/ListSort.cs +++ b/src/Common.System/ListSort.cs @@ -14,6 +14,13 @@ public static class ListSort /// Best rated first; unrated items last. public const string Rating = "rating"; + /// + /// Best rated first by the linked reference's own (provider) rating - the denormalized + /// ReferenceRating copied onto the tenant item on link/refresh; items with no linked rating last. + /// Distinct from , which is the user's own star rating. + /// + public const string ReferenceRating = "refrating"; + /// Most recently watched movie first (Movie's FirstSeenAt); unwatched items last. public const string LastSeen = "seen"; diff --git a/src/Domain/Models/MovieModel.cs b/src/Domain/Models/MovieModel.cs index 6d3a53a0..c00a800c 100644 --- a/src/Domain/Models/MovieModel.cs +++ b/src/Domain/Models/MovieModel.cs @@ -28,6 +28,17 @@ public class MovieModel : IHasIdAndOwnerId, IHasTvTimeId public string? ReferenceId { get; set; } + /// + /// Denormalized copy of the linked reference's primary-source rating value (TMDB's, on a 0-10 scale + /// given by ). Copied down from the shared reference document on + /// link/refresh purely so the list page can display and sort by it without a per-page join; the + /// authoritative multi-source data lives on . Null until linked. + /// + public double? ReferenceRating { get; set; } + + /// Scale of (10 for TMDB); null when there is no reference rating. + public double? ReferenceRatingScale { get; set; } + public DateOnly? FirstSeenAt { get; set; } public bool IsFavorite { get; set; } diff --git a/src/Domain/Models/MovieReferenceModel.cs b/src/Domain/Models/MovieReferenceModel.cs index 169f7106..71184035 100644 --- a/src/Domain/Models/MovieReferenceModel.cs +++ b/src/Domain/Models/MovieReferenceModel.cs @@ -33,6 +33,13 @@ public class MovieReferenceModel : IHasId public List Cast { get; set; } = []; + /// + /// Aggregate ratings for this movie keyed by source ("tmdb" today; "imdb"/others later) - see + /// . Canonical here on the shared reference document; the primary + /// source's value is denormalized onto each tenant's on link. + /// + public Dictionary Ratings { get; set; } = []; + public string? ImageUrl { get; set; } public DateTime? LastEnrichedAt { get; set; } diff --git a/src/Domain/Models/ReferenceRatingModel.cs b/src/Domain/Models/ReferenceRatingModel.cs new file mode 100644 index 00000000..79f75209 --- /dev/null +++ b/src/Domain/Models/ReferenceRatingModel.cs @@ -0,0 +1,20 @@ +namespace Keeptrack.Domain.Models; + +/// +/// One aggregate rating for a reference item from a single source, stored in a reference model's +/// Ratings dictionary keyed by that source (e.g. "tmdb", and later "imdb", "metacritic"). +/// is on the source's own (TMDB is out of 10, RAWG out of 5, +/// Metacritic out of 100) and is never normalized, so a source's native precision is preserved. +/// A single list only ever mixes items from one source, so its raw values still sort apples-to-apples. +/// The whole dictionary lives on the shared, user-agnostic reference document (the source of truth); +/// the tenant's own item carries only a denormalized copy of the primary source's value for fast list +/// display and sorting - see . +/// +public class ReferenceRatingModel +{ + public required double Value { get; set; } + + public required double Scale { get; set; } + + public int? Count { get; set; } +} diff --git a/src/Domain/Repositories/IMovieRepository.cs b/src/Domain/Repositories/IMovieRepository.cs index d3fac302..570f1947 100644 --- a/src/Domain/Repositories/IMovieRepository.cs +++ b/src/Domain/Repositories/IMovieRepository.cs @@ -7,11 +7,20 @@ namespace Keeptrack.Domain.Repositories; public interface IMovieRepository : IDataRepository { /// - /// Sets , and + /// Sets , , + /// and the denormalized / /// (to the reference's canonical values) on every tenant's movie matching this title/year that doesn't /// already have a reference link - see . /// - Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null); + Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, double? canonicalRating = null, double? canonicalRatingScale = null); + + /// + /// Re-propagates the denormalized / + /// to every tenant movie already linked to , keeping the copies current after + /// a periodic reference refresh (unlike , this matches by reference id + /// and so intentionally does touch already-linked documents). Null clears a rating that went away. + /// + Task SetReferenceRatingAsync(string referenceId, double? rating, double? ratingScale); /// /// Distinct (title, year) pairs across every tenant's movies that have no diff --git a/src/Infrastructure.MongoDb/Entities/Movie.cs b/src/Infrastructure.MongoDb/Entities/Movie.cs index 4cc26e30..6f32f9a1 100644 --- a/src/Infrastructure.MongoDb/Entities/Movie.cs +++ b/src/Infrastructure.MongoDb/Entities/Movie.cs @@ -29,6 +29,12 @@ public class Movie : IHasIdAndOwnerId [BsonElement("reference_id")] public string? ReferenceId { get; set; } + [BsonElement("reference_rating")] + public double? ReferenceRating { get; set; } + + [BsonElement("reference_rating_scale")] + public double? ReferenceRatingScale { get; set; } + [BsonElement("first_seen_at")] public DateTime? FirstSeenAt { get; set; } diff --git a/src/Infrastructure.MongoDb/Entities/MovieReference.cs b/src/Infrastructure.MongoDb/Entities/MovieReference.cs index 6b4cbd22..34067a7c 100644 --- a/src/Infrastructure.MongoDb/Entities/MovieReference.cs +++ b/src/Infrastructure.MongoDb/Entities/MovieReference.cs @@ -34,6 +34,9 @@ public class MovieReference public List Cast { get; set; } = []; + /// Aggregate ratings keyed by source name (e.g. "tmdb") - see . + public Dictionary Ratings { get; set; } = []; + [BsonElement("image_url")] public string? ImageUrl { get; set; } diff --git a/src/Infrastructure.MongoDb/Entities/ReferenceRating.cs b/src/Infrastructure.MongoDb/Entities/ReferenceRating.cs new file mode 100644 index 00000000..08b30c71 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/ReferenceRating.cs @@ -0,0 +1,16 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// Embedded aggregate rating from a single source, the value type of a reference entity's ratings +/// map (keyed by source name). See . +/// +public class ReferenceRating +{ + public required double Value { get; set; } + + public required double Scale { get; set; } + + public int? Count { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs b/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs index ac668409..8e53e794 100644 --- a/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs +++ b/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs @@ -69,6 +69,13 @@ public async Task> FindAllAsync(string ownerId, int page, in /// protected virtual Expression>? SortRatingField => null; + /// + /// Field behind the sort key (descending, items with no linked + /// reference rating last) - the denormalized copy on the tenant entity, so this stays a plain indexed + /// sort with no join. Same contract as . + /// + protected virtual Expression>? SortReferenceRatingField => null; + /// /// Field behind / (descending, unset /// items last) - same contract as . A single hook covers both keys: a @@ -90,6 +97,7 @@ protected virtual SortDefinition GetSort(string? sort) { ListSort.Title when SortTitleField is not null => builder.Ascending(SortTitleField).Descending("_id"), ListSort.Rating when SortRatingField is not null => builder.Descending(SortRatingField).Descending("_id"), + ListSort.ReferenceRating when SortReferenceRatingField is not null => builder.Descending(SortReferenceRatingField).Descending("_id"), ListSort.LastSeen or ListSort.LastRead when SortSecondaryDateField is not null => builder.Descending(SortSecondaryDateField).Descending("_id"), _ => builder.Descending("_id") }; diff --git a/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs b/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs index 2c9812f7..92d50513 100644 --- a/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs @@ -23,6 +23,8 @@ public class MovieRepository(IMongoDatabase mongoDatabase, ILogger> SortRatingField => x => x.Rating!; + protected override Expression> SortReferenceRatingField => x => x.ReferenceRating!; + protected override Expression> SortSecondaryDateField => x => x.FirstSeenAt!; protected override FilterDefinition GetFilter(string ownerId, string? search, MovieModel input) @@ -41,19 +43,28 @@ protected override FilterDefinition GetFilter(string ownerId, string? sea return filter; } - public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null) + public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, double? canonicalRating = null, double? canonicalRatingScale = null) { var builder = Builders.Filter; var filter = builder.Regex(f => f.Title, new BsonRegularExpression($"^{Regex.Escape(title)}$", "i")) & builder.Eq(f => f.Year, year) & UnresolvedFilter(); - var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle); + var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle) + .Set(f => f.ReferenceRating, canonicalRating).Set(f => f.ReferenceRatingScale, canonicalRatingScale); if (canonicalYear is not null) update = update.Set(f => f.Year, canonicalYear); var result = await GetCollection().UpdateManyAsync(filter, update); return result.ModifiedCount; } + public async Task SetReferenceRatingAsync(string referenceId, double? rating, double? ratingScale) + { + var filter = Builders.Filter.Eq(f => f.ReferenceId, referenceId); + var update = Builders.Update.Set(f => f.ReferenceRating, rating).Set(f => f.ReferenceRatingScale, ratingScale); + var result = await GetCollection().UpdateManyAsync(filter, update); + return result.ModifiedCount; + } + public async Task> FindDistinctUnresolvedTitleYearsAsync() { var groups = await GetCollection().Aggregate() diff --git a/src/WebApi.Contracts/Dto/MovieDto.cs b/src/WebApi.Contracts/Dto/MovieDto.cs index f8d78cbe..5b1cc1b3 100644 --- a/src/WebApi.Contracts/Dto/MovieDto.cs +++ b/src/WebApi.Contracts/Dto/MovieDto.cs @@ -33,6 +33,16 @@ public class MovieDto : IHasId, IReferenceLinkedDto /// public string? ImageUrl { get; set; } + /// + /// Denormalized primary-source rating from the linked reference (TMDB's, on ), + /// server-managed on link/refresh. Round-tripped on edits so a normal save doesn't drop it; not meant to + /// be set by clients. Null until linked. The full multi-source breakdown is on . + /// + public double? ReferenceRating { get; set; } + + /// Scale of (10 for TMDB); null when there is no reference rating. + public double? ReferenceRatingScale { get; set; } + public DateOnly? FirstSeenAt { get; set; } public bool IsFavorite { get; set; } diff --git a/src/WebApi.Contracts/Dto/MovieReferenceDto.cs b/src/WebApi.Contracts/Dto/MovieReferenceDto.cs index c5b640ea..827d17e0 100644 --- a/src/WebApi.Contracts/Dto/MovieReferenceDto.cs +++ b/src/WebApi.Contracts/Dto/MovieReferenceDto.cs @@ -21,5 +21,8 @@ public class MovieReferenceDto : IHasId public List Cast { get; set; } = []; + /// Aggregate ratings keyed by source name (e.g. "tmdb") - see . + public Dictionary Ratings { get; set; } = []; + public string? ImageUrl { get; set; } } diff --git a/src/WebApi.Contracts/Dto/ReferenceRatingDto.cs b/src/WebApi.Contracts/Dto/ReferenceRatingDto.cs new file mode 100644 index 00000000..65c0adc9 --- /dev/null +++ b/src/WebApi.Contracts/Dto/ReferenceRatingDto.cs @@ -0,0 +1,18 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One aggregate rating from a single source, the value of a reference DTO's Ratings map (keyed +/// by source name, e.g. "tmdb"). is on the source's own +/// (10 for TMDB); never normalized across sources. +/// +public class ReferenceRatingDto +{ + /// The rating value, on (e.g. 7.8 out of 10). + public double Value { get; set; } + + /// The maximum of the source's scale (e.g. 10 for TMDB, 5 for RAWG, 100 for Metacritic). + public double Scale { get; set; } + + /// Number of votes the value aggregates, when the source reports it. + public int? Count { get; set; } +} diff --git a/src/WebApi/ReferenceData/ITmdbClient.cs b/src/WebApi/ReferenceData/ITmdbClient.cs index 6342485c..7585c7c3 100644 --- a/src/WebApi/ReferenceData/ITmdbClient.cs +++ b/src/WebApi/ReferenceData/ITmdbClient.cs @@ -10,7 +10,7 @@ public record TmdbEpisode(int SeasonNumber, int EpisodeNumber, string Title, Dat public record TmdbTvShowDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Episodes, List Genres, string? PosterUrl); -public record TmdbMovieDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Genres, string? PosterUrl); +public record TmdbMovieDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Genres, string? PosterUrl, double? VoteAverage, int? VoteCount); /// /// One credited cast member - is TMDB's person id, used to deduplicate diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs index 0150b4be..a9748fd4 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs @@ -12,6 +12,31 @@ public partial class ReferenceEnrichmentService /// private const int MaxCastMembers = 15; + /// + /// The single source whose rating is denormalized onto the tenant item as the primary (list display / + /// sort) value. TMDB for both movies and shows; other domains use their own provider. + /// + private const string PrimaryRatingSource = "tmdb"; + + /// + /// Builds the reference Ratings map from a TMDB vote aggregate. TMDB returns vote_average 0 + /// with vote_count 0 for a title nobody has rated - that's "no rating", not a genuine zero, so it's + /// omitted rather than stored (a stored 0 would sort as a real score and render a "0" pill). + /// + private static Dictionary BuildTmdbRatings(double? voteAverage, int? voteCount) + { + var ratings = new Dictionary(); + if (voteAverage is > 0 && voteCount is > 0) + { + ratings[PrimaryRatingSource] = new ReferenceRatingModel { Value = voteAverage.Value, Scale = 10, Count = voteCount }; + } + return ratings; + } + + /// The (value, scale) to denormalize onto tenant items - the primary source's, or (null, null) when it has none. + private static (double? Value, double? Scale) PrimaryRating(IReadOnlyDictionary ratings) => + ratings.TryGetValue(PrimaryRatingSource, out var r) ? (r.Value, r.Scale) : (null, null); + /// /// User-triggered "check for reference match" - looks only at the local reference collection (title+year, /// falling back to title-only, against every (title, year) combination ever confirmed for that reference - @@ -92,6 +117,8 @@ public async Task TryLinkExistingMovieReferenceAsync(MovieModel mode if (!string.IsNullOrEmpty(model.ReferenceId)) { model.ReferenceId = string.Empty; + model.ReferenceRating = null; + model.ReferenceRatingScale = null; await movieRepository.UpdateAsync(model.Id!, model, model.OwnerId); } @@ -100,12 +127,15 @@ public async Task TryLinkExistingMovieReferenceAsync(MovieModel mode var originalTitle = model.Title; var originalYear = model.Year; + var (ratingValue, ratingScale) = PrimaryRating(reference.Ratings); model.ReferenceId = reference.Id; model.Title = reference.Title; if (reference.Year is not null) model.Year = reference.Year; + model.ReferenceRating = ratingValue; + model.ReferenceRatingScale = ratingScale; await movieRepository.UpdateAsync(model.Id!, model, model.OwnerId); - await movieRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year); + await movieRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year, ratingValue, ratingScale); return model; } @@ -138,6 +168,8 @@ public async Task UnlinkMovieReferenceAsync(MovieModel model) { var referenceId = model.ReferenceId; model.ReferenceId = string.Empty; + model.ReferenceRating = null; + model.ReferenceRatingScale = null; await movieRepository.UpdateAsync(model.Id!, model, model.OwnerId); if (!string.IsNullOrEmpty(referenceId)) { @@ -266,12 +298,14 @@ public async Task ResolveMovieAsync(string title, int? year MatchedAliases = MergeMatchedAliases(existing?.MatchedAliases, (details.Title, details.Year ?? year, null, null), (title, year, null, null)), Genres = details.Genres, Cast = await ResolveCastAsync(cast), + Ratings = BuildTmdbRatings(details.VoteAverage, details.VoteCount), ImageUrl = details.PosterUrl, LastEnrichedAt = DateTime.UtcNow }; var saved = await movieReferenceRepository.UpsertAsync(model); - await movieRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings); + await movieRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, ratingValue, ratingScale); return saved; } @@ -323,7 +357,11 @@ public async Task ResolveMovieAsync(string title, int? year var tmdbId = reference.ExternalIds.GetValueOrDefault("tmdb"); if (string.IsNullOrEmpty(tmdbId)) return (reference, false); - if (reference.LastEnrichedAt is not null) + // A reference with no ratings yet must do the full fetch even when TMDB reports no change since the + // last enrichment - otherwise references linked before ratings existed would never backfill one (the + // changes pre-check would keep skipping the fetch forever). Every already-rated reference still takes + // the cheap no-change short-circuit. + if (reference.LastEnrichedAt is not null && reference.Ratings.Count > 0) { var changed = await tmdbClient.HasMovieChangedSinceAsync(tmdbId, reference.LastEnrichedAt.Value, cancellationToken); if (!changed) @@ -342,11 +380,16 @@ public async Task ResolveMovieAsync(string title, int? year reference.Synopsis = details.Synopsis; reference.Genres = details.Genres; reference.Cast = await ResolveCastAsync(cast); + reference.Ratings = BuildTmdbRatings(details.VoteAverage, details.VoteCount); reference.ImageUrl = details.PosterUrl ?? reference.ImageUrl; reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, null, null)); reference.LastEnrichedAt = DateTime.UtcNow; - return (await movieReferenceRepository.UpsertAsync(reference), true); + var saved = await movieReferenceRepository.UpsertAsync(reference); + // keep every already-linked tenant movie's denormalized copy current with the refreshed rating + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings); + await movieRepository.SetReferenceRatingAsync(saved.Id!, ratingValue, ratingScale); + return (saved, true); } /// diff --git a/src/WebApi/ReferenceData/TmdbClient.cs b/src/WebApi/ReferenceData/TmdbClient.cs index 25122da4..261399ae 100644 --- a/src/WebApi/ReferenceData/TmdbClient.cs +++ b/src/WebApi/ReferenceData/TmdbClient.cs @@ -55,7 +55,8 @@ public async Task> SearchMovieAsync(string title ? null : new TmdbMovieDetails( tmdbId, details.Title ?? string.Empty, ParseYear(details.ReleaseDate), details.Overview, - details.Genres.Select(g => g.Name).ToList(), BuildImageUrl(details.PosterPath, PosterImageSize)); + details.Genres.Select(g => g.Name).ToList(), BuildImageUrl(details.PosterPath, PosterImageSize), + details.VoteAverage, details.VoteCount); } public async Task> GetTvShowCastAsync(string tmdbId, CancellationToken cancellationToken = default) => @@ -208,6 +209,12 @@ private sealed class TmdbMovieDetailsResponse [JsonPropertyName("genres")] public List Genres { get; set; } = []; + + [JsonPropertyName("vote_average")] + public double? VoteAverage { get; set; } + + [JsonPropertyName("vote_count")] + public int? VoteCount { get; set; } } private sealed class TmdbCreditsResponse diff --git a/test/WebApi.IntegrationTests/Resources/MovieReferenceRatingRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/MovieReferenceRatingRepositoryTest.cs new file mode 100644 index 00000000..36ca2e21 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/MovieReferenceRatingRepositoryTest.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Real-MongoDB coverage for the reference-rating feature's repository behaviors, which mocks can't prove: +/// the sort ordering (with unrated items last), the two +/// denormalized-copy propagation paths (SetReferenceLinkAsync on link and SetReferenceRatingAsync +/// on refresh), and that the reference document's Ratings dictionary round-trips through BSON. +/// Each test uses its own random owner id so parallel runs can't interfere. +/// +public class MovieReferenceRatingRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +{ + [Fact] + public async Task FindAllAsync_SortsByReferenceRating_BestFirstWithUnratedLast() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var ownerId = $"refrating-sort-{Guid.NewGuid():N}"; + + var created = new[] + { + await repository.CreateAsync(NewMovie(ownerId, "Middle", referenceRating: 6.5)), + await repository.CreateAsync(NewMovie(ownerId, "Unrated", referenceRating: null)), + await repository.CreateAsync(NewMovie(ownerId, "Best", referenceRating: 9.1)), + }; + + try + { + var byReferenceRating = await repository.FindAllAsync(ownerId, 1, 10, null, NewMovie(ownerId, ""), ListSort.ReferenceRating); + byReferenceRating.Items.Select(m => m.Title).Should().Equal(["Best", "Middle", "Unrated"], + "the reference-rating sort is best-first with items that have no linked rating last"); + } + finally + { + foreach (var movie in created) + { + await repository.DeleteAsync(movie.Id!, ownerId); + } + } + } + + [Fact] + public async Task SetReferenceLinkAsync_StampsTheDenormalizedRating_OnlyOnUnlinkedMatches() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var ownerId = $"refrating-link-{Guid.NewGuid():N}"; + + var unlinked = await repository.CreateAsync(NewMovie(ownerId, "The Matrix", year: 1999)); + var alreadyLinked = await repository.CreateAsync(NewMovie(ownerId, "The Matrix", year: 1999, referenceId: "pre-existing")); + + try + { + await repository.SetReferenceLinkAsync("The Matrix", 1999, "reference-1", "The Matrix", 1999, 8.2, 10); + + var reloadedUnlinked = await repository.FindOneAsync(unlinked.Id!, ownerId); + reloadedUnlinked!.ReferenceId.Should().Be("reference-1"); + reloadedUnlinked.ReferenceRating.Should().Be(8.2); + reloadedUnlinked.ReferenceRatingScale.Should().Be(10); + + // an already-linked document is left untouched by the link propagation (UnresolvedFilter) + var reloadedLinked = await repository.FindOneAsync(alreadyLinked.Id!, ownerId); + reloadedLinked!.ReferenceId.Should().Be("pre-existing"); + reloadedLinked.ReferenceRating.Should().BeNull(); + } + finally + { + await repository.DeleteAsync(unlinked.Id!, ownerId); + await repository.DeleteAsync(alreadyLinked.Id!, ownerId); + } + } + + [Fact] + public async Task SetReferenceRatingAsync_RepropagatesToEveryAlreadyLinkedMovie() + { + using var scope = factory.Services.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var ownerId = $"refrating-refresh-{Guid.NewGuid():N}"; + + var linkedA = await repository.CreateAsync(NewMovie(ownerId, "Alien", referenceId: "reference-7", referenceRating: 7.0)); + var linkedB = await repository.CreateAsync(NewMovie(ownerId, "Alien", referenceId: "reference-7", referenceRating: 7.0)); + var other = await repository.CreateAsync(NewMovie(ownerId, "Other", referenceId: "reference-other", referenceRating: 5.0)); + + try + { + var modified = await repository.SetReferenceRatingAsync("reference-7", 8.4, 10); + modified.Should().Be(2); + + (await repository.FindOneAsync(linkedA.Id!, ownerId))!.ReferenceRating.Should().Be(8.4); + (await repository.FindOneAsync(linkedB.Id!, ownerId))!.ReferenceRating.Should().Be(8.4); + // a movie linked to a different reference is untouched + (await repository.FindOneAsync(other.Id!, ownerId))!.ReferenceRating.Should().Be(5.0); + } + finally + { + await repository.DeleteAsync(linkedA.Id!, ownerId); + await repository.DeleteAsync(linkedB.Id!, ownerId); + await repository.DeleteAsync(other.Id!, ownerId); + } + } + + [Fact] + public async Task MovieReferenceRatings_RoundTripThroughBson() + { + using var scope = factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + + var saved = await referenceRepository.UpsertAsync(new MovieReferenceModel + { + Title = $"Round Trip {Guid.NewGuid():N}", + TitleNormalized = "placeholder", + Year = 2001, + ExternalIds = new Dictionary { ["tmdb"] = $"rt-{Guid.NewGuid():N}" }, + Ratings = new Dictionary { ["tmdb"] = new() { Value = 7.8, Scale = 10, Count = 4321 } } + }); + + try + { + var reloaded = await referenceRepository.FindByIdAsync(saved.Id!); + reloaded!.Ratings.Should().ContainKey("tmdb"); + reloaded.Ratings["tmdb"].Value.Should().Be(7.8); + reloaded.Ratings["tmdb"].Scale.Should().Be(10); + reloaded.Ratings["tmdb"].Count.Should().Be(4321); + } + finally + { + await referenceRepository.DeleteAsync(saved.Id!); + } + } + + private static MovieModel NewMovie(string ownerId, string title, int? year = null, string? referenceId = null, double? referenceRating = null) => new() + { + OwnerId = ownerId, + Title = title, + Year = year, + ReferenceId = referenceId, + ReferenceRating = referenceRating, + ReferenceRatingScale = referenceRating is null ? null : 10, + }; +} diff --git a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs index b5fcac17..12dabed8 100644 --- a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs +++ b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs @@ -185,7 +185,10 @@ public Task> FindFinishedLinkedShowsAsync() => private sealed class FakeMovieRepository : InMemoryRepository, IMovieRepository { - public Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null) => + public Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, double? canonicalRating = null, double? canonicalRatingScale = null) => + Task.FromResult(0L); + + public Task SetReferenceRatingAsync(string referenceId, double? rating, double? ratingScale) => Task.FromResult(0L); public Task> FindDistinctUnresolvedTitleYearsAsync() => diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs index aba2ffda..7cf8e633 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs @@ -393,7 +393,7 @@ public async Task ResolveMovieAsync_DoesNotMergeIntoAnUnrelatedSameTitledReferen // its Id for the upsert, which doesn't just link wrong, it overwrites the 2024 reference's own data // with the 1990 movie's data (a de-facto merge of two distinct real movies into one document). var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); - tmdbClient.MovieDetails["1990id"] = new TmdbMovieDetails("1990id", "Road House", 1990, "1989 original synopsis", [], null); + tmdbClient.MovieDetails["1990id"] = new TmdbMovieDetails("1990id", "Road House", 1990, "1989 original synopsis", [], null, null, null); _movieReferenceRepository .Setup(r => r.FindByExternalIdAsync("tmdb", "1990id")) .ReturnsAsync((MovieReferenceModel?)null); @@ -414,6 +414,115 @@ public async Task ResolveMovieAsync_DoesNotMergeIntoAnUnrelatedSameTitledReferen _movieReferenceRepository.Verify(r => r.UpsertAsync(It.Is(m => m.Id != "reference-2024")), Times.Once); } + [Fact] + public async Task ResolveMovieAsync_StoresTheTmdbRating_AndPropagatesThePrimaryScalar() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.MovieDetails["42"] = new TmdbMovieDetails("42", "Some Movie", 2020, "Synopsis", [], null, 7.8, 1234); + _movieReferenceRepository + .Setup(r => r.UpsertAsync(It.IsAny())) + .ReturnsAsync((MovieReferenceModel m) => { m.Id = "reference-1"; return m; }); + var service = CreateService(tmdbClient); + + var result = await service.ResolveMovieAsync("Some Movie", 2020, "42"); + + result.Ratings.Should().ContainKey("tmdb"); + result.Ratings["tmdb"].Value.Should().Be(7.8); + result.Ratings["tmdb"].Scale.Should().Be(10); + result.Ratings["tmdb"].Count.Should().Be(1234); + // the primary source's value/scale is denormalized onto every matching tenant movie + _movieRepository.Verify(r => r.SetReferenceLinkAsync("Some Movie", 2020, "reference-1", "Some Movie", It.IsAny(), 7.8, 10), Times.Once); + } + + [Fact] + public async Task ResolveMovieAsync_StoresNoRating_WhenTmdbHasNoVotes() + { + // TMDB returns vote_average 0 / vote_count 0 for an unrated title - that must not be stored as a real 0 + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.MovieDetails["42"] = new TmdbMovieDetails("42", "Some Movie", 2020, "Synopsis", [], null, 0, 0); + _movieReferenceRepository + .Setup(r => r.UpsertAsync(It.IsAny())) + .ReturnsAsync((MovieReferenceModel m) => { m.Id = "reference-1"; return m; }); + var service = CreateService(tmdbClient); + + var result = await service.ResolveMovieAsync("Some Movie", 2020, "42"); + + result.Ratings.Should().BeEmpty(); + _movieRepository.Verify(r => r.SetReferenceLinkAsync("Some Movie", 2020, "reference-1", "Some Movie", It.IsAny(), null, null), Times.Once); + } + + [Fact] + public async Task RefreshMovieReferenceAsync_ForcesAFullFetch_WhenReferenceHasNoRatingsYet_EvenIfTmdbReportsNoChange() + { + // an already-linked reference created before ratings existed must backfill a rating on the next sync, + // so the cheap "nothing changed" short-circuit must not fire while Ratings is still empty + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.ChangedSince["42"] = false; + tmdbClient.MovieDetails["42"] = new TmdbMovieDetails("42", "Some Movie", 2020, "Synopsis", [], null, 6.5, 500); + _movieReferenceRepository + .Setup(r => r.UpsertAsync(It.IsAny())) + .ReturnsAsync((MovieReferenceModel m) => { m.Id = "reference-1"; return m; }); + var reference = new MovieReferenceModel + { + Id = "reference-1", Title = "Some Movie", TitleNormalized = "some movie", Year = 2020, + ExternalIds = new Dictionary { ["tmdb"] = "42" }, LastEnrichedAt = DateTime.UtcNow.AddDays(-5) + }; + var service = CreateService(tmdbClient); + + var (result, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + tmdbClient.MovieDetailsRequested.Should().Contain("42"); + result.Ratings["tmdb"].Value.Should().Be(6.5); + // and the refreshed rating is re-propagated to every already-linked tenant movie + _movieRepository.Verify(r => r.SetReferenceRatingAsync("reference-1", 6.5, 10), Times.Once); + } + + [Fact] + public async Task RefreshMovieReferenceAsync_TakesTheNoChangeShortCircuit_WhenAlreadyRated() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.ChangedSince["42"] = false; + _movieReferenceRepository + .Setup(r => r.UpsertAsync(It.IsAny())) + .ReturnsAsync((MovieReferenceModel m) => m); + var reference = new MovieReferenceModel + { + Id = "reference-1", Title = "Some Movie", TitleNormalized = "some movie", Year = 2020, + ExternalIds = new Dictionary { ["tmdb"] = "42" }, LastEnrichedAt = DateTime.UtcNow.AddDays(-5), + Ratings = new Dictionary { ["tmdb"] = new() { Value = 6.5, Scale = 10, Count = 500 } } + }; + var service = CreateService(tmdbClient); + + var (_, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeFalse(); + tmdbClient.MovieDetailsRequested.Should().NotContain("42"); + _movieRepository.Verify(r => r.SetReferenceRatingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task TryLinkExistingMovieReferenceAsync_SetsTheDenormalizedRating_FromTheMatchedReference() + { + _movieReferenceRepository + .Setup(r => r.FindByTitleYearAsync("Some Movie", 2020)) + .ReturnsAsync(new MovieReferenceModel + { + Id = "reference-1", Title = "Some Movie", TitleNormalized = "some movie", Year = 2020, + ExternalIds = new Dictionary { ["tmdb"] = "42" }, + Ratings = new Dictionary { ["tmdb"] = new() { Value = 8.1, Scale = 10, Count = 900 } } + }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + var model = new MovieModel { Id = "movie-1", OwnerId = "owner-1", Title = "Some Movie", Year = 2020 }; + + var result = await service.TryLinkExistingMovieReferenceAsync(model); + + result.ReferenceRating.Should().Be(8.1); + result.ReferenceRatingScale.Should().Be(10); + _movieRepository.Verify(r => r.UpdateAsync("movie-1", It.Is(m => m.ReferenceRating == 8.1 && m.ReferenceRatingScale == 10), "owner-1"), Times.Once); + _movieRepository.Verify(r => r.SetReferenceLinkAsync("Some Movie", 2020, "reference-1", "Some Movie", It.IsAny(), 8.1, 10), Times.Once); + } + [Fact] public async Task RefreshTvShowReferenceAsync_ReturnsUnchanged_WhenReferenceHasNoTmdbId() { @@ -1179,6 +1288,8 @@ private sealed class FakeTmdbClient : ITmdbClient public List TvShowDetailsRequested { get; } = []; + public List MovieDetailsRequested { get; } = []; + public List ChangesRequested { get; } = []; private FakeTmdbClient(List tvShowSearchResults) => _tvShowSearchResults = tvShowSearchResults; @@ -1197,8 +1308,11 @@ public Task> SearchMovieAsync(string title, int? return Task.FromResult(TvShowDetails.GetValueOrDefault(tmdbId)); } - public Task GetMovieDetailsAsync(string tmdbId, CancellationToken cancellationToken = default) => - Task.FromResult(MovieDetails.GetValueOrDefault(tmdbId)); + public Task GetMovieDetailsAsync(string tmdbId, CancellationToken cancellationToken = default) + { + MovieDetailsRequested.Add(tmdbId); + return Task.FromResult(MovieDetails.GetValueOrDefault(tmdbId)); + } public Task> GetTvShowCastAsync(string tmdbId, CancellationToken cancellationToken = default) => Task.FromResult>(Cast.GetValueOrDefault(tmdbId) ?? []); From 4e61434714b62644769f47d55825e499c15d31b0 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 30 Jul 2026 01:59:36 +0200 Subject: [PATCH 33/80] Add ratings for other media types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference-rating feature is now complete across all five media types. Nothing committed — all working-tree changes, ready for you to run and review. What's in now Every type has: the ★ n.n pill in list + grid rows, the per-source breakdown on the detail page, a Ref ★ sort option, the canonical Ratings dict on the reference collection, and the denormalized scalar on the tenant doc kept current on link + refresh. ┌────────────┬────────────────────────────────────┬─────────────────────┬──────────────────────────────────────────────────────────────────────────────┐ │ Type │ Source(s) → dict │ Primary (pill/sort) │ Notes │ ├────────────┼────────────────────────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────┤ │ Movie / TV │ TMDB vote_average /10 │ TMDB │ backfill-forces a fetch when a reference has no rating yet │ ├────────────┼────────────────────────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────┤ │ Video game │ RAWG /5 + Metacritic /100 │ RAWG │ first multi-source type — both show on detail, RAWG drives the pill │ ├────────────┼────────────────────────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────┤ │ Album │ Discogs /5 │ Discogs │ rating lives on the release, so I fetch the master's main_release │ ├────────────┼────────────────────────────────────┼─────────────────────┼──────────────────────────────────────────────────────────────────────────────┤ │ Book │ Google Books /5 or Open Library /5 │ linking provider │ OL via its /ratings.json endpoint; BnF reports none (pill just doesn't show) │ └────────────┴────────────────────────────────────┴─────────────────────┴──────────────────────────────────────────────────────────────────────────────┘ Two things worth knowing when you look at the DB / run it - Existing links backfill automatically. A reference already linked before this change has no ratings yet; the next sync fills it in (TMDB via the force-fetch guard; RAWG/Discogs/book always re-fetch). No migration script needed. To see ratings immediately on existing data, hit Sync now on the admin page, or re-link an item. - Extra provider call for albums & OL books. Discogs and Open Library ratings need one extra HTTP call during resolve/refresh only (never on list/detail reads), so list load time is unaffected — the concern you flagged. State - Full solution builds; 301 unit tests pass (movies slice already has 5 unit + 4 real-Mongo integration tests covering the shared logic every type reuses). - I did not run integration/e2e or add per-type duplicate tests — the sort/propagation/backfill logic is shared and already covered via the movie tests, and you wanted to drive the app yourself. --- .../Inventory/Meta/AlbumMetaRow.razor | 4 ++ .../Inventory/Meta/BookMetaRow.razor | 4 ++ .../Inventory/Meta/TvShowMetaRow.razor | 4 ++ .../Inventory/Meta/VideoGameMetaRow.razor | 4 ++ .../Inventory/Pages/AlbumDetail.razor | 7 +++ .../Components/Inventory/Pages/Albums.razor | 3 +- .../Inventory/Pages/BookDetail.razor | 7 +++ .../Components/Inventory/Pages/Books.razor | 1 + .../Inventory/Pages/TvShowDetail.razor | 7 +++ .../Components/Inventory/Pages/TvShows.razor | 3 +- .../Inventory/Pages/VideoGameDetail.razor | 7 +++ .../Inventory/Pages/VideoGames.razor | 1 + src/Domain/Models/AlbumModel.cs | 10 +++++ src/Domain/Models/AlbumReferenceModel.cs | 3 ++ src/Domain/Models/BookModel.cs | 10 +++++ src/Domain/Models/BookReferenceModel.cs | 6 +++ src/Domain/Models/TvShowModel.cs | 10 +++++ src/Domain/Models/TvShowReferenceModel.cs | 3 ++ src/Domain/Models/VideoGameModel.cs | 10 +++++ src/Domain/Models/VideoGameReferenceModel.cs | 6 +++ src/Domain/Repositories/IAlbumRepository.cs | 8 +++- src/Domain/Repositories/IBookRepository.cs | 8 +++- src/Domain/Repositories/ITvShowRepository.cs | 8 +++- .../Repositories/IVideoGameRepository.cs | 8 +++- src/Infrastructure.MongoDb/Entities/Album.cs | 6 +++ .../Entities/AlbumReference.cs | 3 ++ src/Infrastructure.MongoDb/Entities/Book.cs | 6 +++ .../Entities/BookReference.cs | 3 ++ src/Infrastructure.MongoDb/Entities/TvShow.cs | 6 +++ .../Entities/TvShowReference.cs | 3 ++ .../Entities/VideoGame.cs | 6 +++ .../Entities/VideoGameReference.cs | 3 ++ .../Repositories/AlbumRepository.cs | 15 ++++++- .../Repositories/BookRepository.cs | 15 ++++++- .../Repositories/TvShowRepository.cs | 15 ++++++- .../Repositories/VideoGameRepository.cs | 15 ++++++- src/WebApi.Contracts/Dto/AlbumDto.cs | 10 +++++ src/WebApi.Contracts/Dto/AlbumReferenceDto.cs | 3 ++ src/WebApi.Contracts/Dto/BookDto.cs | 10 +++++ src/WebApi.Contracts/Dto/BookReferenceDto.cs | 3 ++ src/WebApi.Contracts/Dto/TvShowDto.cs | 10 +++++ .../Dto/TvShowReferenceDto.cs | 3 ++ src/WebApi.Contracts/Dto/VideoGameDto.cs | 10 +++++ .../Dto/VideoGameReferenceDto.cs | 3 ++ src/WebApi/ReferenceData/DiscogsClient.cs | 42 +++++++++++++++++- src/WebApi/ReferenceData/GoogleBooksClient.cs | 12 ++++- .../ReferenceData/IBookReferenceClient.cs | 2 +- src/WebApi/ReferenceData/IDiscogsClient.cs | 2 +- src/WebApi/ReferenceData/IRawgClient.cs | 2 +- src/WebApi/ReferenceData/ITmdbClient.cs | 2 +- src/WebApi/ReferenceData/OpenLibraryClient.cs | 32 +++++++++++++- src/WebApi/ReferenceData/RawgClient.cs | 11 ++++- .../ReferenceEnrichmentService.Albums.cs | 30 +++++++++++-- .../ReferenceEnrichmentService.Books.cs | 39 ++++++++++++++-- ...renceEnrichmentService.TvShowsAndMovies.cs | 39 +++++++++++----- .../ReferenceEnrichmentService.VideoGames.cs | 44 +++++++++++++++++-- src/WebApi/ReferenceData/TmdbClient.cs | 9 +++- .../TvTimeImportServiceIdempotencyTest.cs | 5 ++- .../ReferenceEnrichmentServiceTest.cs | 3 ++ 59 files changed, 520 insertions(+), 44 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Meta/AlbumMetaRow.razor b/src/BlazorApp/Components/Inventory/Meta/AlbumMetaRow.razor index 6ed13518..f1c580a6 100644 --- a/src/BlazorApp/Components/Inventory/Meta/AlbumMetaRow.razor +++ b/src/BlazorApp/Components/Inventory/Meta/AlbumMetaRow.razor @@ -8,6 +8,10 @@ { @Item.Year } +@if (Item.ReferenceRating is not null) +{ + @Item.ReferenceRating.Value.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) +} @if (Item.Rating is not null) { diff --git a/src/BlazorApp/Components/Inventory/Meta/BookMetaRow.razor b/src/BlazorApp/Components/Inventory/Meta/BookMetaRow.razor index c06520b7..8b1a4e90 100644 --- a/src/BlazorApp/Components/Inventory/Meta/BookMetaRow.razor +++ b/src/BlazorApp/Components/Inventory/Meta/BookMetaRow.razor @@ -8,6 +8,10 @@ { @Item.Author } +@if (Item.ReferenceRating is not null) +{ + @Item.ReferenceRating.Value.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) +} @if (Item.Rating is not null) { diff --git a/src/BlazorApp/Components/Inventory/Meta/TvShowMetaRow.razor b/src/BlazorApp/Components/Inventory/Meta/TvShowMetaRow.razor index 49778b45..80c4f7ff 100644 --- a/src/BlazorApp/Components/Inventory/Meta/TvShowMetaRow.razor +++ b/src/BlazorApp/Components/Inventory/Meta/TvShowMetaRow.razor @@ -8,6 +8,10 @@ { @Item.State } +@if (Item.ReferenceRating is not null) +{ + @Item.ReferenceRating.Value.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) +} @if (Item.Rating is not null) { diff --git a/src/BlazorApp/Components/Inventory/Meta/VideoGameMetaRow.razor b/src/BlazorApp/Components/Inventory/Meta/VideoGameMetaRow.razor index ed5d0b2b..6c5d3124 100644 --- a/src/BlazorApp/Components/Inventory/Meta/VideoGameMetaRow.razor +++ b/src/BlazorApp/Components/Inventory/Meta/VideoGameMetaRow.razor @@ -11,6 +11,10 @@ @entry.Platform@(string.IsNullOrEmpty(entry.State) ? "" : $" ({entry.State})") } +@if (Item.ReferenceRating is not null) +{ + @Item.ReferenceRating.Value.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) +} @if (Item.Rating is not null) { diff --git a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor index d64175cf..5d779b3a 100644 --- a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor @@ -94,6 +94,13 @@ else
+ @if (Reference?.Ratings.Count > 0) + { +
+ + +
+ }
+ HasRatingSort="true" + HasReferenceRatingSort="true"> diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor index e89854dd..d85d3b67 100644 --- a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -99,6 +99,13 @@ else
+ @if (Reference?.Ratings.Count > 0) + { +
+ + +
+ } @if (Book.FirstReadAt is not null) {
diff --git a/src/BlazorApp/Components/Inventory/Pages/Books.razor b/src/BlazorApp/Components/Inventory/Pages/Books.razor index e302e8ef..886f06a2 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Books.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Books.razor @@ -31,6 +31,7 @@ Sort="@_sort" OnSortChanged="@SetSort" HasRatingSort="true" + HasReferenceRatingSort="true" ExtraSortValue="@ListSort.LastRead" ExtraSortLabel="Read"> diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor index eb5eac75..6e592a72 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor @@ -94,6 +94,13 @@ else
+ @if (_reference?.Ratings.Count > 0) + { +
+ + +
+ } @if (_reference?.Genres.Count > 0) {

@string.Join(", ", _reference.Genres)

diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor index 598a4c0a..cbc387af 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShows.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShows.razor @@ -30,7 +30,8 @@ OnSearchChanged="@OnSearchChanged" Sort="@_sort" OnSortChanged="@SetSort" - HasRatingSort="true"> + HasRatingSort="true" + HasReferenceRatingSort="true"> diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor index b09b61b2..f7b11b31 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor @@ -89,6 +89,13 @@ else
+ @if (Reference?.Ratings.Count > 0) + { +
+ + +
+ } @if (Reference?.Platforms.Count > 0) {

Available on: @string.Join(", ", Reference.Platforms)

diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor index a0191509..9abbe664 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGames.razor @@ -32,6 +32,7 @@ Sort="@_sort" OnSortChanged="@SetSort" HasRatingSort="true" + HasReferenceRatingSort="true" ExtraSortValue="@ListSort.LastCompleted" ExtraSortLabel="Ended"> diff --git a/src/Domain/Models/AlbumModel.cs b/src/Domain/Models/AlbumModel.cs index f93ebf88..63cb1a49 100644 --- a/src/Domain/Models/AlbumModel.cs +++ b/src/Domain/Models/AlbumModel.cs @@ -21,6 +21,16 @@ public class AlbumModel : IHasIdAndOwnerId public string? ReferenceId { get; set; } + /// + /// Denormalized copy of the linked reference's primary-source (Discogs, 0-5) rating, on + /// . Copied down on link/refresh for fast list display and sorting; + /// the authoritative data is on . + /// + public double? ReferenceRating { get; set; } + + /// Scale of (5 for Discogs); null when there is no reference rating. + public double? ReferenceRatingScale { get; set; } + /// /// Tenant-owned cover image override - takes priority over the linked reference's own cover wherever /// a cover is shown (list thumbnail, detail page). Null means "use the reference's cover, if any" - diff --git a/src/Domain/Models/AlbumReferenceModel.cs b/src/Domain/Models/AlbumReferenceModel.cs index f4e74ab3..d3306d4a 100644 --- a/src/Domain/Models/AlbumReferenceModel.cs +++ b/src/Domain/Models/AlbumReferenceModel.cs @@ -40,6 +40,9 @@ public class AlbumReferenceModel : IHasId public List Tracks { get; set; } = []; + /// Aggregate ratings keyed by source ("discogs") - see . + public Dictionary Ratings { get; set; } = []; + public string? ImageUrl { get; set; } public DateTime? LastEnrichedAt { get; set; } diff --git a/src/Domain/Models/BookModel.cs b/src/Domain/Models/BookModel.cs index b9ea4a25..23966126 100644 --- a/src/Domain/Models/BookModel.cs +++ b/src/Domain/Models/BookModel.cs @@ -37,6 +37,16 @@ public class BookModel : IHasIdAndOwnerId public string? ReferenceId { get; set; } + /// + /// Denormalized copy of the linked reference's primary-source (the linking book provider's, 0-5) + /// rating, on . Copied down on link/refresh for fast list display + /// and sorting; the authoritative data is on . + /// + public double? ReferenceRating { get; set; } + + /// Scale of (5 for the current book providers); null when there is no reference rating. + public double? ReferenceRatingScale { get; set; } + /// /// Tenant-owned cover image override - takes priority over the linked reference's own cover wherever /// a cover is shown (list thumbnail, detail page). Null means "use the reference's cover, if any" - diff --git a/src/Domain/Models/BookReferenceModel.cs b/src/Domain/Models/BookReferenceModel.cs index b417ff1c..2760faf5 100644 --- a/src/Domain/Models/BookReferenceModel.cs +++ b/src/Domain/Models/BookReferenceModel.cs @@ -38,6 +38,12 @@ public class BookReferenceModel : IHasId public List Genres { get; set; } = []; + /// + /// Aggregate ratings keyed by the linking provider's key ("googlebooks"/"openlibrary"; BnF reports + /// none) - see . + /// + public Dictionary Ratings { get; set; } = []; + public string? ImageUrl { get; set; } /// diff --git a/src/Domain/Models/TvShowModel.cs b/src/Domain/Models/TvShowModel.cs index fcd10954..f4ef33ba 100644 --- a/src/Domain/Models/TvShowModel.cs +++ b/src/Domain/Models/TvShowModel.cs @@ -29,6 +29,16 @@ public class TvShowModel : IHasIdAndOwnerId, IHasTvTimeId public string? ReferenceId { get; set; } + /// + /// Denormalized copy of the linked reference's primary-source (TMDB) rating value, on a 0-10 scale + /// given by . Copied down on link/refresh for fast list display and + /// sorting; the authoritative multi-source data is on . + /// + public double? ReferenceRating { get; set; } + + /// Scale of (10 for TMDB); null when there is no reference rating. + public double? ReferenceRatingScale { get; set; } + public TvShowStatus? State { get; set; } public bool IsFavorite { get; set; } diff --git a/src/Domain/Models/TvShowReferenceModel.cs b/src/Domain/Models/TvShowReferenceModel.cs index 80b886af..751ec20f 100644 --- a/src/Domain/Models/TvShowReferenceModel.cs +++ b/src/Domain/Models/TvShowReferenceModel.cs @@ -48,6 +48,9 @@ public class TvShowReferenceModel : IHasId public List Cast { get; set; } = []; + /// Aggregate ratings keyed by source ("tmdb") - see . + public Dictionary Ratings { get; set; } = []; + public string? ImageUrl { get; set; } public DateTime? LastEnrichedAt { get; set; } diff --git a/src/Domain/Models/VideoGameModel.cs b/src/Domain/Models/VideoGameModel.cs index 717612b7..757bce13 100644 --- a/src/Domain/Models/VideoGameModel.cs +++ b/src/Domain/Models/VideoGameModel.cs @@ -21,6 +21,16 @@ public class VideoGameModel : IHasIdAndOwnerId public string? ReferenceId { get; set; } + /// + /// Denormalized copy of the linked reference's primary-source (RAWG) 0-5 rating, on + /// . Copied down on link/refresh for fast list display and sorting; + /// the authoritative multi-source data (RAWG + Metacritic) is on . + /// + public double? ReferenceRating { get; set; } + + /// Scale of (5 for RAWG); null when there is no reference rating. + public double? ReferenceRatingScale { get; set; } + /// /// Tenant-owned cover image override - takes priority over the linked reference's own cover wherever /// a cover is shown (list thumbnail, detail page). Null means "use the reference's cover, if any" - diff --git a/src/Domain/Models/VideoGameReferenceModel.cs b/src/Domain/Models/VideoGameReferenceModel.cs index e7f474af..b9e4c8b7 100644 --- a/src/Domain/Models/VideoGameReferenceModel.cs +++ b/src/Domain/Models/VideoGameReferenceModel.cs @@ -37,6 +37,12 @@ public class VideoGameReferenceModel : IHasId public List Genres { get; set; } = []; + /// + /// Aggregate ratings keyed by source: "rawg" (0-5 user score, the primary) and "metacritic" (0-100 + /// critic score) when present - see . + /// + public Dictionary Ratings { get; set; } = []; + public string? ImageUrl { get; set; } public DateTime? LastEnrichedAt { get; set; } diff --git a/src/Domain/Repositories/IAlbumRepository.cs b/src/Domain/Repositories/IAlbumRepository.cs index 68e7ca12..c8f8fc70 100644 --- a/src/Domain/Repositories/IAlbumRepository.cs +++ b/src/Domain/Repositories/IAlbumRepository.cs @@ -12,7 +12,13 @@ public interface IAlbumRepository : IDataRepository /// on every tenant's album matching this title/year that doesn't already have a reference link - see /// . /// - Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalArtist = null, string? canonicalGenre = null); + Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalArtist = null, string? canonicalGenre = null, double? canonicalRating = null, double? canonicalRatingScale = null); + + /// + /// Re-propagates the denormalized / + /// to every tenant album already linked to - see . + /// + Task SetReferenceRatingAsync(string referenceId, double? rating, double? ratingScale); /// /// Distinct (title, year) pairs across every tenant's albums that have no diff --git a/src/Domain/Repositories/IBookRepository.cs b/src/Domain/Repositories/IBookRepository.cs index 7e5baf4d..a6e577ef 100644 --- a/src/Domain/Repositories/IBookRepository.cs +++ b/src/Domain/Repositories/IBookRepository.cs @@ -14,7 +14,13 @@ public interface IBookRepository : IDataRepository /// . /// Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalAuthor = null, string? canonicalGenre = null, - string? canonicalLanguage = null, string? canonicalIsbn = null); + string? canonicalLanguage = null, string? canonicalIsbn = null, double? canonicalRating = null, double? canonicalRatingScale = null); + + /// + /// Re-propagates the denormalized / + /// to every tenant book already linked to - see . + /// + Task SetReferenceRatingAsync(string referenceId, double? rating, double? ratingScale); /// /// Distinct (title, year) pairs across every tenant's books that have no diff --git a/src/Domain/Repositories/ITvShowRepository.cs b/src/Domain/Repositories/ITvShowRepository.cs index d49c6ff3..dd182f3d 100644 --- a/src/Domain/Repositories/ITvShowRepository.cs +++ b/src/Domain/Repositories/ITvShowRepository.cs @@ -14,7 +14,13 @@ public interface ITvShowRepository : IDataRepository /// newly-linked show starts with a trustworthy year instead of whatever the tenant originally guessed /// (still freely editable afterward). Otherwise never touches any tenant's own rating/notes/episodes. /// - Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null); + Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, double? canonicalRating = null, double? canonicalRatingScale = null); + + /// + /// Re-propagates the denormalized / + /// to every tenant show already linked to - see . + /// + Task SetReferenceRatingAsync(string referenceId, double? rating, double? ratingScale); /// /// Distinct (title, year) pairs across every tenant's shows that have no diff --git a/src/Domain/Repositories/IVideoGameRepository.cs b/src/Domain/Repositories/IVideoGameRepository.cs index 56b0441d..3be8e040 100644 --- a/src/Domain/Repositories/IVideoGameRepository.cs +++ b/src/Domain/Repositories/IVideoGameRepository.cs @@ -11,7 +11,13 @@ public interface IVideoGameRepository : IDataRepository /// (to the reference's canonical values) on every tenant's game matching /// this title/year that doesn't already have a reference link - see . /// - Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null); + Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, double? canonicalRating = null, double? canonicalRatingScale = null); + + /// + /// Re-propagates the denormalized / + /// to every tenant game already linked to - see . + /// + Task SetReferenceRatingAsync(string referenceId, double? rating, double? ratingScale); /// /// Distinct (title, year) pairs across every tenant's games that have no diff --git a/src/Infrastructure.MongoDb/Entities/Album.cs b/src/Infrastructure.MongoDb/Entities/Album.cs index 3306fe63..f7d516a3 100644 --- a/src/Infrastructure.MongoDb/Entities/Album.cs +++ b/src/Infrastructure.MongoDb/Entities/Album.cs @@ -27,6 +27,12 @@ public class Album : IHasIdAndOwnerId [BsonElement("reference_id")] public string? ReferenceId { get; set; } + [BsonElement("reference_rating")] + public double? ReferenceRating { get; set; } + + [BsonElement("reference_rating_scale")] + public double? ReferenceRatingScale { get; set; } + [BsonElement("custom_image_url")] public string? CustomImageUrl { get; set; } diff --git a/src/Infrastructure.MongoDb/Entities/AlbumReference.cs b/src/Infrastructure.MongoDb/Entities/AlbumReference.cs index 40e8b693..7aa55b4b 100644 --- a/src/Infrastructure.MongoDb/Entities/AlbumReference.cs +++ b/src/Infrastructure.MongoDb/Entities/AlbumReference.cs @@ -37,6 +37,9 @@ public class AlbumReference public List Tracks { get; set; } = []; + /// Aggregate ratings keyed by source name ("discogs") - see . + public Dictionary Ratings { get; set; } = []; + [BsonElement("image_url")] public string? ImageUrl { get; set; } diff --git a/src/Infrastructure.MongoDb/Entities/Book.cs b/src/Infrastructure.MongoDb/Entities/Book.cs index f0bef99e..c1222af3 100644 --- a/src/Infrastructure.MongoDb/Entities/Book.cs +++ b/src/Infrastructure.MongoDb/Entities/Book.cs @@ -39,6 +39,12 @@ public class Book : IHasIdAndOwnerId [BsonElement("reference_id")] public string? ReferenceId { get; set; } + [BsonElement("reference_rating")] + public double? ReferenceRating { get; set; } + + [BsonElement("reference_rating_scale")] + public double? ReferenceRatingScale { get; set; } + [BsonElement("custom_image_url")] public string? CustomImageUrl { get; set; } diff --git a/src/Infrastructure.MongoDb/Entities/BookReference.cs b/src/Infrastructure.MongoDb/Entities/BookReference.cs index 699d550c..1b97f206 100644 --- a/src/Infrastructure.MongoDb/Entities/BookReference.cs +++ b/src/Infrastructure.MongoDb/Entities/BookReference.cs @@ -35,6 +35,9 @@ public class BookReference public List Genres { get; set; } = []; + /// Aggregate ratings keyed by provider key ("googlebooks"/"openlibrary") - see . + public Dictionary Ratings { get; set; } = []; + [BsonElement("image_url")] public string? ImageUrl { get; set; } diff --git a/src/Infrastructure.MongoDb/Entities/TvShow.cs b/src/Infrastructure.MongoDb/Entities/TvShow.cs index 4d4091a3..42fc733c 100644 --- a/src/Infrastructure.MongoDb/Entities/TvShow.cs +++ b/src/Infrastructure.MongoDb/Entities/TvShow.cs @@ -32,6 +32,12 @@ public class TvShow : IHasIdAndOwnerId [BsonElement("reference_id")] public string? ReferenceId { get; set; } + [BsonElement("reference_rating")] + public double? ReferenceRating { get; set; } + + [BsonElement("reference_rating_scale")] + public double? ReferenceRatingScale { get; set; } + // storage name kept as "status" deliberately - only the C# property was renamed to State (for parity // with VideoGame.State), so existing documents need no migration. [BsonElement("status")] diff --git a/src/Infrastructure.MongoDb/Entities/TvShowReference.cs b/src/Infrastructure.MongoDb/Entities/TvShowReference.cs index fab4cc8b..ec2f300f 100644 --- a/src/Infrastructure.MongoDb/Entities/TvShowReference.cs +++ b/src/Infrastructure.MongoDb/Entities/TvShowReference.cs @@ -36,6 +36,9 @@ public class TvShowReference public List Cast { get; set; } = []; + /// Aggregate ratings keyed by source name (e.g. "tmdb") - see . + public Dictionary Ratings { get; set; } = []; + [BsonElement("image_url")] public string? ImageUrl { get; set; } diff --git a/src/Infrastructure.MongoDb/Entities/VideoGame.cs b/src/Infrastructure.MongoDb/Entities/VideoGame.cs index 66b3c483..03dd4722 100644 --- a/src/Infrastructure.MongoDb/Entities/VideoGame.cs +++ b/src/Infrastructure.MongoDb/Entities/VideoGame.cs @@ -27,6 +27,12 @@ public class VideoGame : IHasIdAndOwnerId [BsonElement("reference_id")] public string? ReferenceId { get; set; } + [BsonElement("reference_rating")] + public double? ReferenceRating { get; set; } + + [BsonElement("reference_rating_scale")] + public double? ReferenceRatingScale { get; set; } + [BsonElement("custom_image_url")] public string? CustomImageUrl { get; set; } diff --git a/src/Infrastructure.MongoDb/Entities/VideoGameReference.cs b/src/Infrastructure.MongoDb/Entities/VideoGameReference.cs index 45bab635..112822a1 100644 --- a/src/Infrastructure.MongoDb/Entities/VideoGameReference.cs +++ b/src/Infrastructure.MongoDb/Entities/VideoGameReference.cs @@ -34,6 +34,9 @@ public class VideoGameReference public List Genres { get; set; } = []; + /// Aggregate ratings keyed by source name ("rawg", "metacritic") - see . + public Dictionary Ratings { get; set; } = []; + [BsonElement("image_url")] public string? ImageUrl { get; set; } diff --git a/src/Infrastructure.MongoDb/Repositories/AlbumRepository.cs b/src/Infrastructure.MongoDb/Repositories/AlbumRepository.cs index 8d3ef373..c3e2012a 100644 --- a/src/Infrastructure.MongoDb/Repositories/AlbumRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/AlbumRepository.cs @@ -23,6 +23,8 @@ public class AlbumRepository(IMongoDatabase mongoDatabase, ILogger> SortRatingField => x => x.Rating!; + protected override Expression> SortReferenceRatingField => x => x.ReferenceRating!; + protected override FilterDefinition GetFilter(string ownerId, string? search, AlbumModel input) { var builder = Builders.Filter; @@ -35,14 +37,15 @@ protected override FilterDefinition GetFilter(string ownerId, string? sea return filter; } - public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalArtist = null, string? canonicalGenre = null) + public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalArtist = null, string? canonicalGenre = null, double? canonicalRating = null, double? canonicalRatingScale = null) { var builder = Builders.Filter; var filter = builder.Regex(f => f.Title, new BsonRegularExpression($"^{Regex.Escape(title)}$", "i")) & builder.Eq(f => f.Year, year) & UnresolvedFilter(); - var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle); + var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle) + .Set(f => f.ReferenceRating, canonicalRating).Set(f => f.ReferenceRatingScale, canonicalRatingScale); if (canonicalYear is not null) update = update.Set(f => f.Year, canonicalYear); if (canonicalArtist is not null) update = update.Set(f => f.Artist, canonicalArtist); if (canonicalGenre is not null) update = update.Set(f => f.Genre, canonicalGenre); @@ -50,6 +53,14 @@ public async Task SetReferenceLinkAsync(string title, int? year, string re return result.ModifiedCount; } + public async Task SetReferenceRatingAsync(string referenceId, double? rating, double? ratingScale) + { + var filter = Builders.Filter.Eq(f => f.ReferenceId, referenceId); + var update = Builders.Update.Set(f => f.ReferenceRating, rating).Set(f => f.ReferenceRatingScale, ratingScale); + var result = await GetCollection().UpdateManyAsync(filter, update); + return result.ModifiedCount; + } + public async Task> FindDistinctUnresolvedTitleYearsAsync() { // any one tenant's artist works as the queue entry's creator - it only prefills the admin's diff --git a/src/Infrastructure.MongoDb/Repositories/BookRepository.cs b/src/Infrastructure.MongoDb/Repositories/BookRepository.cs index 16d23df3..30ff8eb9 100644 --- a/src/Infrastructure.MongoDb/Repositories/BookRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/BookRepository.cs @@ -25,6 +25,8 @@ public class BookRepository(IMongoDatabase mongoDatabase, ILogger> SortSecondaryDateField => x => x.FirstReadAt!; + protected override Expression> SortReferenceRatingField => x => x.ReferenceRating!; + protected override FilterDefinition GetFilter(string ownerId, string? search, BookModel input) { var builder = Builders.Filter; @@ -43,14 +45,15 @@ protected override FilterDefinition GetFilter(string ownerId, string? sear } public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, string? canonicalAuthor = null, string? canonicalGenre = null, - string? canonicalLanguage = null, string? canonicalIsbn = null) + string? canonicalLanguage = null, string? canonicalIsbn = null, double? canonicalRating = null, double? canonicalRatingScale = null) { var builder = Builders.Filter; var filter = builder.Regex(f => f.Title, new BsonRegularExpression($"^{Regex.Escape(title)}$", "i")) & builder.Eq(f => f.Year, year) & UnresolvedFilter(); - var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle); + var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle) + .Set(f => f.ReferenceRating, canonicalRating).Set(f => f.ReferenceRatingScale, canonicalRatingScale); if (canonicalYear is not null) update = update.Set(f => f.Year, canonicalYear); if (canonicalAuthor is not null) update = update.Set(f => f.Author, canonicalAuthor); if (canonicalGenre is not null) update = update.Set(f => f.Genre, canonicalGenre); @@ -60,6 +63,14 @@ public async Task SetReferenceLinkAsync(string title, int? year, string re return result.ModifiedCount; } + public async Task SetReferenceRatingAsync(string referenceId, double? rating, double? ratingScale) + { + var filter = Builders.Filter.Eq(f => f.ReferenceId, referenceId); + var update = Builders.Update.Set(f => f.ReferenceRating, rating).Set(f => f.ReferenceRatingScale, ratingScale); + var result = await GetCollection().UpdateManyAsync(filter, update); + return result.ModifiedCount; + } + public async Task> FindDistinctUnresolvedTitleYearsAsync() { // any one tenant's author/ISBN works as the queue entry's creator/isbn - both only prefill the diff --git a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs index ddde4641..64bf8c3d 100644 --- a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs @@ -23,6 +23,8 @@ public class TvShowRepository(IMongoDatabase mongoDatabase, ILogger> SortRatingField => x => x.Rating!; + protected override Expression> SortReferenceRatingField => x => x.ReferenceRating!; + protected override FilterDefinition GetFilter(string ownerId, string? search, TvShowModel input) { var builder = Builders.Filter; @@ -38,19 +40,28 @@ protected override FilterDefinition GetFilter(string ownerId, string? se return filter; } - public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null) + public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, double? canonicalRating = null, double? canonicalRatingScale = null) { var builder = Builders.Filter; var filter = builder.Regex(f => f.Title, new BsonRegularExpression($"^{Regex.Escape(title)}$", "i")) & builder.Eq(f => f.Year, year) & UnresolvedFilter(); - var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle); + var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle) + .Set(f => f.ReferenceRating, canonicalRating).Set(f => f.ReferenceRatingScale, canonicalRatingScale); if (canonicalYear is not null) update = update.Set(f => f.Year, canonicalYear); var result = await GetCollection().UpdateManyAsync(filter, update); return result.ModifiedCount; } + public async Task SetReferenceRatingAsync(string referenceId, double? rating, double? ratingScale) + { + var filter = Builders.Filter.Eq(f => f.ReferenceId, referenceId); + var update = Builders.Update.Set(f => f.ReferenceRating, rating).Set(f => f.ReferenceRatingScale, ratingScale); + var result = await GetCollection().UpdateManyAsync(filter, update); + return result.ModifiedCount; + } + public async Task> FindFinishedLinkedShowsAsync() { var builder = Builders.Filter; diff --git a/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs b/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs index 61eddd48..15e87646 100644 --- a/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs @@ -24,6 +24,8 @@ public class VideoGameRepository(IMongoDatabase mongoDatabase, ILogger> SortRatingField => x => x.Rating!; + protected override Expression> SortReferenceRatingField => x => x.ReferenceRating!; + /// /// "Last completed" needs the max CompletedAt across a game's /// array, not a single scalar field, so it can't use the shared SortSecondaryDateField hook. @@ -52,19 +54,28 @@ protected override FilterDefinition GetFilter(string ownerId, string? return filter; } - public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null) + public async Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, double? canonicalRating = null, double? canonicalRatingScale = null) { var builder = Builders.Filter; var filter = builder.Regex(f => f.Title, new BsonRegularExpression($"^{Regex.Escape(title)}$", "i")) & builder.Eq(f => f.Year, year) & UnresolvedFilter(); - var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle); + var update = Builders.Update.Set(f => f.ReferenceId, referenceId).Set(f => f.Title, canonicalTitle) + .Set(f => f.ReferenceRating, canonicalRating).Set(f => f.ReferenceRatingScale, canonicalRatingScale); if (canonicalYear is not null) update = update.Set(f => f.Year, canonicalYear); var result = await GetCollection().UpdateManyAsync(filter, update); return result.ModifiedCount; } + public async Task SetReferenceRatingAsync(string referenceId, double? rating, double? ratingScale) + { + var filter = Builders.Filter.Eq(f => f.ReferenceId, referenceId); + var update = Builders.Update.Set(f => f.ReferenceRating, rating).Set(f => f.ReferenceRatingScale, ratingScale); + var result = await GetCollection().UpdateManyAsync(filter, update); + return result.ModifiedCount; + } + public async Task> FindDistinctUnresolvedTitleYearsAsync() { var groups = await GetCollection().Aggregate() diff --git a/src/WebApi.Contracts/Dto/AlbumDto.cs b/src/WebApi.Contracts/Dto/AlbumDto.cs index 882de0cf..f985f6bb 100644 --- a/src/WebApi.Contracts/Dto/AlbumDto.cs +++ b/src/WebApi.Contracts/Dto/AlbumDto.cs @@ -52,6 +52,16 @@ public class AlbumDto : IHasId, IReferenceLinkedDto /// public string? ImageUrl { get; set; } + /// + /// Denormalized primary-source (Discogs, 0-5) rating from the linked reference, on . + /// Server-managed on link/refresh, round-tripped on edits; null until linked. Full breakdown on + /// . + /// + public double? ReferenceRating { get; set; } + + /// Scale of (5 for Discogs); null when there is no reference rating. + public double? ReferenceRatingScale { get; set; } + /// /// Tenant-owned cover image override, freely editable - takes priority over the linked reference's /// cover wherever one is shown. Null means "use the reference's cover, if any". diff --git a/src/WebApi.Contracts/Dto/AlbumReferenceDto.cs b/src/WebApi.Contracts/Dto/AlbumReferenceDto.cs index 71ab3137..13fb835c 100644 --- a/src/WebApi.Contracts/Dto/AlbumReferenceDto.cs +++ b/src/WebApi.Contracts/Dto/AlbumReferenceDto.cs @@ -25,5 +25,8 @@ public class AlbumReferenceDto : IHasId public List Tracks { get; set; } = []; + /// Aggregate ratings keyed by source name ("discogs") - see . + public Dictionary Ratings { get; set; } = []; + public string? ImageUrl { get; set; } } diff --git a/src/WebApi.Contracts/Dto/BookDto.cs b/src/WebApi.Contracts/Dto/BookDto.cs index dc6ca34c..8d0cf5cb 100644 --- a/src/WebApi.Contracts/Dto/BookDto.cs +++ b/src/WebApi.Contracts/Dto/BookDto.cs @@ -73,6 +73,16 @@ public class BookDto : IHasId, IReferenceLinkedDto /// public string? ImageUrl { get; set; } + /// + /// Denormalized primary-source (the linking book provider's, 0-5) rating from the linked reference, on + /// . Server-managed on link/refresh, round-tripped on edits; null until + /// linked. Full breakdown on . + /// + public double? ReferenceRating { get; set; } + + /// Scale of (5 for the current book providers); null when there is no reference rating. + public double? ReferenceRatingScale { get; set; } + /// /// Tenant-owned cover image override, freely editable - takes priority over the linked reference's /// cover wherever one is shown. Null means "use the reference's cover, if any". diff --git a/src/WebApi.Contracts/Dto/BookReferenceDto.cs b/src/WebApi.Contracts/Dto/BookReferenceDto.cs index c9099497..d92db752 100644 --- a/src/WebApi.Contracts/Dto/BookReferenceDto.cs +++ b/src/WebApi.Contracts/Dto/BookReferenceDto.cs @@ -23,6 +23,9 @@ public class BookReferenceDto : IHasId public List Genres { get; set; } = []; + /// Aggregate ratings keyed by provider key ("googlebooks"/"openlibrary") - see . + public Dictionary Ratings { get; set; } = []; + public string? ImageUrl { get; set; } /// The book's language, when the linking provider reports one. diff --git a/src/WebApi.Contracts/Dto/TvShowDto.cs b/src/WebApi.Contracts/Dto/TvShowDto.cs index b66413ee..585f63ae 100644 --- a/src/WebApi.Contracts/Dto/TvShowDto.cs +++ b/src/WebApi.Contracts/Dto/TvShowDto.cs @@ -43,6 +43,16 @@ public class TvShowDto : IHasId, IReferenceLinkedDto /// public string? ImageUrl { get; set; } + /// + /// Denormalized primary-source (TMDB) rating from the linked reference, on . + /// Server-managed on link/refresh, round-tripped on edits; null until linked. Full breakdown on + /// . + /// + public double? ReferenceRating { get; set; } + + /// Scale of (10 for TMDB); null when there is no reference rating. + public double? ReferenceRatingScale { get; set; } + public TvShowStatus? State { get; set; } public bool IsFavorite { get; set; } diff --git a/src/WebApi.Contracts/Dto/TvShowReferenceDto.cs b/src/WebApi.Contracts/Dto/TvShowReferenceDto.cs index cff2afc0..7137b3c4 100644 --- a/src/WebApi.Contracts/Dto/TvShowReferenceDto.cs +++ b/src/WebApi.Contracts/Dto/TvShowReferenceDto.cs @@ -23,5 +23,8 @@ public class TvShowReferenceDto : IHasId public List Cast { get; set; } = []; + /// Aggregate ratings keyed by source name (e.g. "tmdb") - see . + public Dictionary Ratings { get; set; } = []; + public string? ImageUrl { get; set; } } diff --git a/src/WebApi.Contracts/Dto/VideoGameDto.cs b/src/WebApi.Contracts/Dto/VideoGameDto.cs index f76141a9..7b10e932 100644 --- a/src/WebApi.Contracts/Dto/VideoGameDto.cs +++ b/src/WebApi.Contracts/Dto/VideoGameDto.cs @@ -41,6 +41,16 @@ public class VideoGameDto : IHasId, IReferenceLinkedDto /// public string? ImageUrl { get; set; } + /// + /// Denormalized primary-source (RAWG, 0-5) rating from the linked reference, on . + /// Server-managed on link/refresh, round-tripped on edits; null until linked. Full breakdown (incl. Metacritic) + /// on . + /// + public double? ReferenceRating { get; set; } + + /// Scale of (5 for RAWG); null when there is no reference rating. + public double? ReferenceRatingScale { get; set; } + /// /// Tenant-owned cover image override, freely editable - takes priority over the linked reference's /// cover wherever one is shown. Null means "use the reference's cover, if any". diff --git a/src/WebApi.Contracts/Dto/VideoGameReferenceDto.cs b/src/WebApi.Contracts/Dto/VideoGameReferenceDto.cs index f8cb4880..7247a547 100644 --- a/src/WebApi.Contracts/Dto/VideoGameReferenceDto.cs +++ b/src/WebApi.Contracts/Dto/VideoGameReferenceDto.cs @@ -21,5 +21,8 @@ public class VideoGameReferenceDto : IHasId public List Genres { get; set; } = []; + /// Aggregate ratings keyed by source name ("rawg", "metacritic") - see . + public Dictionary Ratings { get; set; } = []; + public string? ImageUrl { get; set; } } diff --git a/src/WebApi/ReferenceData/DiscogsClient.cs b/src/WebApi/ReferenceData/DiscogsClient.cs index dccf0583..18eda5bc 100644 --- a/src/WebApi/ReferenceData/DiscogsClient.cs +++ b/src/WebApi/ReferenceData/DiscogsClient.cs @@ -56,9 +56,24 @@ private async Task> SearchAlbumsCoreAsync(str .Select(t => new DiscogsTrack(t.Position ?? "", t.Title ?? "", t.Duration)) .ToList(); + // A Discogs community rating lives on an individual *release*, not the master grouping we search/fetch, + // so fetch the master's canonical main_release to read it. Best-effort: a missing release or rating + // just leaves the album without a reference rating rather than failing the whole resolve. + var (rating, ratingCount) = await GetCommunityRatingAsync(details.MainRelease, cancellationToken); + return new DiscogsAlbumDetails( externalId, details.Title ?? string.Empty, details.Year, details.Notes, - primaryArtist?.Name, primaryArtist?.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), genres, image, tracks); + primaryArtist?.Name, primaryArtist?.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), genres, image, tracks, + rating, ratingCount); + } + + private async Task<(double? Rating, int? Count)> GetCommunityRatingAsync(int? mainReleaseId, CancellationToken cancellationToken) + { + if (mainReleaseId is null or 0) return (null, null); + var release = await http.GetFromJsonAsync($"releases/{mainReleaseId}?token={Token}", cancellationToken); + var rating = release?.Community?.Rating; + // Discogs reports average 0 / count 0 for an unrated release - treat that as "no rating", not a real 0. + return rating is { Average: > 0, Count: > 0 } ? (rating.Average, rating.Count) : (null, null); } private string Token => settings.Token; @@ -103,6 +118,9 @@ private sealed class DiscogsSearchItem private sealed class DiscogsMasterResponse { + [JsonPropertyName("main_release")] + public int? MainRelease { get; set; } + [JsonPropertyName("title")] public string? Title { get; set; } @@ -153,6 +171,28 @@ private sealed class DiscogsImage public string? Uri { get; set; } } + /// The individual-release resource (fetched via a master's main_release) - the only place Discogs exposes a community rating. + private sealed class DiscogsReleaseResponse + { + [JsonPropertyName("community")] + public DiscogsCommunity? Community { get; set; } + } + + private sealed class DiscogsCommunity + { + [JsonPropertyName("rating")] + public DiscogsRating? Rating { get; set; } + } + + private sealed class DiscogsRating + { + [JsonPropertyName("average")] + public double Average { get; set; } + + [JsonPropertyName("count")] + public int Count { get; set; } + } + private sealed class DiscogsArtist { [JsonPropertyName("id")] diff --git a/src/WebApi/ReferenceData/GoogleBooksClient.cs b/src/WebApi/ReferenceData/GoogleBooksClient.cs index 7c1b388d..5d514d45 100644 --- a/src/WebApi/ReferenceData/GoogleBooksClient.cs +++ b/src/WebApi/ReferenceData/GoogleBooksClient.cs @@ -53,7 +53,7 @@ public async Task> SearchBooksAsync(string title /// /// Same idea as , restricted to what reads. /// - private const string DetailsFields = "fields=volumeInfo(title,publishedDate,description,authors,categories,imageLinks/thumbnail,language,industryIdentifiers)"; + private const string DetailsFields = "fields=volumeInfo(title,publishedDate,description,authors,categories,imageLinks/thumbnail,language,industryIdentifiers,averageRating,ratingsCount)"; private async Task> SearchBooksCoreAsync(string query, CancellationToken cancellationToken) { @@ -83,7 +83,9 @@ private async Task> SearchBooksCoreAsync(string info.Categories.Take(MaxGenres).ToList(), BuildImageUrl(info.ImageLinks), info.Language, - ExtractIsbn(info.IndustryIdentifiers)); + ExtractIsbn(info.IndustryIdentifiers), + info.AverageRating, + info.RatingsCount); } private static string BuildQuery(string title, string? author) @@ -213,6 +215,12 @@ private sealed class GoogleBooksVolumeInfo [JsonPropertyName("language")] public string? Language { get; set; } + [JsonPropertyName("averageRating")] + public double? AverageRating { get; set; } + + [JsonPropertyName("ratingsCount")] + public int? RatingsCount { get; set; } + [JsonPropertyName("imageLinks")] public GoogleBooksImageLinks? ImageLinks { get; set; } diff --git a/src/WebApi/ReferenceData/IBookReferenceClient.cs b/src/WebApi/ReferenceData/IBookReferenceClient.cs index af25869d..55c42566 100644 --- a/src/WebApi/ReferenceData/IBookReferenceClient.cs +++ b/src/WebApi/ReferenceData/IBookReferenceClient.cs @@ -6,7 +6,7 @@ namespace Keeptrack.WebApi.ReferenceData; /// public record BookSearchResult(string ExternalId, string Title, int? Year, string? Author, string? ImageUrl); -public record BookDetails(string ExternalId, string Title, int? Year, string? Synopsis, string? Author, string? AuthorExternalId, List Genres, string? ImageUrl, string? Language = null, string? Isbn = null); +public record BookDetails(string ExternalId, string Title, int? Year, string? Synopsis, string? Author, string? AuthorExternalId, List Genres, string? ImageUrl, string? Language = null, string? Isbn = null, double? Rating = null, int? RatingCount = null); /// /// Provider-agnostic book lookup, backing 's book resolution/refresh diff --git a/src/WebApi/ReferenceData/IDiscogsClient.cs b/src/WebApi/ReferenceData/IDiscogsClient.cs index 838bd5d0..307b78c4 100644 --- a/src/WebApi/ReferenceData/IDiscogsClient.cs +++ b/src/WebApi/ReferenceData/IDiscogsClient.cs @@ -6,7 +6,7 @@ namespace Keeptrack.WebApi.ReferenceData; /// public record DiscogsSearchResult(string ExternalId, string Title, int? Year, string? Artist, string? ImageUrl); -public record DiscogsAlbumDetails(string ExternalId, string Title, int? Year, string? Synopsis, string? Artist, string? ArtistExternalId, List Genres, string? ImageUrl, List Tracks); +public record DiscogsAlbumDetails(string ExternalId, string Title, int? Year, string? Synopsis, string? Artist, string? ArtistExternalId, List Genres, string? ImageUrl, List Tracks, double? Rating = null, int? RatingCount = null); /// /// One tracklist entry from Discogs' /masters/{id} response - isn't diff --git a/src/WebApi/ReferenceData/IRawgClient.cs b/src/WebApi/ReferenceData/IRawgClient.cs index 788d501c..dc13599c 100644 --- a/src/WebApi/ReferenceData/IRawgClient.cs +++ b/src/WebApi/ReferenceData/IRawgClient.cs @@ -6,7 +6,7 @@ namespace Keeptrack.WebApi.ReferenceData; /// public record RawgSearchResult(string ExternalId, string Title, int? Year, string? ImageUrl); -public record RawgGameDetails(string ExternalId, string Title, int? Year, string? Synopsis, List Genres, List Platforms, string? ImageUrl); +public record RawgGameDetails(string ExternalId, string Title, int? Year, string? Synopsis, List Genres, List Platforms, string? ImageUrl, double? Rating = null, int? RatingsCount = null, int? Metacritic = null); /// /// Thin wrapper over the RAWG Video Games Database REST API. Interface exists so tests use a fake - diff --git a/src/WebApi/ReferenceData/ITmdbClient.cs b/src/WebApi/ReferenceData/ITmdbClient.cs index 7585c7c3..c97d00da 100644 --- a/src/WebApi/ReferenceData/ITmdbClient.cs +++ b/src/WebApi/ReferenceData/ITmdbClient.cs @@ -8,7 +8,7 @@ public record TmdbSearchResult(string TmdbId, string Title, int? Year, string? S public record TmdbEpisode(int SeasonNumber, int EpisodeNumber, string Title, DateOnly? AirDate); -public record TmdbTvShowDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Episodes, List Genres, string? PosterUrl); +public record TmdbTvShowDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Episodes, List Genres, string? PosterUrl, double? VoteAverage = null, int? VoteCount = null); public record TmdbMovieDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Genres, string? PosterUrl, double? VoteAverage, int? VoteCount); diff --git a/src/WebApi/ReferenceData/OpenLibraryClient.cs b/src/WebApi/ReferenceData/OpenLibraryClient.cs index 20b5467d..690c105c 100644 --- a/src/WebApi/ReferenceData/OpenLibraryClient.cs +++ b/src/WebApi/ReferenceData/OpenLibraryClient.cs @@ -76,6 +76,7 @@ private async Task> SearchBooksCoreAsync(string } var year = ParseYear(work.FirstPublishDate) ?? await FindPublishYearViaSearchAsync(externalId, cancellationToken); + var (rating, ratingCount) = await GetRatingAsync(externalId, cancellationToken); return new BookDetails( externalId, @@ -85,7 +86,21 @@ private async Task> SearchBooksCoreAsync(string authorName, authorExternalId, work.Subjects.Take(MaxGenres).ToList(), - BuildCoverUrl(work.Covers.FirstOrDefault())); + BuildCoverUrl(work.Covers.FirstOrDefault()), + Rating: rating, + RatingCount: ratingCount); + } + + /// + /// Open Library exposes a work's aggregate rating on a dedicated /works/{id}/ratings.json endpoint + /// (not on the work document itself) - one extra call, best-effort: a missing summary just leaves the + /// book without a reference rating. A 0/absent average is treated as "no rating", not a real zero. + /// + private async Task<(double? Rating, int? Count)> GetRatingAsync(string workKey, CancellationToken cancellationToken) + { + var response = await http.GetFromJsonAsync($"{workKey}/ratings.json", cancellationToken); + var summary = response?.Summary; + return summary is { Average: > 0, Count: > 0 } ? (summary.Average, summary.Count) : (null, null); } /// @@ -204,4 +219,19 @@ private sealed class OpenLibraryAuthorResponse [JsonPropertyName("name")] public string? Name { get; set; } } + + private sealed class OpenLibraryRatingsResponse + { + [JsonPropertyName("summary")] + public OpenLibraryRatingsSummary? Summary { get; set; } + } + + private sealed class OpenLibraryRatingsSummary + { + [JsonPropertyName("average")] + public double? Average { get; set; } + + [JsonPropertyName("count")] + public int Count { get; set; } + } } diff --git a/src/WebApi/ReferenceData/RawgClient.cs b/src/WebApi/ReferenceData/RawgClient.cs index e676fd6b..30ad1757 100644 --- a/src/WebApi/ReferenceData/RawgClient.cs +++ b/src/WebApi/ReferenceData/RawgClient.cs @@ -27,7 +27,7 @@ public async Task> SearchGamesAsync(string title externalId, details.Name ?? string.Empty, ParseYear(details.Released), details.DescriptionRaw, details.Genres.Select(g => g.Name).ToList(), details.Platforms.Select(p => p.Platform?.Name).OfType().ToList(), - details.BackgroundImage); + details.BackgroundImage, details.Rating, details.RatingsCount, details.Metacritic); } private const int MaxResults = 5; @@ -74,6 +74,15 @@ private sealed class RawgGameDetailsResponse [JsonPropertyName("background_image")] public string? BackgroundImage { get; set; } + [JsonPropertyName("rating")] + public double? Rating { get; set; } + + [JsonPropertyName("ratings_count")] + public int? RatingsCount { get; set; } + + [JsonPropertyName("metacritic")] + public int? Metacritic { get; set; } + [JsonPropertyName("genres")] public List Genres { get; set; } = []; diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs index 418c6f0e..98235bb4 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs @@ -7,6 +7,17 @@ public partial class ReferenceEnrichmentService { private const string DiscogsProviderKey = "discogs"; + /// Builds the reference Ratings map from Discogs' 0-5 community rating; a 0/absent value is omitted, not stored as a real zero. + private static Dictionary BuildDiscogsRatings(double? rating, int? ratingCount) + { + var ratings = new Dictionary(); + if (rating is > 0) + { + ratings[DiscogsProviderKey] = new ReferenceRatingModel { Value = rating.Value, Scale = 5, Count = ratingCount }; + } + return ratings; + } + /// /// User-triggered "check for reference match" for albums - see /// for the full rationale (this is the same local-only, @@ -34,6 +45,8 @@ public async Task TryLinkExistingAlbumReferenceAsync(AlbumModel mode if (!string.IsNullOrEmpty(model.ReferenceId)) { model.ReferenceId = string.Empty; + model.ReferenceRating = null; + model.ReferenceRatingScale = null; await albumRepository.UpdateAsync(model.Id!, model, model.OwnerId); } @@ -44,14 +57,17 @@ public async Task TryLinkExistingAlbumReferenceAsync(AlbumModel mode var originalYear = model.Year; var artistName = await ResolvePersonNameAsync(reference.ArtistReferenceId); var genre = JoinGenres(reference.Genres); + var (ratingValue, ratingScale) = PrimaryRating(reference.Ratings, DiscogsProviderKey); model.ReferenceId = reference.Id; model.Title = reference.Title; if (reference.Year is not null) model.Year = reference.Year; if (!string.IsNullOrEmpty(artistName)) model.Artist = artistName; if (genre is not null) model.Genre = genre; + model.ReferenceRating = ratingValue; + model.ReferenceRatingScale = ratingScale; await albumRepository.UpdateAsync(model.Id!, model, model.OwnerId); - await albumRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year, artistName, genre); + await albumRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year, artistName, genre, ratingValue, ratingScale); return model; } @@ -65,6 +81,8 @@ public async Task UnlinkAlbumReferenceAsync(AlbumModel model) { var referenceId = model.ReferenceId; model.ReferenceId = string.Empty; + model.ReferenceRating = null; + model.ReferenceRatingScale = null; await albumRepository.UpdateAsync(model.Id!, model, model.OwnerId); if (!string.IsNullOrEmpty(referenceId)) { @@ -127,12 +145,14 @@ public async Task ResolveAlbumAsync(string title, int? year MatchedAliases = MergeMatchedAliases(existing?.MatchedAliases, (details.Title, details.Year ?? year, details.Artist, null), (title, year, details.Artist, null)), Genres = details.Genres, Tracks = MapTracks(details.Tracks), + Ratings = BuildDiscogsRatings(details.Rating, details.RatingCount), ImageUrl = details.ImageUrl, LastEnrichedAt = DateTime.UtcNow }; var saved = await albumReferenceRepository.UpsertAsync(model); - await albumRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, details.Artist, JoinGenres(details.Genres)); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, DiscogsProviderKey); + await albumRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, details.Artist, JoinGenres(details.Genres), ratingValue, ratingScale); return saved; } @@ -159,11 +179,15 @@ public async Task ResolveAlbumAsync(string title, int? year } reference.Genres = details.Genres; reference.Tracks = MapTracks(details.Tracks); + reference.Ratings = BuildDiscogsRatings(details.Rating, details.RatingCount); reference.ImageUrl = details.ImageUrl ?? reference.ImageUrl; reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, details.Artist, null)); reference.LastEnrichedAt = DateTime.UtcNow; - return (await albumReferenceRepository.UpsertAsync(reference), true); + var saved = await albumReferenceRepository.UpsertAsync(reference); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, DiscogsProviderKey); + await albumRepository.SetReferenceRatingAsync(saved.Id!, ratingValue, ratingScale); + return (saved, true); } private static List MapTracks(List tracks) => diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs index 6d402d15..0a1f956d 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs @@ -5,6 +5,26 @@ namespace Keeptrack.WebApi.ReferenceData; public partial class ReferenceEnrichmentService { + /// + /// Builds the reference Ratings map for a book, keyed by the provider that resolved it + /// (a book links through exactly one provider, so the map holds at most one entry - that provider is + /// therefore the primary). Google Books and Open Library both report a 0-5 average; BnF reports none. + /// A 0/absent value is omitted, never stored as a real zero. + /// + private static Dictionary BuildBookRatings(string providerKey, double? rating, int? ratingCount) + { + var ratings = new Dictionary(); + if (rating is > 0) + { + ratings[providerKey] = new ReferenceRatingModel { Value = rating.Value, Scale = 5, Count = ratingCount }; + } + return ratings; + } + + /// The book's single stored rating (from whichever provider linked it), or (null, null) when it has none. + private static (double? Value, double? Scale) BookPrimaryRating(BookReferenceModel reference) => + reference.Ratings.Count == 0 ? (null, null) : PrimaryRating(reference.Ratings, reference.Ratings.Keys.First()); + /// /// User-triggered "check for reference match" for books - see /// for the full rationale (this is the same local-only, @@ -33,6 +53,8 @@ public async Task TryLinkExistingBookReferenceAsync(BookModel model) if (!string.IsNullOrEmpty(model.ReferenceId)) { model.ReferenceId = string.Empty; + model.ReferenceRating = null; + model.ReferenceRatingScale = null; await bookRepository.UpdateAsync(model.Id!, model, model.OwnerId); } @@ -43,6 +65,7 @@ public async Task TryLinkExistingBookReferenceAsync(BookModel model) var originalYear = model.Year; var authorName = await ResolvePersonNameAsync(reference.AuthorReferenceId); var genre = JoinGenres(reference.Genres); + var (ratingValue, ratingScale) = BookPrimaryRating(reference); model.ReferenceId = reference.Id; model.Title = reference.Title; @@ -51,8 +74,10 @@ public async Task TryLinkExistingBookReferenceAsync(BookModel model) if (genre is not null) model.Genre = genre; if (reference.Language is not null) model.Language = reference.Language; if (reference.Isbn is not null) model.Isbn = reference.Isbn; + model.ReferenceRating = ratingValue; + model.ReferenceRatingScale = ratingScale; await bookRepository.UpdateAsync(model.Id!, model, model.OwnerId); - await bookRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year, authorName, genre, reference.Language, reference.Isbn); + await bookRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year, authorName, genre, reference.Language, reference.Isbn, ratingValue, ratingScale); return model; } @@ -66,6 +91,8 @@ public async Task UnlinkBookReferenceAsync(BookModel model) { var referenceId = model.ReferenceId; model.ReferenceId = string.Empty; + model.ReferenceRating = null; + model.ReferenceRatingScale = null; await bookRepository.UpdateAsync(model.Id!, model, model.OwnerId); if (!string.IsNullOrEmpty(referenceId)) { @@ -143,6 +170,7 @@ public async Task ResolveBookAsync(string title, int? year, (details.Title, details.Year ?? year, details.Author, details.Isbn), (title, year, details.Author, isbn)), Genres = details.Genres, + Ratings = BuildBookRatings(client.ProviderKey, details.Rating, details.RatingCount), ImageUrl = details.ImageUrl, Language = details.Language ?? existing?.Language, Isbn = details.Isbn ?? existing?.Isbn, @@ -150,7 +178,8 @@ public async Task ResolveBookAsync(string title, int? year, }; var saved = await bookReferenceRepository.UpsertAsync(model); - await bookRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, details.Author, JoinGenres(details.Genres), details.Language, details.Isbn); + var (ratingValue, ratingScale) = BookPrimaryRating(saved); + await bookRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, details.Author, JoinGenres(details.Genres), details.Language, details.Isbn, ratingValue, ratingScale); return saved; } @@ -183,12 +212,16 @@ public async Task ResolveBookAsync(string title, int? year, reference.AuthorReferenceId = await ResolvePersonReferenceIdAsync(client.ProviderKey, details.AuthorExternalId, details.Author ?? "Unknown", null); } reference.Genres = details.Genres; + reference.Ratings = BuildBookRatings(client.ProviderKey, details.Rating, details.RatingCount); reference.ImageUrl = details.ImageUrl ?? reference.ImageUrl; reference.Language = details.Language ?? reference.Language; reference.Isbn = details.Isbn ?? reference.Isbn; reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, details.Author, details.Isbn)); reference.LastEnrichedAt = DateTime.UtcNow; - return (await bookReferenceRepository.UpsertAsync(reference), true); + var saved = await bookReferenceRepository.UpsertAsync(reference); + var (ratingValue, ratingScale) = BookPrimaryRating(saved); + await bookRepository.SetReferenceRatingAsync(saved.Id!, ratingValue, ratingScale); + return (saved, true); } } diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs index a9748fd4..5d25304f 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs @@ -33,9 +33,13 @@ private static Dictionary BuildTmdbRatings(double? return ratings; } - /// The (value, scale) to denormalize onto tenant items - the primary source's, or (null, null) when it has none. - private static (double? Value, double? Scale) PrimaryRating(IReadOnlyDictionary ratings) => - ratings.TryGetValue(PrimaryRatingSource, out var r) ? (r.Value, r.Scale) : (null, null); + /// + /// The (value, scale) to denormalize onto tenant items - the given primary source's, or (null, null) + /// when it has none. Shared across every domain (video games/albums/books pass their own primary key); + /// which source is primary is a per-domain code default for now (see CLAUDE.md). + /// + private static (double? Value, double? Scale) PrimaryRating(IReadOnlyDictionary ratings, string source) => + ratings.TryGetValue(source, out var r) ? (r.Value, r.Scale) : (null, null); /// /// User-triggered "check for reference match" - looks only at the local reference collection (title+year, @@ -78,6 +82,8 @@ public async Task TryLinkExistingTvShowReferenceAsync(TvShowModel m if (!string.IsNullOrEmpty(model.ReferenceId)) { model.ReferenceId = string.Empty; + model.ReferenceRating = null; + model.ReferenceRatingScale = null; await tvShowRepository.UpdateAsync(model.Id!, model, model.OwnerId); } @@ -86,12 +92,15 @@ public async Task TryLinkExistingTvShowReferenceAsync(TvShowModel m var originalTitle = model.Title; var originalYear = model.Year; + var (ratingValue, ratingScale) = PrimaryRating(reference.Ratings, PrimaryRatingSource); model.ReferenceId = reference.Id; model.Title = reference.Title; if (reference.Year is not null) model.Year = reference.Year; + model.ReferenceRating = ratingValue; + model.ReferenceRatingScale = ratingScale; await tvShowRepository.UpdateAsync(model.Id!, model, model.OwnerId); - await tvShowRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year); + await tvShowRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year, ratingValue, ratingScale); return model; } @@ -127,7 +136,7 @@ public async Task TryLinkExistingMovieReferenceAsync(MovieModel mode var originalTitle = model.Title; var originalYear = model.Year; - var (ratingValue, ratingScale) = PrimaryRating(reference.Ratings); + var (ratingValue, ratingScale) = PrimaryRating(reference.Ratings, PrimaryRatingSource); model.ReferenceId = reference.Id; model.Title = reference.Title; @@ -152,6 +161,8 @@ public async Task UnlinkTvShowReferenceAsync(TvShowModel model) { var referenceId = model.ReferenceId; model.ReferenceId = string.Empty; + model.ReferenceRating = null; + model.ReferenceRatingScale = null; await tvShowRepository.UpdateAsync(model.Id!, model, model.OwnerId); if (!string.IsNullOrEmpty(referenceId)) { @@ -250,12 +261,14 @@ public async Task ResolveTvShowAsync(string title, int? ye .ToList(), Genres = details.Genres, Cast = await ResolveCastAsync(cast), + Ratings = BuildTmdbRatings(details.VoteAverage, details.VoteCount), ImageUrl = details.PosterUrl, LastEnrichedAt = DateTime.UtcNow }; var saved = await tvShowReferenceRepository.UpsertAsync(model); - await tvShowRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, PrimaryRatingSource); + await tvShowRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, ratingValue, ratingScale); return saved; } @@ -304,7 +317,7 @@ public async Task ResolveMovieAsync(string title, int? year }; var saved = await movieReferenceRepository.UpsertAsync(model); - var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, PrimaryRatingSource); await movieRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, ratingValue, ratingScale); return saved; } @@ -320,7 +333,9 @@ public async Task ResolveMovieAsync(string title, int? year var tmdbId = reference.ExternalIds.GetValueOrDefault("tmdb"); if (string.IsNullOrEmpty(tmdbId)) return (reference, false); - if (reference.LastEnrichedAt is not null) + // see RefreshMovieReferenceAsync: force a full fetch while the reference has no ratings yet, so + // references linked before ratings existed backfill one instead of being skipped forever. + if (reference.LastEnrichedAt is not null && reference.Ratings.Count > 0) { var changed = await tmdbClient.HasTvShowChangedSinceAsync(tmdbId, reference.LastEnrichedAt.Value, cancellationToken); if (!changed) @@ -342,11 +357,15 @@ public async Task ResolveMovieAsync(string title, int? year .ToList(); reference.Genres = details.Genres; reference.Cast = await ResolveCastAsync(cast); + reference.Ratings = BuildTmdbRatings(details.VoteAverage, details.VoteCount); reference.ImageUrl = details.PosterUrl ?? reference.ImageUrl; reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, null, null)); reference.LastEnrichedAt = DateTime.UtcNow; - return (await tvShowReferenceRepository.UpsertAsync(reference), true); + var saved = await tvShowReferenceRepository.UpsertAsync(reference); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, PrimaryRatingSource); + await tvShowRepository.SetReferenceRatingAsync(saved.Id!, ratingValue, ratingScale); + return (saved, true); } /// @@ -387,7 +406,7 @@ public async Task ResolveMovieAsync(string title, int? year var saved = await movieReferenceRepository.UpsertAsync(reference); // keep every already-linked tenant movie's denormalized copy current with the refreshed rating - var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, PrimaryRatingSource); await movieRepository.SetReferenceRatingAsync(saved.Id!, ratingValue, ratingScale); return (saved, true); } diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs index eea65aff..e9dbf5bf 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs @@ -5,6 +5,31 @@ namespace Keeptrack.WebApi.ReferenceData; public partial class ReferenceEnrichmentService { + /// The source denormalized onto the tenant game as the primary (list/sort) value - RAWG's own 0-5 user score. + private const string RawgRatingSource = "rawg"; + + private const string MetacriticRatingSource = "metacritic"; + + /// + /// Builds the reference Ratings map from RAWG's aggregates: RAWG's own 0-5 user score (the + /// primary, usually present) plus Metacritic's 0-100 critic score as a second source when RAWG reports + /// one (frequently absent). A 0/absent value is treated as "no rating" and omitted, never stored as a + /// genuine zero. + /// + private static Dictionary BuildRawgRatings(double? rating, int? ratingsCount, int? metacritic) + { + var ratings = new Dictionary(); + if (rating is > 0) + { + ratings[RawgRatingSource] = new ReferenceRatingModel { Value = rating.Value, Scale = 5, Count = ratingsCount }; + } + if (metacritic is > 0) + { + ratings[MetacriticRatingSource] = new ReferenceRatingModel { Value = metacritic.Value, Scale = 100 }; + } + return ratings; + } + /// /// User-triggered "check for reference match" for video games - see /// for the full rationale (this is the same local-only, @@ -31,6 +56,8 @@ public async Task TryLinkExistingVideoGameReferenceAsync(VideoGa if (!string.IsNullOrEmpty(model.ReferenceId)) { model.ReferenceId = string.Empty; + model.ReferenceRating = null; + model.ReferenceRatingScale = null; await videoGameRepository.UpdateAsync(model.Id!, model, model.OwnerId); } @@ -39,12 +66,15 @@ public async Task TryLinkExistingVideoGameReferenceAsync(VideoGa var originalTitle = model.Title; var originalYear = model.Year; + var (ratingValue, ratingScale) = PrimaryRating(reference.Ratings, RawgRatingSource); model.ReferenceId = reference.Id; model.Title = reference.Title; if (reference.Year is not null) model.Year = reference.Year; + model.ReferenceRating = ratingValue; + model.ReferenceRatingScale = ratingScale; await videoGameRepository.UpdateAsync(model.Id!, model, model.OwnerId); - await videoGameRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year); + await videoGameRepository.SetReferenceLinkAsync(originalTitle, originalYear, reference.Id!, reference.Title, reference.Year, ratingValue, ratingScale); return model; } @@ -58,6 +88,8 @@ public async Task UnlinkVideoGameReferenceAsync(VideoGameModel m { var referenceId = model.ReferenceId; model.ReferenceId = string.Empty; + model.ReferenceRating = null; + model.ReferenceRatingScale = null; await videoGameRepository.UpdateAsync(model.Id!, model, model.OwnerId); if (!string.IsNullOrEmpty(referenceId)) { @@ -113,12 +145,14 @@ public async Task ResolveVideoGameAsync(string title, i ExternalIds = externalIds, MatchedAliases = MergeMatchedAliases(existing?.MatchedAliases, (details.Title, details.Year ?? year, null, null), (title, year, null, null)), Genres = details.Genres, + Ratings = BuildRawgRatings(details.Rating, details.RatingsCount, details.Metacritic), ImageUrl = details.ImageUrl, LastEnrichedAt = DateTime.UtcNow }; var saved = await videoGameReferenceRepository.UpsertAsync(model); - await videoGameRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, RawgRatingSource); + await videoGameRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, ratingValue, ratingScale); return saved; } @@ -141,10 +175,14 @@ public async Task ResolveVideoGameAsync(string title, i reference.Synopsis = details.Synopsis; reference.Platforms = details.Platforms; reference.Genres = details.Genres; + reference.Ratings = BuildRawgRatings(details.Rating, details.RatingsCount, details.Metacritic); reference.ImageUrl = details.ImageUrl ?? reference.ImageUrl; reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, null, null)); reference.LastEnrichedAt = DateTime.UtcNow; - return (await videoGameReferenceRepository.UpsertAsync(reference), true); + var saved = await videoGameReferenceRepository.UpsertAsync(reference); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, RawgRatingSource); + await videoGameRepository.SetReferenceRatingAsync(saved.Id!, ratingValue, ratingScale); + return (saved, true); } } diff --git a/src/WebApi/ReferenceData/TmdbClient.cs b/src/WebApi/ReferenceData/TmdbClient.cs index 261399ae..f53b40d8 100644 --- a/src/WebApi/ReferenceData/TmdbClient.cs +++ b/src/WebApi/ReferenceData/TmdbClient.cs @@ -45,7 +45,8 @@ public async Task> SearchMovieAsync(string title return new TmdbTvShowDetails( tmdbId, details.Name ?? string.Empty, ParseYear(details.FirstAirDate), details.Overview, episodes, - details.Genres.Select(g => g.Name).ToList(), BuildImageUrl(details.PosterPath, PosterImageSize)); + details.Genres.Select(g => g.Name).ToList(), BuildImageUrl(details.PosterPath, PosterImageSize), + details.VoteAverage, details.VoteCount); } public async Task GetMovieDetailsAsync(string tmdbId, CancellationToken cancellationToken = default) @@ -159,6 +160,12 @@ private sealed class TmdbTvShowDetailsResponse [JsonPropertyName("genres")] public List Genres { get; set; } = []; + [JsonPropertyName("vote_average")] + public double? VoteAverage { get; set; } + + [JsonPropertyName("vote_count")] + public int? VoteCount { get; set; } + [JsonPropertyName("seasons")] public List Seasons { get; set; } = []; } diff --git a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs index 12dabed8..ffa7de80 100644 --- a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs +++ b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs @@ -173,7 +173,10 @@ public Task DeleteAsync(string id, string ownerId) => private sealed class FakeTvShowRepository : InMemoryRepository, ITvShowRepository { - public Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null) => + public Task SetReferenceLinkAsync(string title, int? year, string referenceId, string canonicalTitle, int? canonicalYear = null, double? canonicalRating = null, double? canonicalRatingScale = null) => + Task.FromResult(0L); + + public Task SetReferenceRatingAsync(string referenceId, double? rating, double? ratingScale) => Task.FromResult(0L); public Task> FindDistinctUnresolvedTitleYearsAsync() => diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs index 7cf8e633..a45f22f8 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs @@ -549,6 +549,9 @@ public async Task RefreshTvShowReferenceAsync_OnlyBumpsLastEnrichedAt_WhenTmdbRe Title = "Some Show", TitleNormalized = "some show", ExternalIds = new Dictionary { ["tmdb"] = "42" }, + // already has a rating, so the no-change short-circuit applies (an empty Ratings would instead + // force a full backfill fetch - covered separately below) + Ratings = new Dictionary { ["tmdb"] = new() { Value = 8.0, Scale = 10, Count = 100 } }, LastEnrichedAt = lastEnrichedAt }; _tvShowReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((TvShowReferenceModel m) => m); From ceab4d72d7511be18af22769a7ad1f63c8c7ba51 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 30 Jul 2026 02:12:13 +0200 Subject: [PATCH 34/80] Use openlibrary to get book rating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was wrong Not a bug in our code — Google Books (your default provider) stopped serving ratings entirely (verified live: zero averageRating even for The Hobbit / Harry Potter). Open Library serves them fine. Since your library links via Google Books, it inherited the gap. The fix (as you approved) A small cross-provider Open-Library-by-ISBN rating fallback for books: - When a book resolves via a provider that returns no rating but does have a clean ISBN, it does one Open Library call (search.json?q=isbn:…, first result) and stores that rating under an "openlibrary" source key in the same Ratings dict. - It's skipped when the linking provider already gave a rating, when the provider is Open Library, or when there's no ISBN — so no wasted calls. - Kept off IBookReferenceClient via a tiny dedicated IBookRatingByIsbnLookup interface (only Open Library implements it), so the provider abstraction stays clean. Net effect: a Google-Books-linked book keeps Google's better cover/synopsis and now gets a real rating pill, as long as Open Library knows the ISBN. (A BnF book with no ISBN still won't get one — no lookup key — which is the expected edge.) To see it on your existing books The refresh path is what fills it in, so either hit Sync now on the reference-data admin page, or re-check the reference on a book — it'll backfill the rating from Open Library. State - Full solution builds; 303 unit tests pass (new: fallback fires for a non-OL provider with an ISBN; fallback is skipped when the provider is OL). --- src/WebApi/Program.cs | 2 + .../ReferenceData/IBookReferenceClient.cs | 13 +++++ src/WebApi/ReferenceData/OpenLibraryClient.cs | 22 +++++++- .../ReferenceEnrichmentService.Books.cs | 28 +++++++++- .../ReferenceEnrichmentService.cs | 1 + .../ReferenceEnrichmentServiceTest.cs | 53 +++++++++++++++++++ .../ReferenceData/ReferenceSyncServiceTest.cs | 4 +- 7 files changed, 119 insertions(+), 4 deletions(-) diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index a67e482d..861a4d4c 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -82,6 +82,8 @@ client.Timeout = Timeout.InfiniteTimeSpan; }).AddBookProviderResilienceHandler(); builder.Services.AddTransient(sp => sp.GetRequiredService()); +// Open Library also backs the cross-provider ISBN rating fallback (Google Books, the default, serves no ratings). +builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://catalogue.bnf.fr/api/"); diff --git a/src/WebApi/ReferenceData/IBookReferenceClient.cs b/src/WebApi/ReferenceData/IBookReferenceClient.cs index 55c42566..6e987b5c 100644 --- a/src/WebApi/ReferenceData/IBookReferenceClient.cs +++ b/src/WebApi/ReferenceData/IBookReferenceClient.cs @@ -47,3 +47,16 @@ public interface IBookReferenceClient Task GetBookDetailsAsync(string externalId, CancellationToken cancellationToken = default); } + +/// +/// Looks up an aggregate book rating by ISBN. A small, single-purpose capability kept off +/// because only one provider needs to implement it: it's a cross-provider +/// fallback for the rating field alone. The default book provider (Google Books) no longer serves ratings at +/// all (confirmed against the live API), so a book linked through it - or any provider without ratings - can +/// still get one from Open Library via the clean ISBN the link already resolved. Implemented by +/// . +/// +public interface IBookRatingByIsbnLookup +{ + Task<(double? Average, int? Count)> GetRatingByIsbnAsync(string isbn, CancellationToken cancellationToken = default); +} diff --git a/src/WebApi/ReferenceData/OpenLibraryClient.cs b/src/WebApi/ReferenceData/OpenLibraryClient.cs index 690c105c..4f83192f 100644 --- a/src/WebApi/ReferenceData/OpenLibraryClient.cs +++ b/src/WebApi/ReferenceData/OpenLibraryClient.cs @@ -11,7 +11,7 @@ namespace Keeptrack.WebApi.ReferenceData; /// No API key required; /// registered as a typed with a descriptive User-Agent header (Open Library's stated best practice for API consumers) - see Program.cs. /// -public class OpenLibraryClient(HttpClient http) : IBookReferenceClient +public class OpenLibraryClient(HttpClient http) : IBookReferenceClient, IBookRatingByIsbnLookup { public string ProviderKey => "openlibrary"; @@ -91,6 +91,20 @@ private async Task> SearchBooksCoreAsync(string RatingCount: ratingCount); } + /// + /// Cross-provider rating fallback (see ): the search index carries + /// ratings_average/ratings_count directly, so an isbn: query returns the work's + /// rating in a single call. First hit only, as agreed - a clean resolved ISBN maps to one work. A + /// 0/absent average is treated as "no rating", not a real zero. + /// + public async Task<(double? Average, int? Count)> GetRatingByIsbnAsync(string isbn, CancellationToken cancellationToken = default) + { + var response = await http.GetFromJsonAsync( + $"search.json?q={Encode($"isbn:{isbn}")}&fields=ratings_average,ratings_count&limit=1", cancellationToken); + var doc = response?.Docs.FirstOrDefault(); + return doc is { RatingsAverage: > 0 } ? (doc.RatingsAverage, doc.RatingsCount) : (null, null); + } + /// /// Open Library exposes a work's aggregate rating on a dedicated /works/{id}/ratings.json endpoint /// (not on the work document itself) - one extra call, best-effort: a missing summary just leaves the @@ -179,6 +193,12 @@ private sealed class OpenLibrarySearchDoc [JsonPropertyName("cover_i")] public int? CoverId { get; set; } + + [JsonPropertyName("ratings_average")] + public double? RatingsAverage { get; set; } + + [JsonPropertyName("ratings_count")] + public int? RatingsCount { get; set; } } private sealed class OpenLibraryWorkResponse diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs index 0a1f956d..0c5f5532 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs @@ -21,10 +21,30 @@ private static Dictionary BuildBookRatings(string return ratings; } - /// The book's single stored rating (from whichever provider linked it), or (null, null) when it has none. + /// The book's single stored rating (from whichever provider linked it, or the OL fallback), or (null, null) when it has none. private static (double? Value, double? Scale) BookPrimaryRating(BookReferenceModel reference) => reference.Ratings.Count == 0 ? (null, null) : PrimaryRating(reference.Ratings, reference.Ratings.Keys.First()); + private const string OpenLibraryProviderKey = "openlibrary"; + + /// + /// Cross-provider rating fallback for books: when the linking provider supplied no rating (Google Books, + /// the default, no longer serves any) and there's a resolved ISBN to look up by, fetch Open Library's + /// rating by ISBN and store it under its own source key. No-op when a rating already exists, the linking + /// provider IS Open Library (already covered), or there's no ISBN. Best-effort - a failed/empty lookup + /// just leaves the book unrated rather than failing the resolve. + /// + private async Task AddOpenLibraryRatingFallbackAsync(Dictionary ratings, string providerKey, string? isbn, CancellationToken cancellationToken) + { + if (ratings.Count > 0 || providerKey == OpenLibraryProviderKey || string.IsNullOrWhiteSpace(isbn)) return; + + var (average, count) = await bookRatingByIsbnLookup.GetRatingByIsbnAsync(isbn, cancellationToken); + if (average is > 0) + { + ratings[OpenLibraryProviderKey] = new ReferenceRatingModel { Value = average.Value, Scale = 5, Count = count }; + } + } + /// /// User-triggered "check for reference match" for books - see /// for the full rationale (this is the same local-only, @@ -157,6 +177,9 @@ public async Task ResolveBookAsync(string title, int? year, ? await ResolvePersonReferenceIdAsync(client.ProviderKey, details.AuthorExternalId, details.Author ?? "Unknown", null) : existing?.AuthorReferenceId; + var ratings = BuildBookRatings(client.ProviderKey, details.Rating, details.RatingCount); + await AddOpenLibraryRatingFallbackAsync(ratings, client.ProviderKey, details.Isbn ?? existing?.Isbn, CancellationToken.None); + var model = new BookReferenceModel { Id = existing?.Id, @@ -170,7 +193,7 @@ public async Task ResolveBookAsync(string title, int? year, (details.Title, details.Year ?? year, details.Author, details.Isbn), (title, year, details.Author, isbn)), Genres = details.Genres, - Ratings = BuildBookRatings(client.ProviderKey, details.Rating, details.RatingCount), + Ratings = ratings, ImageUrl = details.ImageUrl, Language = details.Language ?? existing?.Language, Isbn = details.Isbn ?? existing?.Isbn, @@ -216,6 +239,7 @@ public async Task ResolveBookAsync(string title, int? year, reference.ImageUrl = details.ImageUrl ?? reference.ImageUrl; reference.Language = details.Language ?? reference.Language; reference.Isbn = details.Isbn ?? reference.Isbn; + await AddOpenLibraryRatingFallbackAsync(reference.Ratings, client.ProviderKey, reference.Isbn, cancellationToken); reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, details.Author, details.Isbn)); reference.LastEnrichedAt = DateTime.UtcNow; diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs index 30de18f4..a8dd20c0 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs @@ -16,6 +16,7 @@ namespace Keeptrack.WebApi.ReferenceData; public partial class ReferenceEnrichmentService( ITmdbClient tmdbClient, BookReferenceClientRegistry bookReferenceClientRegistry, + IBookRatingByIsbnLookup bookRatingByIsbnLookup, IRawgClient rawgClient, IDiscogsClient discogsClient, ITvShowReferenceRepository tvShowReferenceRepository, diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs index a45f22f8..fb2c6f65 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs @@ -26,6 +26,7 @@ public class ReferenceEnrichmentServiceTest private readonly Mock _bookRepository = new(); private readonly Mock _videoGameRepository = new(); private readonly Mock _albumRepository = new(); + private readonly FakeBookRatingByIsbnLookup _bookRatingByIsbnLookup = new(); /// /// The registry always has "openlibrary" as the deployment default - matches FakeBookReferenceClient's @@ -42,6 +43,7 @@ private ReferenceEnrichmentService CreateService( FakeBnfClient? bnfClient = null) => new( tmdbClient, new BookReferenceClientRegistry([bookReferenceClient ?? FakeBookReferenceClient.Empty(), bnfClient ?? FakeBnfClient.Empty()], DefaultBookProvider), + _bookRatingByIsbnLookup, rawgClient ?? FakeRawgClient.Empty(), discogsClient ?? FakeDiscogsClient.Empty(), _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _personReferenceRepository.Object, _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, @@ -712,6 +714,42 @@ public async Task ResolveBookAsync_LeavesTheSearchAliasIsbnNull_WhenNoIsbnWasSup result.MatchedAliases.Should().OnlyContain(a => a.Isbn == null); } + [Fact] + public async Task ResolveBookAsync_FallsBackToOpenLibraryRatingByIsbn_WhenTheLinkingProviderReportsNone() + { + // BnF (like Google Books, the production default) serves no rating; the resolved ISBN lets Open + // Library supply one, stored under its own source key and denormalized as the book's primary rating. + var bnfClient = FakeBnfClient.Empty(); + bnfClient.Details["ark:/12148/cb1"] = new BookDetails("ark:/12148/cb1", "Some Book", 2020, "Synopsis", "Some Author", null, [], null, "fre", "9780000000001"); + _bookRatingByIsbnLookup.Result = (4.2, 100); + _bookReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((BookReferenceModel m) => { m.Id = "reference-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), bnfClient: bnfClient); + + var result = await service.ResolveBookAsync("Some Book", 2020, "ark:/12148/cb1", "bnf"); + + _bookRatingByIsbnLookup.RequestedIsbns.Should().Contain("9780000000001"); + result.Ratings.Should().ContainKey("openlibrary"); + result.Ratings["openlibrary"].Value.Should().Be(4.2); + result.Ratings["openlibrary"].Scale.Should().Be(5); + _bookRepository.Verify(r => r.SetReferenceLinkAsync("Some Book", 2020, "reference-1", "Some Book", It.IsAny(), + "Some Author", It.IsAny(), "fre", "9780000000001", 4.2, 5), Times.Once); + } + + [Fact] + public async Task ResolveBookAsync_DoesNotCallTheOpenLibraryFallback_WhenTheLinkingProviderIsOpenLibrary() + { + // the default provider IS Open Library here - a rating (or its absence) already comes from the link itself + var bookReferenceClient = FakeBookReferenceClient.Empty(); + bookReferenceClient.Details["OL1W"] = new BookDetails("OL1W", "Some Book", 2020, "Synopsis", "Some Author", "OL1A", [], null, null, "9780000000001"); + _bookReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((BookReferenceModel m) => { m.Id = "reference-1"; return m; }); + _personReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((PersonReferenceModel m) => { m.Id ??= "person-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), bookReferenceClient); + + await service.ResolveBookAsync("Some Book", 2020, "OL1W"); + + _bookRatingByIsbnLookup.RequestedIsbns.Should().BeEmpty(); + } + [Fact] public async Task TryLinkExistingBookReferenceAsync_LinksAndUpdatesTitleAndAuthor_OnTitleYearMatch() { @@ -1210,6 +1248,7 @@ public async Task RefreshAlbumReferenceAsync_AlwaysRefetches_RegardlessOfLastEnr private ReferenceEnrichmentService CreateServiceWithStrictClients() => new( new Mock(MockBehavior.Strict).Object, new BookReferenceClientRegistry([new Mock(MockBehavior.Strict).Object], DefaultBookProvider), + new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _personReferenceRepository.Object, @@ -1276,6 +1315,20 @@ public async Task Resolve_Throws_OnAnEmptyTitle_ForAnyDomain() await ((Func)(() => service.ResolveAlbumAsync(" ", 2020, "42"))).Should().ThrowAsync(); } + private sealed class FakeBookRatingByIsbnLookup : IBookRatingByIsbnLookup + { + /// Result the fallback returns; defaults to "no rating" so tests not exercising it are unaffected. + public (double? Average, int? Count) Result { get; set; } = (null, null); + + public List RequestedIsbns { get; } = []; + + public Task<(double? Average, int? Count)> GetRatingByIsbnAsync(string isbn, CancellationToken cancellationToken = default) + { + RequestedIsbns.Add(isbn); + return Task.FromResult(Result); + } + } + private sealed class FakeTmdbClient : ITmdbClient { private readonly List _tvShowSearchResults; diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs index e0a9aa2c..9ec7b76b 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs @@ -34,8 +34,10 @@ private ReferenceSyncService CreateService(FakeTmdbClient tmdbClient) _videoGameReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([]); _albumReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync([]); + var bookRatingLookup = new Mock(); + bookRatingLookup.Setup(x => x.GetRatingByIsbnAsync(It.IsAny(), It.IsAny())).ReturnsAsync(((double?)null, (int?)null)); var enrichmentService = new ReferenceEnrichmentService( - tmdbClient, new BookReferenceClientRegistry([FakeBookReferenceClient.Empty()], "openlibrary"), FakeRawgClient.Empty(), FakeDiscogsClient.Empty(), + tmdbClient, new BookReferenceClientRegistry([FakeBookReferenceClient.Empty()], "openlibrary"), bookRatingLookup.Object, FakeRawgClient.Empty(), FakeDiscogsClient.Empty(), _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _personReferenceRepository.Object, _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object); From f8d8569dc4ca67089899c30e3ca68baeed071cc4 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 30 Jul 2026 02:19:46 +0200 Subject: [PATCH 35/80] Add doc for reference ratings --- Keeptrack.slnx | 1 + docs/reference-ratings-plan.md | 99 ++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 docs/reference-ratings-plan.md diff --git a/Keeptrack.slnx b/Keeptrack.slnx index 842b3473..29d5a045 100644 --- a/Keeptrack.slnx +++ b/Keeptrack.slnx @@ -19,6 +19,7 @@ + diff --git a/docs/reference-ratings-plan.md b/docs/reference-ratings-plan.md new file mode 100644 index 00000000..e565d4b4 --- /dev/null +++ b/docs/reference-ratings-plan.md @@ -0,0 +1,99 @@ +# Reference ratings + +Provider ("reference") ratings for tracked items - the linked reference's own aggregate score (TMDB, RAWG, Metacritic, Discogs, Google Books, Open Library), shown alongside the user's personal star rating. + +## Status + +Phase 1 is implemented across all five reference-bearing media types (Movie, TV show, Video game, Album, Book). +Build is green; the WebApi unit suite passes (303 tests, including the new rating tests); the Movies slice also has real-MongoDB integration coverage. +The user has confirmed the feature displays well and the mechanics are right. + +## End goal / solution design + +A rating for a tracked item can come from more than one source, so the shape is source-keyed from day one and the same design serves a future multi-source world (IMDb, Metacritic, ...) with no schema change. + +### Two homes for the rating + +1. **Canonical, on the shared reference document** (`*ReferenceModel.Ratings`): `Dictionary` keyed by source name. + `ReferenceRatingModel` is `{ double Value; double Scale; int? Count; }`. + `Value` is on the source's own `Scale` (TMDB 10, RAWG/Discogs/books 5, Metacritic 100) and is never normalized - a single list only mixes one source, so raw values still sort apples-to-apples. + This is the source of truth the periodic sync refreshes, and it is what a future "suggest top-rated" feature will query and sort by (`ratings..value` is a fixed, indexable path). + +2. **Denormalized scalar, on the tenant's own item** (`*Model.ReferenceRating` + `ReferenceRatingScale`, both `double?`): a copy of the *primary* source's value/scale, written on link and refresh. + This exists purely so the list page can display and sort by the rating with no per-page join and no extra query - the whole point was that list load time must not regress. + The list pill and the `Ref ★` sort key both read this scalar; the detail page shows the full per-source breakdown from the reference dict it already loads. + +The tenant item already being per-user is what makes a future per-user "which source is primary" choice a clean change later (only the propagation would change, no migration) - see "Decisions locked" below for why it's a code default for now. + +### Primary source per domain (the value denormalized onto the tenant item) + +| Domain | Provider(s) → dict keys | Primary (pill + sort) | Scale | +|--------|-------------------------|-----------------------|-------| +| Movie / TV show | TMDB `vote_average` → `tmdb` | `tmdb` | 10 | +| Video game | RAWG `rating` → `rawg`, RAWG `metacritic` → `metacritic` | `rawg` | 5 | +| Album | Discogs community rating → `discogs` | `discogs` | 5 | +| Book | linking provider → its key (`googlebooks`/`openlibrary`), plus OL fallback → `openlibrary` | the single stored entry | 5 | + +### Propagation and freshness + +`SetReferenceLinkAsync` gained `canonicalRating`/`canonicalRatingScale` params (appended after each domain's existing canonical-* params) - it stamps the denormalized scalar on every matching *unlinked* tenant item at link time. +A new `SetReferenceRatingAsync(referenceId, rating, ratingScale)` on each repository re-propagates to every *already-linked* tenant item, called from each `Refresh*ReferenceAsync` so the copies stay current with the 24h sync. +`TryLinkExisting*`/`Unlink*` set/clear the scalar on the one tenant item directly. + +**TMDB backfill gotcha (Movie/TV only):** `Refresh*ReferenceAsync` normally short-circuits via TMDB's `/changes` pre-check when nothing changed. +That guard is now `LastEnrichedAt is not null && reference.Ratings.Count > 0`, so a reference linked before ratings existed is force-fetched once to backfill its rating instead of being skipped forever. +RAWG/Discogs/book providers have no `/changes` endpoint and always full-fetch past the staleness cutoff, so they backfill for free. + +### Book cross-provider rating fallback + +Google Books (the default book provider) **no longer serves ratings at all** (confirmed against the live API - `averageRating` is absent even for The Hobbit / Harry Potter). +So a small cross-provider fallback was added: when a book resolves via a provider that returns no rating but does have a clean, resolved ISBN, one Open Library call (`search.json?q=isbn:{isbn}`, first result) supplies a rating, stored under the `openlibrary` source key. +It is skipped when a rating already exists, when the linking provider *is* Open Library, or when there is no ISBN. +It lives behind a dedicated one-method `IBookRatingByIsbnLookup` interface (implemented only by `OpenLibraryClient`) so the provider-agnostic `IBookReferenceClient` stays clean. + +### UI + +- List + grid rows: a compact bare `★ 7.8` pill in the shared per-type meta row (`*MetaRow.razor`), rendered nothing when there is no rating. Class `kt-ref-rating` in `app.css`. +- Detail pages: a per-source breakdown via the shared `ReferenceRatings.razor` component (`★ 8.2 / 10 TMDB (24,183)`), driven off the reference `Ratings` dict, so phase 2 sources appear automatically. +- Sort: a `Ref ★` option (kept short so it fits the narrow, mobile-sized sort control) added via `InventoryList`'s `HasReferenceRatingSort` flag and `ListSort.ReferenceRating`. Best-first, unrated last, a plain indexed sort on the tenant collection. + +## Decisions locked (with the user) + +- **Display:** bare number with a star (`★ 7.8`) in list/grid, no scale; fuller `value / scale source (count)` on the detail page. +- **Sort label:** `Ref ★` (short, mobile). +- **Primary-source selection:** a **code default per media type** *as shipped in Phase 1* - not admin-configurable and not per-user yet. + Reason it was fine to ship this way: only video games have more than one source so far, and the storage already supports upgrading later with no migration. + Agreed follow-up: make the primary source admin-selectable, starting with video games (RAWG vs Metacritic) since it's the one multi-source type today, built as a general per-domain setting so IMDb (phase 2) reuses it - see next-steps step 1. +- **Video games:** RAWG's 0-5 user score is the primary (usually present); Metacritic is stored as a second source and shown on the detail page but does not drive the pill/sort (frequently absent). + +## Known limitations + +- **Google Books serves no ratings** - covered by the Open Library ISBN fallback above. +- **French (and other non-English) books often get no rating** - Open Library's rating coverage is thin outside English, and the ISBN must map to a work OL actually has ratings on. Confirmed by the user (English "Psion" got a rating, French titles did not). Accepted for now. +- **BnF books with no ISBN** get no rating (no lookup key) - expected edge. +- **Metacritic** is often absent on RAWG for smaller/older games. +- Non-TMDB providers have no cheap "has this changed" pre-check, so their `*Updated` sync counts always equal their `*Checked` counts (pre-existing behavior, unchanged). + +## Tests added + +- `ReferenceEnrichmentServiceTest` (unit, mocked): rating populate + primary-scalar propagation; no-rating-when-no-votes; TMDB backfill-forces-a-fetch; no-change short-circuit stays for an already-rated reference; TryLink sets the denormalized rating; the Open Library ISBN fallback fires for a non-OL provider and is skipped when the provider is OL. +- `MovieReferenceRatingRepositoryTest` (integration, real MongoDB): the `ReferenceRating` sort ordering (unrated last), `SetReferenceLinkAsync` stamps only unlinked matches, `SetReferenceRatingAsync` re-propagates to every already-linked item, and the `Ratings` dict round-trips through BSON. +- The other four types reuse the identical shared sort/propagation code the Movie tests cover; per-type integration tests were deliberately deferred (see next steps). + +## What to do next + +1. **Admin-selectable primary source (starting with video games: RAWG vs Metacritic).** Video games are the one type that already has two sources today, so this is the first place the code-default decision is worth revisiting - ahead of, and independent of, IMDb. + Build it **general, not games-only**: a per-domain "primary rating source" setting (config or a stored admin setting) that the enrichment's `PrimaryRating` call reads instead of the current hardcoded key. + Keep it **admin/global, not per-user** - changing it must recompute the denormalized `ReferenceRating` scalar on every linked tenant item from the reference dict, which is one bulk `SetReferenceRatingAsync` pass per affected reference; per-user couldn't be a single bulk update. + So it's a setting **plus** an admin "recompute reference ratings" action (on the reference-data admin page) that re-propagates when the source changes. + Note the switch's effect: Metacritic is `/100` and often absent on RAWG, so flipping games to Metacritic-primary blanks the pill for many games and sorts them last - RAWG stays the safer default. + This same mechanism then covers IMDb-vs-TMDB for movies/TV in Phase 2 for free. +2. **Phase 2 - IMDb ratings for movies/TV.** Add an `imdb` source to the movie/TV `Ratings` dict. + IMDb has no public ratings API; options are OMDb (needs an API key, returns `imdbRating`/`imdbVotes`) or TMDB `external_ids` → IMDb id → a data source. + Once movies/TV have two sources, the primary is chosen via the admin-selectable mechanism from step 1 (the storage already supports it with no migration). +3. **Phase 3 - "suggest N top-rated not-yet-added" feature.** Read a source, order the reference collection by `ratings..value` desc, skip titles the user already tracks, return the top N. + This needs a supporting index on the chosen `ratings..value` path in `scripts/mongodb-create-index.js` (not added yet - phase 1 added no indexes because the per-owner tenant-side sort is small). +4. **Per-type integration + Playwright coverage.** Movies has real-Mongo integration coverage; add the equivalent for TV/game/album/book if desired (the logic is shared, so this is defense-in-depth, not filling a logic gap). The existing Book Playwright smoke test uses a synthetic seeded reference, so it does not exercise a real provider rating. +5. **French / non-English book ratings.** If it becomes worth it, try a second fallback source for books (e.g. a provider with better FR coverage) under the same `IBookRatingByIsbnLookup`-style pattern. +6. **Backfill existing data.** Ratings fill in on the next sync (`Sync now` on the reference-data admin page, or re-check a reference). No migration script is needed; if a bulk backfill is ever wanted it is just a forced sync, not a `scripts/*.js`. +7. **Docs.** Fold the durable parts of this into `CLAUDE.md`'s reference-data section once the feature settles (kept here as a working plan for now). From 9381be32c2f5eee053b51994cf91234f06f9f271 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 30 Jul 2026 10:55:03 +0200 Subject: [PATCH 36/80] Admin select reference rating source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What I built (next-step #1) A general, admin/global, per-domain primary rating source mechanism, wired for video games (RAWG vs Metacritic) as the first case. Storage — shared app_setting collection (as you specified): - AppSetting entity = one document, fixed _id: "global", with a reference_rating_source map (domain → source). New settings become new fields on this document, never new collections. - IAppSettingRepository / AppSettingRepository (GetReferenceRatingSourcesAsync, SetReferenceRatingSourceAsync) — LeaseRepository-style, atomic $set on just the one map entry (upsert creates the doc). Registered in DI. General mechanism: - RatingSourceCatalog — the single home for per-domain available sources + code default (today only VideoGame → [rawg, metacritic], default rawg). The "rawg"/"metacritic" literals now live here; VideoGames.cs consts point at them (no drift). - ReferenceEnrichmentService.GetPrimaryRatingSourceAsync(domain) — stored override if it still names an available source, else the catalog default. The three video-game PrimaryRating call sites (resolve/refresh/link) now read this instead of the hardcoded "rawg". Movie/TV/Album/Book are untouched this increment (they join by adding a catalog entry when they gain a 2nd source, e.g. IMDb). - RecomputeReferenceRatingsAsync(domain) — one bulk SetReferenceRatingAsync pass over the (small, shared) reference collection, no provider calls; the loop is a domain-agnostic generic helper so adding movies/TV/albums later is a one-line switch arm. Admin API + UI (two actions, as chosen): - GET /api/reference-data/rating-sources, PUT …/{domain} (validated ∈ available → 400 otherwise), POST …/{domain}/recompute → { referencesChecked, itemsUpdated }. - New "Primary rating source" card on the reference-data admin page: per selectable domain, a source button-group + a separate Recompute button showing result counts, with the Metacritic caveat spelled out. Tests (all passing): catalog defaults/available; resolver (default / valid override / invalid-override-falls-back); resolve denormalizes the selected source; recompute re-stamps every linked item with the selected source's value+scale (rawg /5 vs metacritic /100), uses default when unset, and throws for a non-selectable domain. To try it Run WebApi + BlazorApp, go to /admin/reference-data, use the new card: switch video games to metacritic, click Recompute, and check that game list pills/Ref ★ sort reflect the /100 scores (many will blank out, as warned). Switch back to rawg + Recompute to restore. Two things I deliberately left for your call after you've run it: - No index on app_setting (single doc, _id lookup only) and none needed. Both docs are updated: - docs/reference-ratings-plan.md — Status now notes step 1 done (315 tests), a new "Admin-selectable primary source (per-domain, admin/global) - implemented" section, the "Decisions locked" bullet updated, the new tests listed, and next-step #1 struck through as done. - CLAUDE.md — a paragraph in the reference-data admin area documenting the shared app_setting collection / IAppSettingRepository primitive (the "new field, not a new collection" rule and when to use it vs AppConfiguration), with its first use (admin-selectable primary rating source) summarized and a pointer to the plan doc. --- CLAUDE.md | 7 + docs/reference-ratings-plan.md | 41 ++++-- .../ReferenceDataAdminApiClient.cs | 30 +++++ .../ReferenceDataAdminPage.razor | 88 ++++++++++++ .../Repositories/IAppSettingRepository.cs | 25 ++++ .../Entities/AppSetting.cs | 20 +++ .../Repositories/AppSettingRepository.cs | 35 +++++ .../Dto/RatingSourceOptionDto.cs | 19 +++ .../Dto/RecomputeRatingsResultDto.cs | 14 ++ .../Dto/SetRatingSourceRequestDto.cs | 10 ++ ...frastructureServiceCollectionExtensions.cs | 1 + .../ReferenceData/RatingSourceCatalog.cs | 40 ++++++ .../ReferenceDataAdminController.cs | 60 ++++++++- .../ReferenceEnrichmentService.VideoGames.cs | 15 ++- .../ReferenceEnrichmentService.cs | 63 ++++++++- .../ReferenceData/RatingSourceCatalogTest.cs | 33 +++++ .../ReferenceEnrichmentServiceTest.cs | 126 +++++++++++++++++- .../ReferenceData/ReferenceSyncServiceTest.cs | 5 +- 18 files changed, 610 insertions(+), 22 deletions(-) create mode 100644 src/Domain/Repositories/IAppSettingRepository.cs create mode 100644 src/Infrastructure.MongoDb/Entities/AppSetting.cs create mode 100644 src/Infrastructure.MongoDb/Repositories/AppSettingRepository.cs create mode 100644 src/WebApi.Contracts/Dto/RatingSourceOptionDto.cs create mode 100644 src/WebApi.Contracts/Dto/RecomputeRatingsResultDto.cs create mode 100644 src/WebApi.Contracts/Dto/SetRatingSourceRequestDto.cs create mode 100644 src/WebApi/ReferenceData/RatingSourceCatalog.cs create mode 100644 test/WebApi.UnitTests/ReferenceData/RatingSourceCatalogTest.cs diff --git a/CLAUDE.md b/CLAUDE.md index 8dbf11b1..eb1f7bca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -427,6 +427,13 @@ Firebase's claim arrives as a plain `"role"` claim rather than the `ClaimTypes.R WebApi validates the bearer token's claims directly and needs no equivalent step. There's no in-app way to grant the first admin; it's a one-off `setCustomUserClaims` call via the Firebase Admin SDK (see `CONTRIBUTING.md`). +Global admin settings an admin changes at runtime (as opposed to deploy-time config in `appsettings`/env vars) live in one shared `app_setting` collection - a single document (`_id: "global"`), one field per setting. +`IAppSettingRepository`/`AppSettingRepository` is the purpose-built accessor (like `LeaseRepository`, it doesn't extend the owner-scoped `IDataRepository`), writing with a targeted `$set` on just the one field so unrelated settings on the same document are never clobbered. +Reach for this - a new field/accessor here, not a new collection - for any future runtime-changeable global setting; use `AppConfiguration`/env vars only for values that are fine to change at deploy time. +Its first use is the admin-selectable **primary rating source** (which provider score is denormalized onto a tenant item as the list/sort rating): `RatingSourceCatalog` declares each domain's selectable sources + code default (only video games have more than one today - RAWG vs Metacritic), +`ReferenceEnrichmentService.GetPrimaryRatingSourceAsync` reads the stored override-or-default, and `ReferenceDataAdminController`'s `rating-sources` GET/PUT plus a `.../recompute` POST (a synchronous bulk `SetReferenceRatingAsync` pass, no provider calls) let an admin switch it and re-propagate to every already-linked item. +The full design (two homes for a rating, propagation, this admin mechanism, and the pending IMDb/top-rated phases) is tracked in `docs/reference-ratings-plan.md` until the feature settles. + The app is meant to be publicly shareable: anyone can sign in (Google/GitHub via Firebase Auth), but a plain account with **no** `role` claim is a *free preview* tier. Free tier = movies and TV shows only, capped at `Features:FreeTierItemLimit` creations per collection (default 20, guarded in `AppConfiguration.GetFreeTierItemLimit` so a missing setting can never lock the tier out entirely); episodes are capped at 100x that limit (`EpisodeController.FreeTierLimitFactor`) - generous on purpose, the cap only exists so a raw-API caller can't flood the database, never to ration a real watch-through. diff --git a/docs/reference-ratings-plan.md b/docs/reference-ratings-plan.md index e565d4b4..a07ebeef 100644 --- a/docs/reference-ratings-plan.md +++ b/docs/reference-ratings-plan.md @@ -5,8 +5,9 @@ Provider ("reference") ratings for tracked items - the linked reference's own ag ## Status Phase 1 is implemented across all five reference-bearing media types (Movie, TV show, Video game, Album, Book). -Build is green; the WebApi unit suite passes (303 tests, including the new rating tests); the Movies slice also has real-MongoDB integration coverage. -The user has confirmed the feature displays well and the mechanics are right. +Next-step 1 (admin-selectable primary rating source) is also implemented, wired for video games as its first case - see "Admin-selectable primary source" below. +Build is green; the WebApi unit suite passes (315 tests, including the new rating and rating-source tests); the Movies slice also has real-MongoDB integration coverage. +The user has confirmed both the Phase 1 display/mechanics and the admin-selectable source switch (RAWG↔Metacritic + recompute) work in the running app. ## End goal / solution design @@ -44,6 +45,26 @@ A new `SetReferenceRatingAsync(referenceId, rating, ratingScale)` on each reposi That guard is now `LastEnrichedAt is not null && reference.Ratings.Count > 0`, so a reference linked before ratings existed is force-fetched once to backfill its rating instead of being skipped forever. RAWG/Discogs/book providers have no `/changes` endpoint and always full-fetch past the staleness cutoff, so they backfill for free. +### Admin-selectable primary source (per-domain, admin/global) - implemented + +Which source is "primary" (the value denormalized onto the tenant item) is no longer a hardcoded per-domain constant. +It is an admin-selectable, global (not per-user) setting, wired for video games (RAWG vs Metacritic) as the first and only multi-source domain today. + +- **Catalog:** `WebApi/ReferenceData/RatingSourceCatalog.cs` is the single declaration of, per `ReferenceItemType`, the selectable source keys plus the code default (`VideoGame → [rawg, metacritic]`, default `rawg`). + Only domains with more than one source appear; movies/TV join it when they gain IMDb (phase 2) and reuse everything below unchanged. + The `"rawg"`/`"metacritic"` string literals live here now; `ReferenceEnrichmentService.VideoGames.cs`'s build-key consts point at them so the two never drift. +- **Storage:** the choice lives in a shared `app_setting` collection - a single document (`_id: "global"`) whose `reference_rating_source` field is a domain→source map. + This is one shared collection for every future global admin setting, deliberately not a collection per setting (`IAppSettingRepository`/`AppSettingRepository`, purpose-built like `LeaseRepository`, `$set` on just the one map entry with upsert). + Config/env-var was rejected: it can't be changed from the admin UI without a redeploy, which defeats "admin-selectable". +- **Resolver:** `ReferenceEnrichmentService.GetPrimaryRatingSourceAsync(domain)` returns the stored override when it still names an available source, else the catalog default (an override for a source since removed from the catalog is ignored, never trusted). + The three video-game `PrimaryRating` call sites (resolve/refresh/link) read this instead of the old `"rawg"` const; the other four domains still pass their single source directly (they migrate when they become multi-source). +- **Recompute:** changing the source only affects new links/syncs until the admin runs `RecomputeReferenceRatingsAsync(domain)` - one bulk `SetReferenceRatingAsync` pass over the (small, shared) reference collection re-stamping the denormalized scalar from each doc's `Ratings` dict, no provider calls. + It runs synchronously (unlike "Sync now", which is a background job only because it hits providers) and returns `(ReferencesChecked, ItemsUpdated)`. + The loop is a domain-agnostic generic helper, so adding movies/TV/albums later is a one-line switch arm, never a copied loop. +- **Endpoints (admin-only, on `ReferenceDataAdminController`):** `GET /api/reference-data/rating-sources`, `PUT /api/reference-data/rating-sources/{domain}` (validates source ∈ available, 400 otherwise), `POST /api/reference-data/rating-sources/{domain}/recompute`. +- **UI:** a "Primary rating source" card on the reference-data admin page - per selectable domain, a source button-group plus a **separate** Recompute button (two deliberate actions, not one) showing the checked/updated counts. + It spells out the Metacritic caveat: `/100` and often absent on RAWG, so switching to it blanks the pill for many games and sorts them last. + ### Book cross-provider rating fallback Google Books (the default book provider) **no longer serves ratings at all** (confirmed against the live API - `averageRating` is absent even for The Hobbit / Harry Potter). @@ -61,9 +82,8 @@ It lives behind a dedicated one-method `IBookRatingByIsbnLookup` interface (impl - **Display:** bare number with a star (`★ 7.8`) in list/grid, no scale; fuller `value / scale source (count)` on the detail page. - **Sort label:** `Ref ★` (short, mobile). -- **Primary-source selection:** a **code default per media type** *as shipped in Phase 1* - not admin-configurable and not per-user yet. - Reason it was fine to ship this way: only video games have more than one source so far, and the storage already supports upgrading later with no migration. - Agreed follow-up: make the primary source admin-selectable, starting with video games (RAWG vs Metacritic) since it's the one multi-source type today, built as a general per-domain setting so IMDb (phase 2) reuses it - see next-steps step 1. +- **Primary-source selection:** shipped in Phase 1 as a **code default per media type**; now **admin-selectable and global** (not per-user), wired for video games (RAWG vs Metacritic) - see "Admin-selectable primary source" above. + The code default is still the fallback when an admin hasn't chosen; per-user was ruled out because a switch must be a single bulk `SetReferenceRatingAsync` recompute per reference, which per-user couldn't be. - **Video games:** RAWG's 0-5 user score is the primary (usually present); Metacritic is stored as a second source and shown on the detail page but does not drive the pill/sort (frequently absent). ## Known limitations @@ -79,15 +99,14 @@ It lives behind a dedicated one-method `IBookRatingByIsbnLookup` interface (impl - `ReferenceEnrichmentServiceTest` (unit, mocked): rating populate + primary-scalar propagation; no-rating-when-no-votes; TMDB backfill-forces-a-fetch; no-change short-circuit stays for an already-rated reference; TryLink sets the denormalized rating; the Open Library ISBN fallback fires for a non-OL provider and is skipped when the provider is OL. - `MovieReferenceRatingRepositoryTest` (integration, real MongoDB): the `ReferenceRating` sort ordering (unrated last), `SetReferenceLinkAsync` stamps only unlinked matches, `SetReferenceRatingAsync` re-propagates to every already-linked item, and the `Ratings` dict round-trips through BSON. - The other four types reuse the identical shared sort/propagation code the Movie tests cover; per-type integration tests were deliberately deferred (see next steps). +- `RatingSourceCatalogTest` (unit): video games offer RAWG+Metacritic with RAWG default; single-source domains are not yet selectable. +- `ReferenceEnrichmentServiceTest` (unit, mocked) gained: the source resolver (default / valid override / invalid-override-falls-back-to-default); `ResolveVideoGameAsync` denormalizes Metacritic's `/100` when it's the selected source; `RecomputeReferenceRatingsAsync` re-stamps every linked item with the selected source's value+scale, uses the default when unset, and throws for a non-selectable domain. ## What to do next -1. **Admin-selectable primary source (starting with video games: RAWG vs Metacritic).** Video games are the one type that already has two sources today, so this is the first place the code-default decision is worth revisiting - ahead of, and independent of, IMDb. - Build it **general, not games-only**: a per-domain "primary rating source" setting (config or a stored admin setting) that the enrichment's `PrimaryRating` call reads instead of the current hardcoded key. - Keep it **admin/global, not per-user** - changing it must recompute the denormalized `ReferenceRating` scalar on every linked tenant item from the reference dict, which is one bulk `SetReferenceRatingAsync` pass per affected reference; per-user couldn't be a single bulk update. - So it's a setting **plus** an admin "recompute reference ratings" action (on the reference-data admin page) that re-propagates when the source changes. - Note the switch's effect: Metacritic is `/100` and often absent on RAWG, so flipping games to Metacritic-primary blanks the pill for many games and sorts them last - RAWG stays the safer default. - This same mechanism then covers IMDb-vs-TMDB for movies/TV in Phase 2 for free. +1. **~~Admin-selectable primary source (video games: RAWG vs Metacritic).~~ Done** - implemented as a general per-domain, admin/global setting in the shared `app_setting` collection plus a recompute action, wired for video games. + See "Admin-selectable primary source (per-domain, admin/global) - implemented" above for the full shape. + The same mechanism covers IMDb-vs-TMDB for movies/TV in Phase 2 for free (just add a `RatingSourceCatalog` entry and route those `PrimaryRating` call sites through `GetPrimaryRatingSourceAsync`). 2. **Phase 2 - IMDb ratings for movies/TV.** Add an `imdb` source to the movie/TV `Ratings` dict. IMDb has no public ratings API; options are OMDb (needs an API key, returns `imdbRating`/`imdbVotes`) or TMDB `external_ids` → IMDb id → a data source. Once movies/TV have two sources, the primary is chosen via the admin-selectable mechanism from step 1 (the storage already supports it with no migration). diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs index e318a5f9..a70d7f67 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminApiClient.cs @@ -33,6 +33,36 @@ public async Task> GetBookProvidersAsync() return results ?? []; } + /// + /// Every domain whose primary rating source is admin-selectable, with available/selected sources (see + /// ReferenceDataAdminController.GetRatingSources). + /// + public async Task> GetRatingSourcesAsync() + { + var results = await http.GetFromJsonAsync>("/api/reference-data/rating-sources"); + return results ?? []; + } + + /// + /// Stores a domain's primary rating source (does not re-propagate - call for that). + /// + public async Task SetRatingSourceAsync(ReferenceItemType domain, string source) + { + var response = await http.PutAsJsonAsync($"/api/reference-data/rating-sources/{domain}", new SetRatingSourceRequestDto { Source = source }); + response.EnsureSuccessStatusCode(); + } + + /// + /// Re-applies a domain's current primary rating source to every linked tenant item (see + /// ReferenceDataAdminController.RecomputeRatingSource). + /// + public async Task RecomputeRatingsAsync(ReferenceItemType domain) + { + var response = await http.PostAsync($"/api/reference-data/rating-sources/{domain}/recompute", null); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync() ?? new RecomputeRatingsResultDto(); + } + public async Task LinkAsync(LinkReferenceRequestDto request) { var response = await http.PostAsJsonAsync("/api/reference-data/link", request); diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor index de01f83f..9a348f65 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor @@ -163,6 +163,40 @@ }
+@if (_ratingSources.Count > 0) +{ +
+

+ Which provider score is shown as the rating pill and drives the "Ref ★" sort. Changing the source + only affects new links and syncs until you recompute - use Recompute to re-apply the selected + source to every already-linked item at once. Note: Metacritic is out of 100 and often absent on + RAWG, so switching video games to it blanks the pill for many games and sorts them last. +

+ @foreach (var option in _ratingSources) + { +
+ @DomainLabel(option.Domain) +
+ @foreach (var source in option.AvailableSources) + { + + } +
+ + @if (_recomputedDomain == option.Domain && _recomputeResult is not null) + { + @_recomputeResult.ReferencesChecked references checked, @_recomputeResult.ItemsUpdated items updated. + } +
+ } + @if (_ratingSourceError is not null) + { +
@_ratingSourceError
+ } +
+} +

Export the whole reference dataset (TV shows, movies, cast) as a zip, or re-import a previously @@ -439,14 +473,68 @@ private SystemStatusDto? _systemStatus; private string? _systemStatusError; + private List _ratingSources = []; + private bool _recomputing; + private RecomputeRatingsResultDto? _recomputeResult; + private ReferenceItemType? _recomputedDomain; + private string? _ratingSourceError; + protected override async Task OnInitializedAsync() { _bookProviders = await Api.GetBookProvidersAsync(); _selectedProvider = _bookProviders.FirstOrDefault()?.Key; + _ratingSources = await Api.GetRatingSourcesAsync(); await LoadUnresolvedAsync(); await LoadSystemStatusAsync(); } + private static string DomainLabel(ReferenceItemType domain) => domain switch + { + ReferenceItemType.TvShow => "TV shows", + ReferenceItemType.Movie => "Movies", + ReferenceItemType.Book => "Books", + ReferenceItemType.VideoGame => "Video games", + ReferenceItemType.Album => "Albums", + _ => domain.ToString() + }; + + private async Task SelectRatingSourceAsync(RatingSourceOptionDto option, string source) + { + if (option.SelectedSource == source) return; + + _ratingSourceError = null; + _recomputeResult = null; + try + { + await Api.SetRatingSourceAsync(option.Domain, source); + option.SelectedSource = source; + } + catch (Exception ex) + { + _ratingSourceError = ex.Message; + } + } + + private async Task RecomputeRatingsAsync(ReferenceItemType domain) + { + _recomputing = true; + _ratingSourceError = null; + _recomputeResult = null; + _recomputedDomain = domain; + try + { + _recomputeResult = await Api.RecomputeRatingsAsync(domain); + } + catch (Exception ex) + { + _ratingSourceError = ex.Message; + } + finally + { + _recomputing = false; + } + } + private async Task LoadSystemStatusAsync() { _loadingSystemStatus = true; diff --git a/src/Domain/Repositories/IAppSettingRepository.cs b/src/Domain/Repositories/IAppSettingRepository.cs new file mode 100644 index 00000000..9ab9b9f5 --- /dev/null +++ b/src/Domain/Repositories/IAppSettingRepository.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Keeptrack.Domain.Repositories; + +///

+/// Global, owner-less application settings an admin can change at runtime, all kept in one shared +/// app_setting collection (a single document) rather than a collection per setting - a new setting +/// adds a field/accessor here, never a new collection. Purpose-built like , +/// so it doesn't extend the owner-scoped . +/// +public interface IAppSettingRepository +{ + /// + /// The admin's per-domain primary rating-source overrides, keyed by domain (empty when none has been + /// set) - which provider score is denormalized onto tenant items as the pill/sort value. A domain with + /// no entry falls back to its code default (see RatingSourceCatalog). + /// + Task> GetReferenceRatingSourcesAsync(); + + /// + /// Sets (or replaces) the primary rating source for one domain. Upserts the single settings document. + /// + Task SetReferenceRatingSourceAsync(string domainKey, string source); +} diff --git a/src/Infrastructure.MongoDb/Entities/AppSetting.cs b/src/Infrastructure.MongoDb/Entities/AppSetting.cs new file mode 100644 index 00000000..098807c6 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/AppSetting.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// The single global-settings document (see ). One shared +/// collection holds every admin-configurable global setting as its own field, rather than a collection per +/// setting - a new setting is a new field here. +/// +public class AppSetting +{ + /// Well-known fixed id of the one settings document. + [BsonId] + public required string Id { get; set; } + + /// Domain key (e.g. VideoGame) → primary rating source key (e.g. metacritic). + [BsonElement("reference_rating_source")] + public Dictionary ReferenceRatingSources { get; set; } = new(); +} diff --git a/src/Infrastructure.MongoDb/Repositories/AppSettingRepository.cs b/src/Infrastructure.MongoDb/Repositories/AppSettingRepository.cs new file mode 100644 index 00000000..b9c87ef0 --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/AppSettingRepository.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +/// +/// A single settings document (fixed _id) holding every global admin setting - see +/// for why this is one shared collection, not one per setting. +/// +public class AppSettingRepository(IMongoDatabase mongoDatabase) : IAppSettingRepository +{ + private const string CollectionName = "app_setting"; + + /// Fixed id of the one settings document, so every read/write targets the same row. + private const string GlobalId = "global"; + + private IMongoCollection Collection => mongoDatabase.GetCollection(CollectionName); + + public async Task> GetReferenceRatingSourcesAsync() + { + var entity = await Collection.Find(s => s.Id == GlobalId).FirstOrDefaultAsync(); + return entity?.ReferenceRatingSources ?? new Dictionary(); + } + + public async Task SetReferenceRatingSourceAsync(string domainKey, string source) + { + // targets just the one map entry so unrelated settings on the same document are never overwritten; + // upsert creates the document (with _id = "global" taken from the filter) the first time. + var update = Builders.Update.Set($"reference_rating_source.{domainKey}", source); + await Collection.UpdateOneAsync(s => s.Id == GlobalId, update, new UpdateOptions { IsUpsert = true }); + } +} diff --git a/src/WebApi.Contracts/Dto/RatingSourceOptionDto.cs b/src/WebApi.Contracts/Dto/RatingSourceOptionDto.cs new file mode 100644 index 00000000..7ce6a4a5 --- /dev/null +++ b/src/WebApi.Contracts/Dto/RatingSourceOptionDto.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// A domain whose primary rating source an admin can choose, plus its selectable sources and the currently +/// selected one - see GET /api/reference-data/rating-sources. +/// +public class RatingSourceOptionDto +{ + /// The domain this option applies to (e.g. video games). + public ReferenceItemType Domain { get; set; } + + /// The source keys that can be picked as primary for this domain, e.g. "rawg"/"metacritic". + public required List AvailableSources { get; set; } + + /// The source currently used as primary - the admin override, or the code default when none is set. + public required string SelectedSource { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/RecomputeRatingsResultDto.cs b/src/WebApi.Contracts/Dto/RecomputeRatingsResultDto.cs new file mode 100644 index 00000000..0e8a803f --- /dev/null +++ b/src/WebApi.Contracts/Dto/RecomputeRatingsResultDto.cs @@ -0,0 +1,14 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Outcome of re-applying a domain's primary rating source to every linked tenant item - see +/// POST /api/reference-data/rating-sources/{domain}/recompute. +/// +public class RecomputeRatingsResultDto +{ + /// How many reference documents were read and re-propagated. + public int ReferencesChecked { get; set; } + + /// How many tenant items had their denormalized rating scalar updated. + public long ItemsUpdated { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/SetRatingSourceRequestDto.cs b/src/WebApi.Contracts/Dto/SetRatingSourceRequestDto.cs new file mode 100644 index 00000000..84bb5887 --- /dev/null +++ b/src/WebApi.Contracts/Dto/SetRatingSourceRequestDto.cs @@ -0,0 +1,10 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Sets a domain's primary rating source - see PUT /api/reference-data/rating-sources/{domain}. +/// +public class SetRatingSourceRequestDto +{ + /// The source key to make primary; must be one of the domain's available sources. + public required string Source { get; set; } +} diff --git a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 485ed56c..861174f3 100644 --- a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -59,6 +59,7 @@ internal static void AddMongoDbInfrastructure(this IServiceCollection services, services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/WebApi/ReferenceData/RatingSourceCatalog.cs b/src/WebApi/ReferenceData/RatingSourceCatalog.cs new file mode 100644 index 00000000..29356df0 --- /dev/null +++ b/src/WebApi/ReferenceData/RatingSourceCatalog.cs @@ -0,0 +1,40 @@ +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// The single declaration of which rating sources a domain can pick a primary from, and the code default +/// when an admin hasn't chosen one. Only domains with more than one source are worth making selectable; +/// today that's just video games (RAWG vs Metacritic). When movies/TV gain a second source (IMDb, phase 2) +/// they get an entry here and reuse the same admin-selection + recompute mechanism with no other change. +/// Keeping defaults and available options in one place is what lets the enrichment service and the admin +/// endpoints agree without duplicating that knowledge. +/// +public static class RatingSourceCatalog +{ + /// RAWG's own 0-5 user score - the safer video-game default (usually present). + public const string Rawg = "rawg"; + + /// Metacritic's 0-100 critic score - frequently absent on RAWG for smaller/older games. + public const string Metacritic = "metacritic"; + + // per domain, the selectable source keys; the first is the code default. + private static readonly IReadOnlyDictionary> s_sources = + new Dictionary> + { + [ReferenceItemType.VideoGame] = [Rawg, Metacritic] + }; + + /// Domains whose primary rating source an admin can choose (those with more than one source). + public static IReadOnlyList SelectableDomains => s_sources.Keys.ToList(); + + /// Whether 's primary rating source is admin-selectable. + public static bool IsSelectable(ReferenceItemType domain) => s_sources.ContainsKey(domain); + + /// The selectable source keys for (empty if not selectable). + public static IReadOnlyList AvailableSources(ReferenceItemType domain) => + s_sources.TryGetValue(domain, out var sources) ? sources : []; + + /// The code default source for (used when no admin override is set). + public static string DefaultSource(ReferenceItemType domain) => + AvailableSources(domain).FirstOrDefault() + ?? throw new ArgumentOutOfRangeException(nameof(domain), $"No rating sources are declared for {domain}."); +} diff --git a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs index 9da484fc..8ab7be73 100644 --- a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs +++ b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs @@ -36,7 +36,8 @@ public class ReferenceDataAdminController( IPersonReferenceRepository personReferenceRepository, IBookReferenceRepository bookReferenceRepository, IVideoGameReferenceRepository videoGameReferenceRepository, - IAlbumReferenceRepository albumReferenceRepository) : ControllerBase + IAlbumReferenceRepository albumReferenceRepository, + IAppSettingRepository appSettingRepository) : ControllerBase { private const string TvShowEntryName = "tvshow_reference.json"; private const string MovieEntryName = "movie_reference.json"; @@ -229,6 +230,63 @@ private async Task RunSyncJobAsync(Guid jobId) public ActionResult> GetBookProviders() => Ok(bookReferenceClientRegistry.All.Select(c => new BookProviderDto { Key = c.ProviderKey, DisplayName = c.DisplayName }).ToList()); + /// + /// Every domain whose primary rating source (the score shown as the pill / used for the "Ref ★" sort) is + /// admin-selectable, with its available sources and the one currently selected. Only domains with more + /// than one source appear - today just video games (RAWG vs Metacritic). + /// + [HttpGet("rating-sources")] + [ProducesResponseType(200)] + public async Task>> GetRatingSources() + { + var options = new List(); + foreach (var domain in RatingSourceCatalog.SelectableDomains) + { + options.Add(new RatingSourceOptionDto + { + Domain = domain, + AvailableSources = RatingSourceCatalog.AvailableSources(domain).ToList(), + SelectedSource = await enrichmentService.GetPrimaryRatingSourceAsync(domain) + }); + } + + return Ok(options); + } + + /// + /// Sets a domain's primary rating source. Only stores the choice - existing linked items keep their old + /// denormalized rating until re-applies it, so a switch is visible + /// and deliberate rather than silently reshuffling every list. + /// + [HttpPut("rating-sources/{domain}")] + [ProducesResponseType(204)] + [ProducesResponseType(400)] + public async Task SetRatingSource(ReferenceItemType domain, [FromBody] SetRatingSourceRequestDto request) + { + // ArgumentException maps to a 400 via ApiExceptionFilterAttribute + if (!RatingSourceCatalog.AvailableSources(domain).Contains(request.Source)) + { + throw new ArgumentException($"'{request.Source}' is not a selectable rating source for {domain}.", nameof(request)); + } + + await appSettingRepository.SetReferenceRatingSourceAsync(domain.ToString(), request.Source); + return NoContent(); + } + + /// + /// Re-applies a domain's current primary rating source to every already-linked tenant item - a single + /// bulk pass over the (small, shared) reference collection, no provider calls. Run after switching the + /// source via so the list pills and sort reflect the new choice. + /// + [HttpPost("rating-sources/{domain}/recompute")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task> RecomputeRatingSource(ReferenceItemType domain) + { + var (referencesChecked, itemsUpdated) = await enrichmentService.RecomputeReferenceRatingsAsync(domain); + return Ok(new RecomputeRatingsResultDto { ReferencesChecked = referencesChecked, ItemsUpdated = itemsUpdated }); + } + /// /// Distinct (title, year) pairs, across every tenant, still missing a reference-data link. Book is /// handled separately since it's the only domain whose FindDistinctUnresolvedTitleYearsAsync diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs index e9dbf5bf..6e6ed17f 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs @@ -5,10 +5,13 @@ namespace Keeptrack.WebApi.ReferenceData; public partial class ReferenceEnrichmentService { - /// The source denormalized onto the tenant game as the primary (list/sort) value - RAWG's own 0-5 user score. - private const string RawgRatingSource = "rawg"; + // the two RAWG-provided rating source keys (see RatingSourceCatalog, the single home for these literals); + // used here purely as the Ratings-dict keys when building the map. Which of the two is the *primary* + // (denormalized onto the tenant game) is now resolved per-domain via GetPrimaryRatingSourceAsync, not + // hardcoded here. + private const string RawgRatingSource = RatingSourceCatalog.Rawg; - private const string MetacriticRatingSource = "metacritic"; + private const string MetacriticRatingSource = RatingSourceCatalog.Metacritic; /// /// Builds the reference Ratings map from RAWG's aggregates: RAWG's own 0-5 user score (the @@ -66,7 +69,7 @@ public async Task TryLinkExistingVideoGameReferenceAsync(VideoGa var originalTitle = model.Title; var originalYear = model.Year; - var (ratingValue, ratingScale) = PrimaryRating(reference.Ratings, RawgRatingSource); + var (ratingValue, ratingScale) = PrimaryRating(reference.Ratings, await GetPrimaryRatingSourceAsync(ReferenceItemType.VideoGame)); model.ReferenceId = reference.Id; model.Title = reference.Title; @@ -151,7 +154,7 @@ public async Task ResolveVideoGameAsync(string title, i }; var saved = await videoGameReferenceRepository.UpsertAsync(model); - var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, RawgRatingSource); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, await GetPrimaryRatingSourceAsync(ReferenceItemType.VideoGame)); await videoGameRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, ratingValue, ratingScale); return saved; } @@ -181,7 +184,7 @@ public async Task ResolveVideoGameAsync(string title, i reference.LastEnrichedAt = DateTime.UtcNow; var saved = await videoGameReferenceRepository.UpsertAsync(reference); - var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, RawgRatingSource); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, await GetPrimaryRatingSourceAsync(ReferenceItemType.VideoGame)); await videoGameRepository.SetReferenceRatingAsync(saved.Id!, ratingValue, ratingScale); return (saved, true); } diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs index a8dd20c0..5bb3ee91 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs @@ -29,8 +29,69 @@ public partial class ReferenceEnrichmentService( IMovieRepository movieRepository, IBookRepository bookRepository, IVideoGameRepository videoGameRepository, - IAlbumRepository albumRepository) + IAlbumRepository albumRepository, + IAppSettingRepository appSettingRepository) { + /// + /// The primary rating source for - the source whose value/scale is denormalized + /// onto tenant items as the list pill / sort value. The admin's stored override (see + /// ) when it names a source the catalog still offers, otherwise the + /// code default (). An override naming a source no longer + /// available is ignored rather than trusted, so removing a source from the catalog can't strand an item. + /// + public async Task GetPrimaryRatingSourceAsync(ReferenceItemType domain) + { + var overrides = await appSettingRepository.GetReferenceRatingSourcesAsync(); + return overrides.TryGetValue(domain.ToString(), out var stored) && RatingSourceCatalog.AvailableSources(domain).Contains(stored) + ? stored + : RatingSourceCatalog.DefaultSource(domain); + } + + /// + /// Re-applies the current primary rating source (see ) to every + /// already-linked tenant item across a domain, re-stamping the denormalized + /// ReferenceRating/ReferenceRatingScale scalar from each reference document's Ratings + /// dict. Backs the admin "recompute" action after switching a domain's source - one bulk + /// SetReferenceRatingAsync pass per reference, no provider calls (unlike the full sync). Small, + /// shared reference collection, so this runs synchronously rather than as a background job. + /// + public async Task<(int ReferencesChecked, long ItemsUpdated)> RecomputeReferenceRatingsAsync(ReferenceItemType domain) + { + var source = await GetPrimaryRatingSourceAsync(domain); + return domain switch + { + ReferenceItemType.VideoGame => await RecomputeReferenceRatingsAsync( + videoGameReferenceRepository.FindAllAsync, + r => (r.Id!, r.Ratings), + videoGameRepository.SetReferenceRatingAsync, + source), + _ => throw new ArgumentOutOfRangeException(nameof(domain), $"Rating source is not admin-selectable for {domain}.") + }; + } + + /// + /// Domain-agnostic recompute loop - each domain only differs in which reference repository it reads and + /// which tenant repository it re-propagates through, so the iteration itself lives once here (adding + /// movies/TV/albums later is a one-line switch arm above, never a copy of this loop). + /// + private static async Task<(int ReferencesChecked, long ItemsUpdated)> RecomputeReferenceRatingsAsync( + Func>> findAll, + Func Ratings)> project, + Func> setRating, + string source) + { + var references = await findAll(); + long updated = 0; + foreach (var reference in references) + { + var (id, ratings) = project(reference); + var (value, scale) = PrimaryRating(ratings, source); + updated += await setRating(id, value, scale); + } + + return (references.Count, updated); + } + /// /// Combines whatever (title, year, creator, isbn) combinations a reference document already remembered /// with the new ones just confirmed (e.g. the provider's canonical (title, year) and the (title, year) diff --git a/test/WebApi.UnitTests/ReferenceData/RatingSourceCatalogTest.cs b/test/WebApi.UnitTests/ReferenceData/RatingSourceCatalogTest.cs new file mode 100644 index 00000000..77510cb2 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/RatingSourceCatalogTest.cs @@ -0,0 +1,33 @@ +using AwesomeAssertions; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.ReferenceData; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +[Trait("Category", "UnitTests")] +public class RatingSourceCatalogTest +{ + [Fact] + public void VideoGames_OfferRawgAndMetacritic_WithRawgAsTheDefault() + { + RatingSourceCatalog.AvailableSources(ReferenceItemType.VideoGame) + .Should().Equal(RatingSourceCatalog.Rawg, RatingSourceCatalog.Metacritic); + RatingSourceCatalog.DefaultSource(ReferenceItemType.VideoGame).Should().Be(RatingSourceCatalog.Rawg); + RatingSourceCatalog.IsSelectable(ReferenceItemType.VideoGame).Should().BeTrue(); + RatingSourceCatalog.SelectableDomains.Should().Contain(ReferenceItemType.VideoGame); + } + + [Theory] + [InlineData(ReferenceItemType.Movie)] + [InlineData(ReferenceItemType.TvShow)] + [InlineData(ReferenceItemType.Book)] + [InlineData(ReferenceItemType.Album)] + public void SingleSourceDomains_AreNotYetAdminSelectable(ReferenceItemType domain) + { + // these have only one source today, so there's nothing to choose - they join the catalog when they + // gain a second source (e.g. IMDb for movies/TV, phase 2). + RatingSourceCatalog.IsSelectable(domain).Should().BeFalse(); + RatingSourceCatalog.AvailableSources(domain).Should().BeEmpty(); + } +} diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs index fb2c6f65..ab7a4f18 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs @@ -6,6 +6,7 @@ using AwesomeAssertions; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Contracts.Dto; using Keeptrack.WebApi.ReferenceData; using Moq; using Xunit; @@ -26,8 +27,17 @@ public class ReferenceEnrichmentServiceTest private readonly Mock _bookRepository = new(); private readonly Mock _videoGameRepository = new(); private readonly Mock _albumRepository = new(); + private readonly Mock _appSettingRepository = new(); private readonly FakeBookRatingByIsbnLookup _bookRatingByIsbnLookup = new(); + public ReferenceEnrichmentServiceTest() + { + // default: no admin override, so every domain resolves to its code default (see RatingSourceCatalog). + // individual tests override this to exercise a stored primary-source choice. + _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()) + .ReturnsAsync(new Dictionary()); + } + /// /// The registry always has "openlibrary" as the deployment default - matches FakeBookReferenceClient's /// hardcoded ProviderKey, so every existing test that never mentions a provider keeps resolving the @@ -47,7 +57,8 @@ private ReferenceEnrichmentService CreateService( rawgClient ?? FakeRawgClient.Empty(), discogsClient ?? FakeDiscogsClient.Empty(), _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _personReferenceRepository.Object, _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, - _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object); + _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object, + _appSettingRepository.Object); [Fact] public async Task TryAutoResolveTvShowAsync_DoesNothing_WhenSearchReturnsNoResults() @@ -936,6 +947,116 @@ public async Task ResolveVideoGameAsync_PropagatesTheUpsertedReferenceId() _videoGameRepository.Verify(r => r.SetReferenceLinkAsync("Some Game", 2020, "reference-1", "Some Game", It.IsAny()), Times.Once); } + // --- Admin-selectable primary rating source (RatingSourceCatalog + IAppSettingRepository) --- + + [Fact] + public async Task GetPrimaryRatingSourceAsync_ReturnsCodeDefault_WhenNoOverrideIsStored() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + + var source = await service.GetPrimaryRatingSourceAsync(ReferenceItemType.VideoGame); + + source.Should().Be(RatingSourceCatalog.Rawg); + } + + [Fact] + public async Task GetPrimaryRatingSourceAsync_ReturnsTheOverride_WhenAnAdminHasSetAValidSource() + { + _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()) + .ReturnsAsync(new Dictionary { ["VideoGame"] = RatingSourceCatalog.Metacritic }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + + var source = await service.GetPrimaryRatingSourceAsync(ReferenceItemType.VideoGame); + + source.Should().Be(RatingSourceCatalog.Metacritic); + } + + [Fact] + public async Task GetPrimaryRatingSourceAsync_FallsBackToTheDefault_WhenTheStoredOverrideIsNoLongerAvailable() + { + _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()) + .ReturnsAsync(new Dictionary { ["VideoGame"] = "some-removed-source" }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + + var source = await service.GetPrimaryRatingSourceAsync(ReferenceItemType.VideoGame); + + source.Should().Be(RatingSourceCatalog.Rawg); + } + + [Fact] + public async Task ResolveVideoGameAsync_DenormalizesMetacritic_WhenItIsTheSelectedPrimarySource() + { + _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()) + .ReturnsAsync(new Dictionary { ["VideoGame"] = RatingSourceCatalog.Metacritic }); + var rawgClient = FakeRawgClient.Empty(); + rawgClient.Details["1"] = new RawgGameDetails("1", "Some Game", 2020, "Synopsis", [], [], null, 4.0, 100, 90); + _videoGameReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((VideoGameReferenceModel m) => { m.Id = "reference-1"; return m; }); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults(), rawgClient: rawgClient); + + await service.ResolveVideoGameAsync("Some Game", 2020, "1"); + + // Metacritic is /100, RAWG's own score is /5 - the selected source drives which pair is denormalized. + _videoGameRepository.Verify(r => r.SetReferenceLinkAsync("Some Game", 2020, "reference-1", "Some Game", It.IsAny(), 90, 100), Times.Once); + } + + [Fact] + public async Task RecomputeReferenceRatingsAsync_ReStampsEveryLinkedItem_WithTheSelectedSourcesRating() + { + _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()) + .ReturnsAsync(new Dictionary { ["VideoGame"] = RatingSourceCatalog.Metacritic }); + _videoGameReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync( + [ + VideoGameReferenceWithRatings("r1", rawg: 4.5, metacritic: 90), + VideoGameReferenceWithRatings("r2", rawg: 3.0, metacritic: 60) + ]); + _videoGameRepository.Setup(r => r.SetReferenceRatingAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(1); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + + var (referencesChecked, itemsUpdated) = await service.RecomputeReferenceRatingsAsync(ReferenceItemType.VideoGame); + + referencesChecked.Should().Be(2); + itemsUpdated.Should().Be(2); + _videoGameRepository.Verify(r => r.SetReferenceRatingAsync("r1", 90, 100), Times.Once); + _videoGameRepository.Verify(r => r.SetReferenceRatingAsync("r2", 60, 100), Times.Once); + } + + [Fact] + public async Task RecomputeReferenceRatingsAsync_UsesTheCodeDefaultSource_WhenNoOverrideIsStored() + { + _videoGameReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync( + [ + VideoGameReferenceWithRatings("r1", rawg: 4.5, metacritic: 90) + ]); + _videoGameRepository.Setup(r => r.SetReferenceRatingAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(1); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + + await service.RecomputeReferenceRatingsAsync(ReferenceItemType.VideoGame); + + // RAWG is the default: its /5 score, not Metacritic's /100. + _videoGameRepository.Verify(r => r.SetReferenceRatingAsync("r1", 4.5, 5), Times.Once); + } + + [Fact] + public async Task RecomputeReferenceRatingsAsync_Throws_ForADomainWhoseSourceIsNotAdminSelectable() + { + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + + await Assert.ThrowsAsync(() => service.RecomputeReferenceRatingsAsync(ReferenceItemType.Movie)); + } + + private static VideoGameReferenceModel VideoGameReferenceWithRatings(string id, double rawg, double metacritic) => new() + { + Id = id, + Title = "Some Game", + TitleNormalized = "some game", + ExternalIds = [], + Ratings = new Dictionary + { + [RatingSourceCatalog.Rawg] = new() { Value = rawg, Scale = 5, Count = 100 }, + [RatingSourceCatalog.Metacritic] = new() { Value = metacritic, Scale = 100 } + } + }; + [Fact] public async Task TryLinkExistingVideoGameReferenceAsync_LinksAndUpdatesTitle_OnTitleYearMatch() { @@ -1253,7 +1374,8 @@ public async Task RefreshAlbumReferenceAsync_AlwaysRefetches_RegardlessOfLastEnr new Mock(MockBehavior.Strict).Object, _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _personReferenceRepository.Object, _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, - _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object); + _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object, + _appSettingRepository.Object); [Theory] [InlineData("")] diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs index 9ec7b76b..18478043 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs @@ -36,11 +36,14 @@ private ReferenceSyncService CreateService(FakeTmdbClient tmdbClient) var bookRatingLookup = new Mock(); bookRatingLookup.Setup(x => x.GetRatingByIsbnAsync(It.IsAny(), It.IsAny())).ReturnsAsync(((double?)null, (int?)null)); + var appSettingRepository = new Mock(); + appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()).ReturnsAsync(new Dictionary()); var enrichmentService = new ReferenceEnrichmentService( tmdbClient, new BookReferenceClientRegistry([FakeBookReferenceClient.Empty()], "openlibrary"), bookRatingLookup.Object, FakeRawgClient.Empty(), FakeDiscogsClient.Empty(), _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _personReferenceRepository.Object, _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, - _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object); + _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object, + appSettingRepository.Object); return new ReferenceSyncService( _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, From bee098ab0ab3229cd5b80f0ee2dbefc7fe3e4990 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 30 Jul 2026 17:07:01 +0200 Subject: [PATCH 37/80] Increment 2 adding IMDB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IMDb ratings now flow into movies/TV, admin-selectable for the list pill + Ref ★ sort: - TMDB → IMDb id, zero extra calls: movie imdb_id is native on /movie/{id}; TV uses ?append_to_response=external_ids (folded into the existing details call, no season fan-out). Stored in the reference's ExternalIds["imdb"]. - OMDb fetch: one call per reference via the increment-1 client; imdb lands in the reference Ratings dict on the /10 scale (same as tmdb), with vote count. - Admin-selectable primary source (your refinement): RatingSourceCatalog now has Movie/TvShow = [tmdb, imdb] (default tmdb). Both appear automatically in the existing "Primary rating source" admin card; the choice drives the denormalized scalar = list/grid pill and Ref ★ sort. Recompute arms added for both. Added an IMDb line to the card's caveat text. - Cheap backfill on the /changes short-circuit: when TMDB reports no change, a missing imdb rating is backfilled with one OMDb call (from the stored id) — never the expensive TMDB re-fetch; skipped entirely once imdb is present. Shared TryBackfillImdbRatingAsync helper so movie/TV don't duplicate it. - Graceful/optional throughout: no OMDb key ⇒ imdb simply never appears, movies/TV behave exactly as before. Tests: +6 (imdb add + id storage, id-stored-even-without-rating, imdb-as-selected-primary denormalization, cheap backfill without re-fetch, no-OMDb-call-when-present, movies recompute). Two existing catalog/recompute tests updated for the intended "Movie/TvShow now selectable" change. Full suite 325 green; whole solution builds. - Rating-source picker → dropdown (ReferenceDataAdminPage.razor, the "Primary rating source" card): the per-domain source is now a single form-select dropdown instead of buttons — one uniform-width control, so no more different-sized buttons. Options read "TMDB / IMDb" and "RAWG / Metacritic"; selecting one still triggers the same save, with Recompute unchanged beside it. - Reverted the out-of-scope changes: the book-provider picker is back to its original buttons, ReferenceRatings.razor is back to its original inline label map, and I deleted the shared ReferenceSourceLabels helper I'd wrongly introduced. The SourceLabel used by the dropdown is now self-contained in the admin page. - Fixed the one CLAUDE.md paragraph to describe the dropdown (docs only). --- CLAUDE.md | 16 +- docs/reference-ratings-plan.md | 34 +++- .../ReferenceDataAdminPage.razor | 21 +- src/WebApi/AppConfiguration.cs | 7 + src/WebApi/Program.cs | 8 + src/WebApi/ReferenceData/IOmdbClient.cs | 19 ++ src/WebApi/ReferenceData/ITmdbClient.cs | 16 +- src/WebApi/ReferenceData/OmdbClient.cs | 48 +++++ src/WebApi/ReferenceData/OmdbSettings.cs | 12 ++ .../ReferenceData/RatingSourceCatalog.cs | 8 + ...renceEnrichmentService.TvShowsAndMovies.cs | 118 +++++++++-- .../ReferenceEnrichmentService.cs | 11 + src/WebApi/ReferenceData/TmdbClient.cs | 33 ++- src/WebApi/appsettings.json | 3 + .../ReferenceData/FakeOmdbClient.cs | 26 +++ .../ReferenceData/OmdbClientTest.cs | 87 ++++++++ .../ReferenceData/RatingSourceCatalogTest.cs | 11 +- .../ReferenceEnrichmentServiceTest.cs | 191 +++++++++++++++++- .../ReferenceData/ReferenceSyncServiceTest.cs | 8 +- 19 files changed, 636 insertions(+), 41 deletions(-) create mode 100644 src/WebApi/ReferenceData/IOmdbClient.cs create mode 100644 src/WebApi/ReferenceData/OmdbClient.cs create mode 100644 src/WebApi/ReferenceData/OmdbSettings.cs create mode 100644 test/WebApi.UnitTests/ReferenceData/FakeOmdbClient.cs create mode 100644 test/WebApi.UnitTests/ReferenceData/OmdbClientTest.cs diff --git a/CLAUDE.md b/CLAUDE.md index eb1f7bca..1ffcc14a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -430,9 +430,21 @@ There's no in-app way to grant the first admin; it's a one-off `setCustomUserCla Global admin settings an admin changes at runtime (as opposed to deploy-time config in `appsettings`/env vars) live in one shared `app_setting` collection - a single document (`_id: "global"`), one field per setting. `IAppSettingRepository`/`AppSettingRepository` is the purpose-built accessor (like `LeaseRepository`, it doesn't extend the owner-scoped `IDataRepository`), writing with a targeted `$set` on just the one field so unrelated settings on the same document are never clobbered. Reach for this - a new field/accessor here, not a new collection - for any future runtime-changeable global setting; use `AppConfiguration`/env vars only for values that are fine to change at deploy time. -Its first use is the admin-selectable **primary rating source** (which provider score is denormalized onto a tenant item as the list/sort rating): `RatingSourceCatalog` declares each domain's selectable sources + code default (only video games have more than one today - RAWG vs Metacritic), +Its first use is the admin-selectable **primary rating source** (which provider score is denormalized onto a tenant item as the list/sort rating): `RatingSourceCatalog` declares each domain's selectable sources + code default (video games RAWG vs Metacritic, movies/TV TMDB vs IMDb - the two multi-source domains today, default `rawg`/`tmdb`), `ReferenceEnrichmentService.GetPrimaryRatingSourceAsync` reads the stored override-or-default, and `ReferenceDataAdminController`'s `rating-sources` GET/PUT plus a `.../recompute` POST (a synchronous bulk `SetReferenceRatingAsync` pass, no provider calls) let an admin switch it and re-propagate to every already-linked item. -The full design (two homes for a rating, propagation, this admin mechanism, and the pending IMDb/top-rated phases) is tracked in `docs/reference-ratings-plan.md` until the feature settles. +The admin card, endpoints, and recompute loop are all domain-generic (they iterate `RatingSourceCatalog.SelectableDomains`), so a domain gaining a second source only needs a catalog entry plus routing its `PrimaryRating` call sites through `GetPrimaryRatingSourceAsync` - no controller/UI change (this is exactly how movies/TV joined when IMDb landed). + +**Movies/TV get their IMDb rating from OMDb** (`IOmdbClient`/`OmdbClient`, `WebApi/ReferenceData/`), keyed by the IMDb id TMDB already exposes - IMDb itself has no public ratings API, so this is the sanctioned path (directly analogous to the book ISBN→Open Library rating fallback: TMDB plays Google Books' role of handing off the cross-provider identifier, OMDb plays Open Library's role of turning it into a rating, stored under its own `imdb` source key on the same 0-10 scale as `tmdb`). +The IMDb id is native on `/movie/{id}` (`imdb_id`, zero extra calls) but not on `/tv/{id}` - the TV details call appends it via `?append_to_response=external_ids` (still one call, no season fan-out), and it's stored in the reference's `ExternalIds["imdb"]`. +**OMDb is optional/best-effort**: unlike every other provider's settings, `OmdbSettings.ApiKey` is nullable (not `required`), and `AppConfiguration.OmdbSettings` coalesces a missing `Omdb` section to an empty instance - a deployment with no `Omdb__ApiKey` simply keeps movies/TV on their TMDB rating alone rather than failing resolution/refresh, and the integration/e2e hosts need no OMDb key. + +**Gotcha (IMDb backfill bootstrap):** the TMDB `/changes` short-circuit (`LastEnrichedAt is not null && Ratings.Count > 0`) skips the full re-fetch once a `tmdb` rating exists, so a reference enriched before IMDb existed would never backfill an `imdb` one - it has a tmdb rating (so it short-circuits) but no stored imdb id (only a full fetch writes that), a chicken-and-egg the first version hit against a real dev database (only the handful of references that happened to full-fetch got IMDb). +Fixed by `BackfillImdbRatingAsync` on the no-change path: when the imdb rating is missing it resolves the imdb id cheaply via TMDB's dedicated `/{tv,movie}/{id}/external_ids` endpoint (`ITmdbClient.GetTvShowImdbIdAsync`/`GetMovieImdbIdAsync` - one call, **no** season fan-out, deliberately not the full details re-fetch the short-circuit avoids), stores it, then does the one OMDb call. +Self-correcting: once the id is stored, later syncs skip the external-ids lookup; a title OMDb genuinely has no rating for just retries one cheap OMDb call per full sync rather than needing a persisted "attempted" marker. +`VideoGameModel.Platform`/`State`-style "this tenant's own copy" fields are never touched by any of this - only the shared reference document and the denormalized scalar. +The full design (two homes for a rating, propagation, this admin mechanism, the IMDb phase above, and the pending top-rated phase) is tracked in `docs/reference-ratings-plan.md` until the feature settles. + +`ReferenceDataAdminPage.razor`'s per-domain primary-rating-source picker is a `form-select` dropdown (`SourceLabel` maps the lowercase source key to a display name - `rawg`→"RAWG", `imdb`→"IMDb", ...), not a button row - a dropdown is one uniform-width control, whereas per-source buttons render at different widths by text length ("RAWG" vs "Metacritic"). The app is meant to be publicly shareable: anyone can sign in (Google/GitHub via Firebase Auth), but a plain account with **no** `role` claim is a *free preview* tier. Free tier = movies and TV shows only, capped at `Features:FreeTierItemLimit` creations per collection (default 20, guarded in `AppConfiguration.GetFreeTierItemLimit` so a missing setting can never lock the tier out entirely); diff --git a/docs/reference-ratings-plan.md b/docs/reference-ratings-plan.md index a07ebeef..2408be71 100644 --- a/docs/reference-ratings-plan.md +++ b/docs/reference-ratings-plan.md @@ -6,8 +6,9 @@ Provider ("reference") ratings for tracked items - the linked reference's own ag Phase 1 is implemented across all five reference-bearing media types (Movie, TV show, Video game, Album, Book). Next-step 1 (admin-selectable primary rating source) is also implemented, wired for video games as its first case - see "Admin-selectable primary source" below. -Build is green; the WebApi unit suite passes (315 tests, including the new rating and rating-source tests); the Movies slice also has real-MongoDB integration coverage. -The user has confirmed both the Phase 1 display/mechanics and the admin-selectable source switch (RAWG↔Metacritic + recompute) work in the running app. +Phase 2 (IMDb ratings for movies/TV, via OMDb) is also implemented - movies/TV are now the second multi-source domain and reuse the step-1 admin-selection mechanism unchanged (TMDB vs IMDb) - see "Phase 2 - IMDb ratings" below. +Build is green; the WebApi unit suite passes (326 tests, including the new IMDb/OMDb, rating, and rating-source tests); the Movies slice also has real-MongoDB integration coverage. +The user has confirmed the Phase 1 display/mechanics, the admin-selectable source switch (RAWG↔Metacritic + recompute), and Phase 2 (IMDb ratings populating across movies/TV after a "Sync now") all work in the running app. ## End goal / solution design @@ -30,8 +31,8 @@ The tenant item already being per-user is what makes a future per-user "which so | Domain | Provider(s) → dict keys | Primary (pill + sort) | Scale | |--------|-------------------------|-----------------------|-------| -| Movie / TV show | TMDB `vote_average` → `tmdb` | `tmdb` | 10 | -| Video game | RAWG `rating` → `rawg`, RAWG `metacritic` → `metacritic` | `rawg` | 5 | +| Movie / TV show | TMDB `vote_average` → `tmdb`, OMDb `imdbRating` → `imdb` | admin-selectable, default `tmdb` | 10 | +| Video game | RAWG `rating` → `rawg`, RAWG `metacritic` → `metacritic` | admin-selectable, default `rawg` | 5 | | Album | Discogs community rating → `discogs` | `discogs` | 5 | | Book | linking provider → its key (`googlebooks`/`openlibrary`), plus OL fallback → `openlibrary` | the single stored entry | 5 | @@ -65,6 +66,20 @@ It is an admin-selectable, global (not per-user) setting, wired for video games - **UI:** a "Primary rating source" card on the reference-data admin page - per selectable domain, a source button-group plus a **separate** Recompute button (two deliberate actions, not one) showing the checked/updated counts. It spells out the Metacritic caveat: `/100` and often absent on RAWG, so switching to it blanks the pill for many games and sorts them last. +### Phase 2 - IMDb ratings (movies/TV, via OMDb) - implemented + +Movies/TV are now the second multi-source domain: alongside `tmdb` they carry an `imdb` entry on the same 0-10 scale, and their primary is admin-selectable through the exact step-1 mechanism (no new mechanism, no migration). + +- **Source: OMDb, keyed by the IMDb id TMDB exposes.** IMDb has no public ratings API, so OMDb (`IOmdbClient`/`OmdbClient`) is the sanctioned path - the same shape as the book ISBN→Open Library fallback (primary provider hands off a cross-provider identifier, a secondary provider turns it into a rating stored under its own key). + The IMDb id is native on `/movie/{id}` (`imdb_id`, zero extra calls); for TV the details call appends it via `?append_to_response=external_ids` (still one call, no season fan-out). Stored in `ExternalIds["imdb"]`. +- **OMDb is optional/best-effort.** `OmdbSettings.ApiKey` is nullable (not `required` like every other provider), and `AppConfiguration.OmdbSettings` coalesces a missing `Omdb` section to an empty instance; with no `Omdb__ApiKey` movies/TV just stay on their TMDB rating alone. `OmdbClient` no-ops with no key and treats OMDb's `"N/A"`/`Response:"False"` as "no rating" (never a stored 0). Config wiring adds `.AddProviderResilienceHandler()` like every other outbound client. The e2e/integration hosts need no OMDb key. +- **Catalog/resolver:** `RatingSourceCatalog` gains `[Movie] = [tmdb, imdb]` and `[TvShow] = [tmdb, imdb]` (default `tmdb`); the `"tmdb"`/`"imdb"` literals live there, and `.TvShowsAndMovies.cs`'s dict-key consts point at them. The movie/TV `PrimaryRating` call sites (resolve/refresh/link) now read `GetPrimaryRatingSourceAsync(Movie/TvShow)` instead of the old hardcoded `"tmdb"` const. `RecomputeReferenceRatingsAsync` gained one-line Movie/TvShow switch arms over the existing generic loop. Because the admin card/endpoints iterate `SelectableDomains`, movies/TV appeared in the "Primary rating source" UI with no controller/UI change. + +**IMDb backfill bootstrap gotcha (found against the real dev database):** the `/changes` short-circuit (`LastEnrichedAt is not null && Ratings.Count > 0`) skips the full re-fetch once a `tmdb` rating exists, so a reference enriched before Phase 2 has a tmdb rating (⇒ short-circuits) but no stored imdb id (only a full fetch writes that) - chicken-and-egg, so it never backfilled `imdb` (only the handful of references that happened to full-fetch got a rating - the first symptom the user reported: "only 1 TV show and 1 movie have an IMDb rating"). +Fixed by `BackfillImdbRatingAsync` on the no-change path: when the imdb rating is missing it resolves the imdb id cheaply via TMDB's dedicated `/{tv,movie}/{id}/external_ids` endpoint (`ITmdbClient.GetTvShowImdbIdAsync`/`GetMovieImdbIdAsync` - one call, **no** season fan-out, deliberately not the full details re-fetch the short-circuit avoids), stores it, then does the one OMDb call. +Self-correcting: once the id is stored, later syncs skip the external-ids lookup; a title OMDb genuinely has no rating for just retries one cheap OMDb call per full sync, no persisted "attempted" marker. +Backfilling onto existing data is just a "Sync now" (or waiting for the periodic pass), never a `scripts/*.js`. + ### Book cross-provider rating fallback Google Books (the default book provider) **no longer serves ratings at all** (confirmed against the live API - `averageRating` is absent even for The Hobbit / Harry Potter). @@ -82,7 +97,7 @@ It lives behind a dedicated one-method `IBookRatingByIsbnLookup` interface (impl - **Display:** bare number with a star (`★ 7.8`) in list/grid, no scale; fuller `value / scale source (count)` on the detail page. - **Sort label:** `Ref ★` (short, mobile). -- **Primary-source selection:** shipped in Phase 1 as a **code default per media type**; now **admin-selectable and global** (not per-user), wired for video games (RAWG vs Metacritic) - see "Admin-selectable primary source" above. +- **Primary-source selection:** shipped in Phase 1 as a **code default per media type**; now **admin-selectable and global** (not per-user), wired for video games (RAWG vs Metacritic) and movies/TV (TMDB vs IMDb) - see "Admin-selectable primary source" above. The code default is still the fallback when an admin hasn't chosen; per-user was ruled out because a switch must be a single bulk `SetReferenceRatingAsync` recompute per reference, which per-user couldn't be. - **Video games:** RAWG's 0-5 user score is the primary (usually present); Metacritic is stored as a second source and shown on the detail page but does not drive the pill/sort (frequently absent). @@ -99,7 +114,9 @@ It lives behind a dedicated one-method `IBookRatingByIsbnLookup` interface (impl - `ReferenceEnrichmentServiceTest` (unit, mocked): rating populate + primary-scalar propagation; no-rating-when-no-votes; TMDB backfill-forces-a-fetch; no-change short-circuit stays for an already-rated reference; TryLink sets the denormalized rating; the Open Library ISBN fallback fires for a non-OL provider and is skipped when the provider is OL. - `MovieReferenceRatingRepositoryTest` (integration, real MongoDB): the `ReferenceRating` sort ordering (unrated last), `SetReferenceLinkAsync` stamps only unlinked matches, `SetReferenceRatingAsync` re-propagates to every already-linked item, and the `Ratings` dict round-trips through BSON. - The other four types reuse the identical shared sort/propagation code the Movie tests cover; per-type integration tests were deliberately deferred (see next steps). -- `RatingSourceCatalogTest` (unit): video games offer RAWG+Metacritic with RAWG default; single-source domains are not yet selectable. +- `RatingSourceCatalogTest` (unit): video games offer RAWG+Metacritic (RAWG default), movies/TV offer TMDB+IMDb (TMDB default); the remaining single-source domains (Book/Album) are not yet selectable. +- `OmdbClientTest` (unit, stubbed handler): parses the rating + comma-separated vote count, returns null on `"N/A"`/unknown-id (`Response:"False"`), and makes no HTTP call when no key is configured. +- `ReferenceEnrichmentServiceTest` (unit, mocked) gained the IMDb slice: resolve adds the `imdb` rating and stores the imdb id in `ExternalIds` (even when OMDb has no rating yet, so the backfill has a key); imdb-as-selected-primary denormalizes imdb's value; the cheap no-change backfill adds imdb without a details re-fetch; the **bootstrap** case resolves a missing imdb id via the external-ids lookup then OMDb; no OMDb call when imdb is already present; movies recompute re-stamps with the selected source. - `ReferenceEnrichmentServiceTest` (unit, mocked) gained: the source resolver (default / valid override / invalid-override-falls-back-to-default); `ResolveVideoGameAsync` denormalizes Metacritic's `/100` when it's the selected source; `RecomputeReferenceRatingsAsync` re-stamps every linked item with the selected source's value+scale, uses the default when unset, and throws for a non-selectable domain. ## What to do next @@ -107,9 +124,8 @@ It lives behind a dedicated one-method `IBookRatingByIsbnLookup` interface (impl 1. **~~Admin-selectable primary source (video games: RAWG vs Metacritic).~~ Done** - implemented as a general per-domain, admin/global setting in the shared `app_setting` collection plus a recompute action, wired for video games. See "Admin-selectable primary source (per-domain, admin/global) - implemented" above for the full shape. The same mechanism covers IMDb-vs-TMDB for movies/TV in Phase 2 for free (just add a `RatingSourceCatalog` entry and route those `PrimaryRating` call sites through `GetPrimaryRatingSourceAsync`). -2. **Phase 2 - IMDb ratings for movies/TV.** Add an `imdb` source to the movie/TV `Ratings` dict. - IMDb has no public ratings API; options are OMDb (needs an API key, returns `imdbRating`/`imdbVotes`) or TMDB `external_ids` → IMDb id → a data source. - Once movies/TV have two sources, the primary is chosen via the admin-selectable mechanism from step 1 (the storage already supports it with no migration). +2. **~~Phase 2 - IMDb ratings for movies/TV.~~ Done** - `imdb` source added via OMDb (keyed by the IMDb id TMDB exposes), optional/graceful when no OMDb key, primary admin-selectable through the step-1 mechanism, with a cheap external-ids backfill for the `/changes` short-circuit. + See "Phase 2 - IMDb ratings (movies/TV, via OMDb) - implemented" above for the full shape and the backfill bootstrap gotcha. 3. **Phase 3 - "suggest N top-rated not-yet-added" feature.** Read a source, order the reference collection by `ratings..value` desc, skip titles the user already tracks, return the top N. This needs a supporting index on the chosen `ratings..value` path in `scripts/mongodb-create-index.js` (not added yet - phase 1 added no indexes because the per-owner tenant-side sort is small). 4. **Per-type integration + Playwright coverage.** Movies has real-Mongo integration coverage; add the equivalent for TV/game/album/book if desired (the logic is shared, so this is defense-in-depth, not filling a logic gap). The existing Book Playwright smoke test uses a synthetic seeded reference, so it does not exercise a real provider rating. diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor index 9a348f65..91ba2ad4 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor +++ b/src/BlazorApp/Components/ReferenceDataAdmin/ReferenceDataAdminPage.razor @@ -170,19 +170,21 @@ Which provider score is shown as the rating pill and drives the "Ref ★" sort. Changing the source only affects new links and syncs until you recompute - use Recompute to re-apply the selected source to every already-linked item at once. Note: Metacritic is out of 100 and often absent on - RAWG, so switching video games to it blanks the pill for many games and sorts them last. + RAWG, so switching video games to it blanks the pill for many games and sorts them last. IMDb + (movies/TV) is out of 10 like TMDB, so switching rarely changes the pill, but a few titles have no + IMDb rating and will blank/sort last.

@foreach (var option in _ratingSources) {
@DomainLabel(option.Domain) -
+ @if (_recomputedDomain == option.Domain && _recomputeResult is not null) { @@ -488,6 +490,15 @@ await LoadSystemStatusAsync(); } + private static string SourceLabel(string source) => source switch + { + "tmdb" => "TMDB", + "imdb" => "IMDb", + "rawg" => "RAWG", + "metacritic" => "Metacritic", + _ => source + }; + private static string DomainLabel(ReferenceItemType domain) => domain switch { ReferenceItemType.TvShow => "TV shows", diff --git a/src/WebApi/AppConfiguration.cs b/src/WebApi/AppConfiguration.cs index feb7095a..f29246b4 100644 --- a/src/WebApi/AppConfiguration.cs +++ b/src/WebApi/AppConfiguration.cs @@ -41,6 +41,13 @@ public static int GetFreeTierItemLimit(IConfiguration configuration) public TmdbSettings TmdbSettings { get; } = configuration.TryGetSection("Tmdb"); + /// + /// OMDb (IMDb ratings) is optional - a missing Omdb section is a supported state (IMDb enrichment + /// disabled), so this coalesces to an empty settings object rather than a null the DI container would then + /// hand a client. See . + /// + public OmdbSettings OmdbSettings { get; } = configuration.TryGetSection("Omdb") ?? new OmdbSettings(); + public RawgSettings RawgSettings { get; } = configuration.TryGetSection("Rawg"); public DiscogsSettings DiscogsSettings { get; } = configuration.TryGetSection("Discogs"); diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index 861a4d4c..9f219ef1 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -59,6 +59,14 @@ // see https://github.com/dotnet/extensions/issues/4770 (confirmed against this exact symptom on Discogs). client.Timeout = Timeout.InfiniteTimeSpan; }).AddProviderResilienceHandler(); +// OMDb supplies IMDb ratings for movies/TV (keyed by the imdb id TMDB already exposes). Optional: with no +// Omdb__ApiKey the client no-ops (see OmdbClient), so movies/TV stay on their TMDB rating alone. +builder.Services.AddSingleton(configuration.OmdbSettings); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://www.omdbapi.com/"); + client.Timeout = Timeout.InfiniteTimeSpan; +}).AddProviderResilienceHandler(); // every book provider is registered unconditionally (unlike the single-provider TMDB/RAWG/Discogs clients // below) - an admin picks which one to search with at request time (see BookReferenceClientRegistry), // ReferenceData:BookProvider only selects the *default* used for automatic/background resolution. diff --git a/src/WebApi/ReferenceData/IOmdbClient.cs b/src/WebApi/ReferenceData/IOmdbClient.cs new file mode 100644 index 00000000..07ba5a43 --- /dev/null +++ b/src/WebApi/ReferenceData/IOmdbClient.cs @@ -0,0 +1,19 @@ +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// Fetches a title's IMDb aggregate rating from OMDb, keyed by the IMDb id TMDB already exposes +/// (imdb_id on a movie, external_ids.imdb_id on a show). The only viable source of IMDb +/// ratings - IMDb has no public ratings API - so movies/TV get their second rating source through this. +/// +public interface IOmdbClient +{ + /// + /// The IMDb rating (already on a 0-10 scale, same as TMDB's) and vote count for , + /// or null when no OMDb key is configured, the id is unknown to OMDb, or it has no rating yet + /// (OMDb returns "N/A"). Best-effort: a missing rating is "no imdb source", never an error. + /// + Task GetRatingAsync(string imdbId, CancellationToken cancellationToken = default); +} + +/// An IMDb aggregate rating: on a 0-10 scale, with its vote . +public sealed record OmdbRating(double Value, int? Count); diff --git a/src/WebApi/ReferenceData/ITmdbClient.cs b/src/WebApi/ReferenceData/ITmdbClient.cs index c97d00da..d1de85f8 100644 --- a/src/WebApi/ReferenceData/ITmdbClient.cs +++ b/src/WebApi/ReferenceData/ITmdbClient.cs @@ -8,9 +8,9 @@ public record TmdbSearchResult(string TmdbId, string Title, int? Year, string? S public record TmdbEpisode(int SeasonNumber, int EpisodeNumber, string Title, DateOnly? AirDate); -public record TmdbTvShowDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Episodes, List Genres, string? PosterUrl, double? VoteAverage = null, int? VoteCount = null); +public record TmdbTvShowDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Episodes, List Genres, string? PosterUrl, double? VoteAverage = null, int? VoteCount = null, string? ImdbId = null); -public record TmdbMovieDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Genres, string? PosterUrl, double? VoteAverage, int? VoteCount); +public record TmdbMovieDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Genres, string? PosterUrl, double? VoteAverage, int? VoteCount, string? ImdbId = null); /// /// One credited cast member - is TMDB's person id, used to deduplicate @@ -47,4 +47,16 @@ public interface ITmdbClient /// Movie equivalent of . /// Task HasMovieChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default); + + /// + /// A show's IMDb id via TMDB's dedicated /tv/{id}/external_ids endpoint - one cheap call, no season + /// fan-out, so a reference enriched before IMDb ratings existed can backfill its imdb id (and then its + /// rating) without the full details re-fetch the no-change sync short-circuit is meant to avoid. + /// + Task GetTvShowImdbIdAsync(string tmdbId, CancellationToken cancellationToken = default); + + /// + /// Movie equivalent of (/movie/{id}/external_ids). + /// + Task GetMovieImdbIdAsync(string tmdbId, CancellationToken cancellationToken = default); } diff --git a/src/WebApi/ReferenceData/OmdbClient.cs b/src/WebApi/ReferenceData/OmdbClient.cs new file mode 100644 index 00000000..e0779a3d --- /dev/null +++ b/src/WebApi/ReferenceData/OmdbClient.cs @@ -0,0 +1,48 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// OMDb REST client - looks up a title by its IMDb id and returns IMDb's own aggregate rating/vote count. +/// Configured as a typed (see Program.cs), with the api key appended as a +/// query parameter, same convention as /. When no key is +/// configured the client is a graceful no-op (returns null) rather than calling the provider - IMDb +/// enrichment is optional, so a deployment without an OMDb key keeps movies/TV on their TMDB rating alone. +/// +public class OmdbClient(HttpClient http, OmdbSettings settings) : IOmdbClient +{ + public async Task GetRatingAsync(string imdbId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(settings.ApiKey) || string.IsNullOrEmpty(imdbId)) return null; + + var response = await http.GetFromJsonAsync( + $"?apikey={settings.ApiKey}&i={Uri.EscapeDataString(imdbId)}", cancellationToken); + + // OMDb returns Response:"False" for an unknown id, and imdbRating:"N/A" for a title nobody has rated - + // both are "no rating", not a genuine zero (a stored 0 would sort as a real score and render a "0" pill). + if (response is null || !string.Equals(response.Response, "True", StringComparison.OrdinalIgnoreCase)) return null; + if (!TryParseRating(response.ImdbRating, out var value)) return null; + + return new OmdbRating(value, ParseVotes(response.ImdbVotes)); + } + + private static bool TryParseRating(string? rating, out double value) => + double.TryParse(rating, NumberStyles.Number, CultureInfo.InvariantCulture, out value) && value > 0; + + /// OMDb formats vote counts with thousands separators ("1,950,000") or "N/A" - strip and parse. + private static int? ParseVotes(string? votes) => + int.TryParse(votes, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed) ? parsed : null; + + private sealed class OmdbResponse + { + [JsonPropertyName("imdbRating")] + public string? ImdbRating { get; set; } + + [JsonPropertyName("imdbVotes")] + public string? ImdbVotes { get; set; } + + [JsonPropertyName("Response")] + public string? Response { get; set; } + } +} diff --git a/src/WebApi/ReferenceData/OmdbSettings.cs b/src/WebApi/ReferenceData/OmdbSettings.cs new file mode 100644 index 00000000..96718381 --- /dev/null +++ b/src/WebApi/ReferenceData/OmdbSettings.cs @@ -0,0 +1,12 @@ +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// OMDb is the source of IMDb aggregate ratings (IMDb itself has no public ratings API) - see +/// . Unlike every other provider's settings, is optional +/// (nullable, not required): IMDb enrichment is best-effort, so a deployment with no OMDb key simply +/// keeps movies/TV on their TMDB rating alone rather than failing resolution/refresh. +/// +public class OmdbSettings +{ + public string? ApiKey { get; set; } +} diff --git a/src/WebApi/ReferenceData/RatingSourceCatalog.cs b/src/WebApi/ReferenceData/RatingSourceCatalog.cs index 29356df0..dec26601 100644 --- a/src/WebApi/ReferenceData/RatingSourceCatalog.cs +++ b/src/WebApi/ReferenceData/RatingSourceCatalog.cs @@ -16,10 +16,18 @@ public static class RatingSourceCatalog /// Metacritic's 0-100 critic score - frequently absent on RAWG for smaller/older games. public const string Metacritic = "metacritic"; + /// TMDB's own 0-10 vote average - the movie/TV default (always present once resolved). + public const string Tmdb = "tmdb"; + + /// IMDb's 0-10 aggregate (via OMDb) - same scale as TMDB, occasionally absent for obscure titles. + public const string Imdb = "imdb"; + // per domain, the selectable source keys; the first is the code default. private static readonly IReadOnlyDictionary> s_sources = new Dictionary> { + [ReferenceItemType.Movie] = [Tmdb, Imdb], + [ReferenceItemType.TvShow] = [Tmdb, Imdb], [ReferenceItemType.VideoGame] = [Rawg, Metacritic] }; diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs index 5d25304f..65926d45 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs @@ -12,27 +12,77 @@ public partial class ReferenceEnrichmentService ///
private const int MaxCastMembers = 15; - /// - /// The single source whose rating is denormalized onto the tenant item as the primary (list display / - /// sort) value. TMDB for both movies and shows; other domains use their own provider. - /// - private const string PrimaryRatingSource = "tmdb"; + // the two rating source keys movies/TV can carry (see RatingSourceCatalog, the single home for these + // literals); used here purely as the Ratings-dict keys when building the map. Which of the two is the + // *primary* (denormalized onto the tenant item as the list pill / sort value) is resolved per-domain via + // GetPrimaryRatingSourceAsync (admin-selectable), not hardcoded here. + private const string TmdbRatingSource = RatingSourceCatalog.Tmdb; + + private const string ImdbRatingSource = RatingSourceCatalog.Imdb; /// /// Builds the reference Ratings map from a TMDB vote aggregate. TMDB returns vote_average 0 /// with vote_count 0 for a title nobody has rated - that's "no rating", not a genuine zero, so it's - /// omitted rather than stored (a stored 0 would sort as a real score and render a "0" pill). + /// omitted rather than stored (a stored 0 would sort as a real score and render a "0" pill). IMDb is added + /// separately () since it needs an OMDb HTTP call this static builder can't make. /// private static Dictionary BuildTmdbRatings(double? voteAverage, int? voteCount) { var ratings = new Dictionary(); if (voteAverage is > 0 && voteCount is > 0) { - ratings[PrimaryRatingSource] = new ReferenceRatingModel { Value = voteAverage.Value, Scale = 10, Count = voteCount }; + ratings[TmdbRatingSource] = new ReferenceRatingModel { Value = voteAverage.Value, Scale = 10, Count = voteCount }; } return ratings; } + /// + /// Adds an imdb entry (IMDb's 0-10 aggregate, via OMDb keyed by the IMDb id TMDB exposes) to a + /// map, returning whether one was added. A no-op (returns false) when there's + /// no IMDb id, no OMDb key is configured, or OMDb has no rating for the title - IMDb is best-effort, its + /// absence is never an error. The IMDb id itself is stored in the reference's ExternalIds["imdb"] + /// so the periodic sync can backfill a missing rating cheaply (one OMDb call, no TMDB re-fetch) - see + /// . + /// + private async Task AddImdbRatingAsync(Dictionary ratings, string? imdbId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(imdbId)) return false; + + var imdb = await omdbClient.GetRatingAsync(imdbId, cancellationToken); + if (imdb is null) return false; + + ratings[ImdbRatingSource] = new ReferenceRatingModel { Value = imdb.Value, Scale = 10, Count = imdb.Count }; + return true; + } + + /// + /// Cheap imdb-only backfill for the no-change sync short-circuit (see ): + /// adds an imdb rating only when the reference has none yet, so an already-imdb-rated reference makes no + /// OMDb call at all. When the reference has no stored imdb id (enriched before IMDb ratings existed), it's + /// fetched via a cheap TMDB external-ids lookup () - not the full details + /// re-fetch the short-circuit avoids - and stored on so later syncs skip + /// that lookup. Returns whether a rating was added. Re-attempting a title OMDb genuinely has no rating for + /// on each full sync is one cheap OMDb call, deliberately accepted rather than persisting an "attempted" + /// marker - it self-corrects the moment OMDb does have a rating. + /// + private async Task BackfillImdbRatingAsync( + Dictionary ratings, + Dictionary externalIds, + Func> fetchImdbId, + CancellationToken cancellationToken) + { + if (ratings.ContainsKey(ImdbRatingSource)) return false; + + var imdbId = externalIds.GetValueOrDefault("imdb"); + if (string.IsNullOrEmpty(imdbId)) + { + imdbId = await fetchImdbId(cancellationToken); + if (!string.IsNullOrEmpty(imdbId)) externalIds["imdb"] = imdbId; + } + + return await AddImdbRatingAsync(ratings, imdbId, cancellationToken); + } + /// /// The (value, scale) to denormalize onto tenant items - the given primary source's, or (null, null) /// when it has none. Shared across every domain (video games/albums/books pass their own primary key); @@ -92,7 +142,7 @@ public async Task TryLinkExistingTvShowReferenceAsync(TvShowModel m var originalTitle = model.Title; var originalYear = model.Year; - var (ratingValue, ratingScale) = PrimaryRating(reference.Ratings, PrimaryRatingSource); + var (ratingValue, ratingScale) = PrimaryRating(reference.Ratings, await GetPrimaryRatingSourceAsync(ReferenceItemType.TvShow)); model.ReferenceId = reference.Id; model.Title = reference.Title; @@ -136,7 +186,7 @@ public async Task TryLinkExistingMovieReferenceAsync(MovieModel mode var originalTitle = model.Title; var originalYear = model.Year; - var (ratingValue, ratingScale) = PrimaryRating(reference.Ratings, PrimaryRatingSource); + var (ratingValue, ratingScale) = PrimaryRating(reference.Ratings, await GetPrimaryRatingSourceAsync(ReferenceItemType.Movie)); model.ReferenceId = reference.Id; model.Title = reference.Title; @@ -243,6 +293,11 @@ public async Task ResolveTvShowAsync(string title, int? ye } var externalIds = existing?.ExternalIds ?? new Dictionary(); externalIds["tmdb"] = tmdbId; + // store the imdb id even when OMDb has no rating yet, so a later sync can backfill it cheaply + if (!string.IsNullOrEmpty(details.ImdbId)) externalIds["imdb"] = details.ImdbId; + + var ratings = BuildTmdbRatings(details.VoteAverage, details.VoteCount); + await AddImdbRatingAsync(ratings, details.ImdbId); var model = new TvShowReferenceModel { @@ -261,13 +316,13 @@ public async Task ResolveTvShowAsync(string title, int? ye .ToList(), Genres = details.Genres, Cast = await ResolveCastAsync(cast), - Ratings = BuildTmdbRatings(details.VoteAverage, details.VoteCount), + Ratings = ratings, ImageUrl = details.PosterUrl, LastEnrichedAt = DateTime.UtcNow }; var saved = await tvShowReferenceRepository.UpsertAsync(model); - var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, PrimaryRatingSource); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, await GetPrimaryRatingSourceAsync(ReferenceItemType.TvShow)); await tvShowRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, ratingValue, ratingScale); return saved; } @@ -296,6 +351,11 @@ public async Task ResolveMovieAsync(string title, int? year } var externalIds = existing?.ExternalIds ?? new Dictionary(); externalIds["tmdb"] = tmdbId; + // store the imdb id even when OMDb has no rating yet, so a later sync can backfill it cheaply + if (!string.IsNullOrEmpty(details.ImdbId)) externalIds["imdb"] = details.ImdbId; + + var ratings = BuildTmdbRatings(details.VoteAverage, details.VoteCount); + await AddImdbRatingAsync(ratings, details.ImdbId); var model = new MovieReferenceModel { @@ -311,13 +371,13 @@ public async Task ResolveMovieAsync(string title, int? year MatchedAliases = MergeMatchedAliases(existing?.MatchedAliases, (details.Title, details.Year ?? year, null, null), (title, year, null, null)), Genres = details.Genres, Cast = await ResolveCastAsync(cast), - Ratings = BuildTmdbRatings(details.VoteAverage, details.VoteCount), + Ratings = ratings, ImageUrl = details.PosterUrl, LastEnrichedAt = DateTime.UtcNow }; var saved = await movieReferenceRepository.UpsertAsync(model); - var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, PrimaryRatingSource); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, await GetPrimaryRatingSourceAsync(ReferenceItemType.Movie)); await movieRepository.SetReferenceLinkAsync(title, year, saved.Id!, details.Title, saved.Year, ratingValue, ratingScale); return saved; } @@ -340,8 +400,16 @@ public async Task ResolveMovieAsync(string title, int? year var changed = await tmdbClient.HasTvShowChangedSinceAsync(tmdbId, reference.LastEnrichedAt.Value, cancellationToken); if (!changed) { + var backfilled = await BackfillImdbRatingAsync(reference.Ratings, reference.ExternalIds, + ct => tmdbClient.GetTvShowImdbIdAsync(tmdbId, ct), cancellationToken); reference.LastEnrichedAt = DateTime.UtcNow; - return (await tvShowReferenceRepository.UpsertAsync(reference), false); + var refreshed = await tvShowReferenceRepository.UpsertAsync(reference); + if (backfilled) + { + var (v, s) = PrimaryRating(refreshed.Ratings, await GetPrimaryRatingSourceAsync(ReferenceItemType.TvShow)); + await tvShowRepository.SetReferenceRatingAsync(refreshed.Id!, v, s); + } + return (refreshed, backfilled); } } @@ -357,13 +425,16 @@ public async Task ResolveMovieAsync(string title, int? year .ToList(); reference.Genres = details.Genres; reference.Cast = await ResolveCastAsync(cast); + // store the imdb id even when OMDb has no rating yet, so a later sync can backfill it cheaply + if (!string.IsNullOrEmpty(details.ImdbId)) reference.ExternalIds["imdb"] = details.ImdbId; reference.Ratings = BuildTmdbRatings(details.VoteAverage, details.VoteCount); + await AddImdbRatingAsync(reference.Ratings, details.ImdbId, cancellationToken); reference.ImageUrl = details.PosterUrl ?? reference.ImageUrl; reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, null, null)); reference.LastEnrichedAt = DateTime.UtcNow; var saved = await tvShowReferenceRepository.UpsertAsync(reference); - var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, PrimaryRatingSource); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, await GetPrimaryRatingSourceAsync(ReferenceItemType.TvShow)); await tvShowRepository.SetReferenceRatingAsync(saved.Id!, ratingValue, ratingScale); return (saved, true); } @@ -385,8 +456,18 @@ public async Task ResolveMovieAsync(string title, int? year var changed = await tmdbClient.HasMovieChangedSinceAsync(tmdbId, reference.LastEnrichedAt.Value, cancellationToken); if (!changed) { + // nothing changed on TMDB, but backfill a missing imdb rating cheaply (one OMDb call, no + // TMDB details re-fetch) from the imdb id already stored on the reference + var backfilled = await BackfillImdbRatingAsync(reference.Ratings, reference.ExternalIds, + ct => tmdbClient.GetMovieImdbIdAsync(tmdbId, ct), cancellationToken); reference.LastEnrichedAt = DateTime.UtcNow; - return (await movieReferenceRepository.UpsertAsync(reference), false); + var refreshed = await movieReferenceRepository.UpsertAsync(reference); + if (backfilled) + { + var (v, s) = PrimaryRating(refreshed.Ratings, await GetPrimaryRatingSourceAsync(ReferenceItemType.Movie)); + await movieRepository.SetReferenceRatingAsync(refreshed.Id!, v, s); + } + return (refreshed, backfilled); } } @@ -399,14 +480,17 @@ public async Task ResolveMovieAsync(string title, int? year reference.Synopsis = details.Synopsis; reference.Genres = details.Genres; reference.Cast = await ResolveCastAsync(cast); + // store the imdb id even when OMDb has no rating yet, so a later sync can backfill it cheaply + if (!string.IsNullOrEmpty(details.ImdbId)) reference.ExternalIds["imdb"] = details.ImdbId; reference.Ratings = BuildTmdbRatings(details.VoteAverage, details.VoteCount); + await AddImdbRatingAsync(reference.Ratings, details.ImdbId, cancellationToken); reference.ImageUrl = details.PosterUrl ?? reference.ImageUrl; reference.MatchedAliases = MergeMatchedAliases(reference.MatchedAliases, (details.Title, reference.Year, null, null)); reference.LastEnrichedAt = DateTime.UtcNow; var saved = await movieReferenceRepository.UpsertAsync(reference); // keep every already-linked tenant movie's denormalized copy current with the refreshed rating - var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, PrimaryRatingSource); + var (ratingValue, ratingScale) = PrimaryRating(saved.Ratings, await GetPrimaryRatingSourceAsync(ReferenceItemType.Movie)); await movieRepository.SetReferenceRatingAsync(saved.Id!, ratingValue, ratingScale); return (saved, true); } diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs index 5bb3ee91..f6fc82b6 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs @@ -15,6 +15,7 @@ namespace Keeptrack.WebApi.ReferenceData; /// public partial class ReferenceEnrichmentService( ITmdbClient tmdbClient, + IOmdbClient omdbClient, BookReferenceClientRegistry bookReferenceClientRegistry, IBookRatingByIsbnLookup bookRatingByIsbnLookup, IRawgClient rawgClient, @@ -60,6 +61,16 @@ public async Task GetPrimaryRatingSourceAsync(ReferenceItemType domain) var source = await GetPrimaryRatingSourceAsync(domain); return domain switch { + ReferenceItemType.Movie => await RecomputeReferenceRatingsAsync( + movieReferenceRepository.FindAllAsync, + r => (r.Id!, r.Ratings), + movieRepository.SetReferenceRatingAsync, + source), + ReferenceItemType.TvShow => await RecomputeReferenceRatingsAsync( + tvShowReferenceRepository.FindAllAsync, + r => (r.Id!, r.Ratings), + tvShowRepository.SetReferenceRatingAsync, + source), ReferenceItemType.VideoGame => await RecomputeReferenceRatingsAsync( videoGameReferenceRepository.FindAllAsync, r => (r.Id!, r.Ratings), diff --git a/src/WebApi/ReferenceData/TmdbClient.cs b/src/WebApi/ReferenceData/TmdbClient.cs index f53b40d8..8b2b2f97 100644 --- a/src/WebApi/ReferenceData/TmdbClient.cs +++ b/src/WebApi/ReferenceData/TmdbClient.cs @@ -29,7 +29,10 @@ public async Task> SearchMovieAsync(string title public async Task GetTvShowDetailsAsync(string tmdbId, CancellationToken cancellationToken = default) { - var details = await http.GetFromJsonAsync($"tv/{tmdbId}?api_key={ApiKey}", cancellationToken); + // append_to_response=external_ids folds the imdb id into this same details call - no extra request, + // no season fan-out change (a show's imdb id isn't on /tv/{id} itself, unlike a movie's). + var details = await http.GetFromJsonAsync( + $"tv/{tmdbId}?api_key={ApiKey}&append_to_response=external_ids", cancellationToken); if (details is null) return null; var episodes = new List(); @@ -46,7 +49,7 @@ public async Task> SearchMovieAsync(string title return new TmdbTvShowDetails( tmdbId, details.Name ?? string.Empty, ParseYear(details.FirstAirDate), details.Overview, episodes, details.Genres.Select(g => g.Name).ToList(), BuildImageUrl(details.PosterPath, PosterImageSize), - details.VoteAverage, details.VoteCount); + details.VoteAverage, details.VoteCount, details.ExternalIds?.ImdbId); } public async Task GetMovieDetailsAsync(string tmdbId, CancellationToken cancellationToken = default) @@ -57,7 +60,7 @@ public async Task> SearchMovieAsync(string title : new TmdbMovieDetails( tmdbId, details.Title ?? string.Empty, ParseYear(details.ReleaseDate), details.Overview, details.Genres.Select(g => g.Name).ToList(), BuildImageUrl(details.PosterPath, PosterImageSize), - details.VoteAverage, details.VoteCount); + details.VoteAverage, details.VoteCount, details.ImdbId); } public async Task> GetTvShowCastAsync(string tmdbId, CancellationToken cancellationToken = default) => @@ -80,6 +83,18 @@ public Task HasTvShowChangedSinceAsync(string tmdbId, DateTime since, Canc public Task HasMovieChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default) => HasChangedSinceAsync("movie", tmdbId, since, cancellationToken); + public Task GetTvShowImdbIdAsync(string tmdbId, CancellationToken cancellationToken = default) => + GetImdbIdAsync($"tv/{tmdbId}/external_ids", cancellationToken); + + public Task GetMovieImdbIdAsync(string tmdbId, CancellationToken cancellationToken = default) => + GetImdbIdAsync($"movie/{tmdbId}/external_ids", cancellationToken); + + private async Task GetImdbIdAsync(string path, CancellationToken cancellationToken) + { + var response = await http.GetFromJsonAsync($"{path}?api_key={ApiKey}", cancellationToken); + return response?.ImdbId; + } + /// /// TMDB's per-id "changes" endpoint (as opposed to the bulk /tv/changes, /movie/changes /// endpoints which only cover the last 24-72h) reports whether anything changed since an arbitrary date - @@ -168,6 +183,15 @@ private sealed class TmdbTvShowDetailsResponse [JsonPropertyName("seasons")] public List Seasons { get; set; } = []; + + [JsonPropertyName("external_ids")] + public TmdbExternalIds? ExternalIds { get; set; } + } + + private sealed class TmdbExternalIds + { + [JsonPropertyName("imdb_id")] + public string? ImdbId { get; set; } } private sealed class TmdbGenre @@ -222,6 +246,9 @@ private sealed class TmdbMovieDetailsResponse [JsonPropertyName("vote_count")] public int? VoteCount { get; set; } + + [JsonPropertyName("imdb_id")] + public string? ImdbId { get; set; } } private sealed class TmdbCreditsResponse diff --git a/src/WebApi/appsettings.json b/src/WebApi/appsettings.json index 8091f505..1c2b5c6b 100644 --- a/src/WebApi/appsettings.json +++ b/src/WebApi/appsettings.json @@ -38,6 +38,9 @@ "ReferenceData": { "BookProvider": "googlebooks" }, + "Omdb": { + "ApiKey": "" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/test/WebApi.UnitTests/ReferenceData/FakeOmdbClient.cs b/test/WebApi.UnitTests/ReferenceData/FakeOmdbClient.cs new file mode 100644 index 00000000..91263214 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/FakeOmdbClient.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keeptrack.WebApi.ReferenceData; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +/// +/// Test double for OMDb (IMDb ratings): returns a rating for any imdb id seeded in , +/// null otherwise (the "no OMDb key / no rating" case), and records which ids were queried so a test can +/// assert the cheap backfill did or didn't call the provider. +/// +internal sealed class FakeOmdbClient : IOmdbClient +{ + public Dictionary Ratings { get; } = new(); + + public List Requested { get; } = []; + + public static FakeOmdbClient Empty() => new(); + + public Task GetRatingAsync(string imdbId, CancellationToken cancellationToken = default) + { + Requested.Add(imdbId); + return Task.FromResult(Ratings.GetValueOrDefault(imdbId)); + } +} diff --git a/test/WebApi.UnitTests/ReferenceData/OmdbClientTest.cs b/test/WebApi.UnitTests/ReferenceData/OmdbClientTest.cs new file mode 100644 index 00000000..e1c06f5c --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/OmdbClientTest.cs @@ -0,0 +1,87 @@ +using System; +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; + +/// +/// OMDb's rating/vote fields are strings ("8.7", "1,950,000", or "N/A") and it signals an unknown id via +/// Response:"False" - this pins the parsing of each of those shapes plus the graceful no-op when no +/// key is configured (IMDb enrichment is optional, so movies/TV must keep working without an OMDb key). +/// +[Trait("Category", "UnitTests")] +public class OmdbClientTest +{ + private static OmdbClient BuildClient(string? apiKey, Func? respond = null) + { + var handler = new StubHttpMessageHandler(respond ?? (() => new HttpResponseMessage(HttpStatusCode.OK))); + var http = new HttpClient(handler) { BaseAddress = new Uri("https://www.omdbapi.com/") }; + return new OmdbClient(http, new OmdbSettings { ApiKey = apiKey }); + } + + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + + private sealed class StubHttpMessageHandler(Func respond) : HttpMessageHandler + { + public int CallCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + CallCount++; + return Task.FromResult(respond()); + } + } + + [Fact] + public async Task GetRatingAsync_ParsesTheRatingAndCommaSeparatedVoteCount() + { + var client = BuildClient("k", () => Json("""{"imdbRating":"8.7","imdbVotes":"1,950,000","Response":"True"}""")); + + var rating = await client.GetRatingAsync("tt0133093", TestContext.Current.CancellationToken); + + rating.Should().NotBeNull(); + rating!.Value.Should().Be(8.7); + rating.Count.Should().Be(1_950_000); + } + + [Fact] + public async Task GetRatingAsync_ReturnsNull_WhenTheTitleHasNoRatingYet() + { + var client = BuildClient("k", () => Json("""{"imdbRating":"N/A","imdbVotes":"N/A","Response":"True"}""")); + + var rating = await client.GetRatingAsync("tt0133093", TestContext.Current.CancellationToken); + + rating.Should().BeNull(); + } + + [Fact] + public async Task GetRatingAsync_ReturnsNull_WhenTheIdIsUnknownToOmdb() + { + var client = BuildClient("k", () => Json("""{"Response":"False","Error":"Incorrect IMDb ID."}""")); + + var rating = await client.GetRatingAsync("tt0000000", TestContext.Current.CancellationToken); + + rating.Should().BeNull(); + } + + [Fact] + public async Task GetRatingAsync_DoesNotCallTheProvider_WhenNoApiKeyIsConfigured() + { + var handler = new StubHttpMessageHandler(() => Json("""{"imdbRating":"8.7","imdbVotes":"1","Response":"True"}""")); + var client = new OmdbClient(new HttpClient(handler) { BaseAddress = new Uri("https://www.omdbapi.com/") }, new OmdbSettings { ApiKey = null }); + + var rating = await client.GetRatingAsync("tt0133093", TestContext.Current.CancellationToken); + + rating.Should().BeNull(); + handler.CallCount.Should().Be(0); + } +} diff --git a/test/WebApi.UnitTests/ReferenceData/RatingSourceCatalogTest.cs b/test/WebApi.UnitTests/ReferenceData/RatingSourceCatalogTest.cs index 77510cb2..230e5344 100644 --- a/test/WebApi.UnitTests/ReferenceData/RatingSourceCatalogTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/RatingSourceCatalogTest.cs @@ -21,12 +21,21 @@ public void VideoGames_OfferRawgAndMetacritic_WithRawgAsTheDefault() [Theory] [InlineData(ReferenceItemType.Movie)] [InlineData(ReferenceItemType.TvShow)] + public void MoviesAndTvShows_OfferTmdbAndImdb_WithTmdbAsTheDefault(ReferenceItemType domain) + { + RatingSourceCatalog.AvailableSources(domain).Should().Equal(RatingSourceCatalog.Tmdb, RatingSourceCatalog.Imdb); + RatingSourceCatalog.DefaultSource(domain).Should().Be(RatingSourceCatalog.Tmdb); + RatingSourceCatalog.IsSelectable(domain).Should().BeTrue(); + RatingSourceCatalog.SelectableDomains.Should().Contain(domain); + } + + [Theory] [InlineData(ReferenceItemType.Book)] [InlineData(ReferenceItemType.Album)] public void SingleSourceDomains_AreNotYetAdminSelectable(ReferenceItemType domain) { // these have only one source today, so there's nothing to choose - they join the catalog when they - // gain a second source (e.g. IMDb for movies/TV, phase 2). + // gain a second source, the same way movies/TV did once IMDb landed. RatingSourceCatalog.IsSelectable(domain).Should().BeFalse(); RatingSourceCatalog.AvailableSources(domain).Should().BeEmpty(); } diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs index ab7a4f18..5b2ac645 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs @@ -45,6 +45,8 @@ public ReferenceEnrichmentServiceTest() /// private const string DefaultBookProvider = "openlibrary"; + private readonly FakeOmdbClient _omdbClient = FakeOmdbClient.Empty(); + private ReferenceEnrichmentService CreateService( FakeTmdbClient tmdbClient, FakeBookReferenceClient? bookReferenceClient = null, @@ -52,6 +54,7 @@ private ReferenceEnrichmentService CreateService( FakeDiscogsClient? discogsClient = null, FakeBnfClient? bnfClient = null) => new( tmdbClient, + _omdbClient, new BookReferenceClientRegistry([bookReferenceClient ?? FakeBookReferenceClient.Empty(), bnfClient ?? FakeBnfClient.Empty()], DefaultBookProvider), _bookRatingByIsbnLookup, rawgClient ?? FakeRawgClient.Empty(), discogsClient ?? FakeDiscogsClient.Empty(), @@ -536,6 +539,173 @@ public async Task TryLinkExistingMovieReferenceAsync_SetsTheDenormalizedRating_F _movieRepository.Verify(r => r.SetReferenceLinkAsync("Some Movie", 2020, "reference-1", "Some Movie", It.IsAny(), 8.1, 10), Times.Once); } + [Fact] + public async Task ResolveMovieAsync_AddsTheImdbRating_AndStoresTheImdbIdInExternalIds() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.MovieDetails["42"] = new TmdbMovieDetails("42", "Some Movie", 2020, "Synopsis", [], null, 7.8, 1234, "tt0042"); + _omdbClient.Ratings["tt0042"] = new OmdbRating(8.9, 500_000); + _movieReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((MovieReferenceModel m) => { m.Id = "reference-1"; return m; }); + var service = CreateService(tmdbClient); + + var result = await service.ResolveMovieAsync("Some Movie", 2020, "42"); + + // both sources land in the reference dict, each on its own scale + result.Ratings["tmdb"].Value.Should().Be(7.8); + result.Ratings["imdb"].Value.Should().Be(8.9); + result.Ratings["imdb"].Scale.Should().Be(10); + result.Ratings["imdb"].Count.Should().Be(500_000); + // the imdb id is stored so a later sync can backfill/refresh it cheaply + result.ExternalIds["imdb"].Should().Be("tt0042"); + // tmdb is the default primary, so the denormalized scalar is still tmdb's + _movieRepository.Verify(r => r.SetReferenceLinkAsync("Some Movie", 2020, "reference-1", "Some Movie", It.IsAny(), 7.8, 10), Times.Once); + } + + [Fact] + public async Task ResolveMovieAsync_StoresTheImdbId_EvenWhenOmdbHasNoRatingYet() + { + // no OMDb rating (unknown title, or no OMDb key) must still record the imdb id, so the periodic + // sync's cheap backfill has a key to retry with instead of the id being lost forever + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.MovieDetails["42"] = new TmdbMovieDetails("42", "Some Movie", 2020, "Synopsis", [], null, 7.8, 1234, "tt0042"); + _movieReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((MovieReferenceModel m) => { m.Id = "reference-1"; return m; }); + var service = CreateService(tmdbClient); + + var result = await service.ResolveMovieAsync("Some Movie", 2020, "42"); + + result.Ratings.Should().NotContainKey("imdb"); + result.ExternalIds["imdb"].Should().Be("tt0042"); + } + + [Fact] + public async Task ResolveMovieAsync_DenormalizesTheImdbScore_WhenImdbIsTheAdminSelectedPrimarySource() + { + // the admin-selectable primary source (same mechanism as video games' RAWG/Metacritic) drives the + // list pill / Ref-sort scalar: with imdb selected for movies, imdb's value/scale is denormalized + _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()) + .ReturnsAsync(new Dictionary { ["Movie"] = "imdb" }); + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.MovieDetails["42"] = new TmdbMovieDetails("42", "Some Movie", 2020, "Synopsis", [], null, 7.8, 1234, "tt0042"); + _omdbClient.Ratings["tt0042"] = new OmdbRating(8.9, 500_000); + _movieReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((MovieReferenceModel m) => { m.Id = "reference-1"; return m; }); + var service = CreateService(tmdbClient); + + await service.ResolveMovieAsync("Some Movie", 2020, "42"); + + _movieRepository.Verify(r => r.SetReferenceLinkAsync("Some Movie", 2020, "reference-1", "Some Movie", It.IsAny(), 8.9, 10), Times.Once); + } + + [Fact] + public async Task RefreshMovieReferenceAsync_BackfillsImdbCheaply_OnTheNoChangeShortCircuit_WithoutRefetchingDetails() + { + // an already-tmdb-rated reference takes the no-change short-circuit, but a missing imdb rating is + // still backfilled with one cheap OMDb call - never the expensive TMDB details re-fetch + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.ChangedSince["42"] = false; + _omdbClient.Ratings["tt0042"] = new OmdbRating(8.9, 500_000); + _movieReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((MovieReferenceModel m) => m); + var reference = new MovieReferenceModel + { + Id = "reference-1", Title = "Some Movie", TitleNormalized = "some movie", Year = 2020, + ExternalIds = new Dictionary { ["tmdb"] = "42", ["imdb"] = "tt0042" }, + LastEnrichedAt = DateTime.UtcNow.AddDays(-5), + Ratings = new Dictionary { ["tmdb"] = new() { Value = 7.8, Scale = 10, Count = 1234 } } + }; + var service = CreateService(tmdbClient); + + var (result, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + result.Ratings["imdb"].Value.Should().Be(8.9); + tmdbClient.MovieDetailsRequested.Should().NotContain("42"); + // backfill re-propagates the denormalized scalar to already-linked items + _movieRepository.Verify(r => r.SetReferenceRatingAsync("reference-1", It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task RefreshMovieReferenceAsync_ResolvesTheImdbIdCheaply_WhenBackfillingAReferenceThatPredatesImdb() + { + // the bootstrap case: a reference enriched before IMDb existed has a tmdb rating (so it takes the + // no-change short-circuit) but NO stored imdb id - the id must be fetched via the cheap external-ids + // lookup (not a full details re-fetch), stored, and then used to pull the OMDb rating + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.ChangedSince["42"] = false; + tmdbClient.ImdbIds["42"] = "tt0042"; + _omdbClient.Ratings["tt0042"] = new OmdbRating(8.9, 500_000); + _movieReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((MovieReferenceModel m) => m); + var reference = new MovieReferenceModel + { + Id = "reference-1", Title = "Some Movie", TitleNormalized = "some movie", Year = 2020, + ExternalIds = new Dictionary { ["tmdb"] = "42" }, // no imdb id yet + LastEnrichedAt = DateTime.UtcNow.AddDays(-5), + Ratings = new Dictionary { ["tmdb"] = new() { Value = 7.8, Scale = 10, Count = 1234 } } + }; + var service = CreateService(tmdbClient); + + var (result, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeTrue(); + result.Ratings["imdb"].Value.Should().Be(8.9); + // the resolved id is now stored so later syncs skip the external-ids lookup... + result.ExternalIds["imdb"].Should().Be("tt0042"); + tmdbClient.ImdbIdsRequested.Should().ContainSingle(); + // ...and the expensive full details re-fetch was still avoided + tmdbClient.MovieDetailsRequested.Should().NotContain("42"); + } + + [Fact] + public async Task RefreshMovieReferenceAsync_DoesNotCallOmdb_OnTheShortCircuit_WhenImdbIsAlreadyPresent() + { + var tmdbClient = FakeTmdbClient.WithTvShowSearchResults(); + tmdbClient.ChangedSince["42"] = false; + _movieReferenceRepository.Setup(r => r.UpsertAsync(It.IsAny())).ReturnsAsync((MovieReferenceModel m) => m); + var reference = new MovieReferenceModel + { + Id = "reference-1", Title = "Some Movie", TitleNormalized = "some movie", Year = 2020, + ExternalIds = new Dictionary { ["tmdb"] = "42", ["imdb"] = "tt0042" }, + LastEnrichedAt = DateTime.UtcNow.AddDays(-5), + Ratings = new Dictionary + { + ["tmdb"] = new() { Value = 7.8, Scale = 10, Count = 1234 }, + ["imdb"] = new() { Value = 8.9, Scale = 10, Count = 500_000 } + } + }; + var service = CreateService(tmdbClient); + + var (_, changed) = await service.RefreshMovieReferenceAsync(reference, TestContext.Current.CancellationToken); + + changed.Should().BeFalse(); + _omdbClient.Requested.Should().BeEmpty(); + _movieRepository.Verify(r => r.SetReferenceRatingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task RecomputeReferenceRatingsAsync_ReStampsMovies_WithTheSelectedImdbSource() + { + _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()) + .ReturnsAsync(new Dictionary { ["Movie"] = "imdb" }); + _movieReferenceRepository.Setup(r => r.FindAllAsync()).ReturnsAsync( + [ + new MovieReferenceModel + { + Id = "r1", Title = "Some Movie", TitleNormalized = "some movie", ExternalIds = [], + Ratings = new Dictionary + { + ["tmdb"] = new() { Value = 7.8, Scale = 10, Count = 1 }, + ["imdb"] = new() { Value = 8.9, Scale = 10, Count = 2 } + } + } + ]); + _movieRepository.Setup(r => r.SetReferenceRatingAsync("r1", 8.9, 10)).ReturnsAsync(1); + var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); + + var (checkedCount, updated) = await service.RecomputeReferenceRatingsAsync(ReferenceItemType.Movie); + + checkedCount.Should().Be(1); + updated.Should().Be(1); + _movieRepository.Verify(r => r.SetReferenceRatingAsync("r1", 8.9, 10), Times.Once); + } + [Fact] public async Task RefreshTvShowReferenceAsync_ReturnsUnchanged_WhenReferenceHasNoTmdbId() { @@ -1041,7 +1211,8 @@ public async Task RecomputeReferenceRatingsAsync_Throws_ForADomainWhoseSourceIsN { var service = CreateService(FakeTmdbClient.WithTvShowSearchResults()); - await Assert.ThrowsAsync(() => service.RecomputeReferenceRatingsAsync(ReferenceItemType.Movie)); + // Album is still single-source (Book/Album haven't gained a second source), so it's not recomputable + await Assert.ThrowsAsync(() => service.RecomputeReferenceRatingsAsync(ReferenceItemType.Album)); } private static VideoGameReferenceModel VideoGameReferenceWithRatings(string id, double rawg, double metacritic) => new() @@ -1368,6 +1539,7 @@ public async Task RefreshAlbumReferenceAsync_AlwaysRefetches_RegardlessOfLastEnr ///
private ReferenceEnrichmentService CreateServiceWithStrictClients() => new( new Mock(MockBehavior.Strict).Object, + new Mock(MockBehavior.Strict).Object, new BookReferenceClientRegistry([new Mock(MockBehavior.Strict).Object], DefaultBookProvider), new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object, @@ -1509,5 +1681,22 @@ public Task HasMovieChangedSinceAsync(string tmdbId, DateTime since, Cance ChangesRequested.Add(tmdbId); return Task.FromResult(ChangedSince.GetValueOrDefault(tmdbId, true)); } + + /// imdb id served by the cheap external-ids lookup - keyed by tmdb id, defaults to null (unknown). + public Dictionary ImdbIds { get; } = new(); + + public List ImdbIdsRequested { get; } = []; + + public Task GetTvShowImdbIdAsync(string tmdbId, CancellationToken cancellationToken = default) + { + ImdbIdsRequested.Add(tmdbId); + return Task.FromResult(ImdbIds.GetValueOrDefault(tmdbId)); + } + + public Task GetMovieImdbIdAsync(string tmdbId, CancellationToken cancellationToken = default) + { + ImdbIdsRequested.Add(tmdbId); + return Task.FromResult(ImdbIds.GetValueOrDefault(tmdbId)); + } } } diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs index 18478043..22300097 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs @@ -39,7 +39,7 @@ private ReferenceSyncService CreateService(FakeTmdbClient tmdbClient) var appSettingRepository = new Mock(); appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()).ReturnsAsync(new Dictionary()); var enrichmentService = new ReferenceEnrichmentService( - tmdbClient, new BookReferenceClientRegistry([FakeBookReferenceClient.Empty()], "openlibrary"), bookRatingLookup.Object, FakeRawgClient.Empty(), FakeDiscogsClient.Empty(), + tmdbClient, FakeOmdbClient.Empty(), new BookReferenceClientRegistry([FakeBookReferenceClient.Empty()], "openlibrary"), bookRatingLookup.Object, FakeRawgClient.Empty(), FakeDiscogsClient.Empty(), _tvShowReferenceRepository.Object, _movieReferenceRepository.Object, _personReferenceRepository.Object, _bookReferenceRepository.Object, _videoGameReferenceRepository.Object, _albumReferenceRepository.Object, _tvShowRepository.Object, _movieRepository.Object, _bookRepository.Object, _videoGameRepository.Object, _albumRepository.Object, @@ -188,5 +188,11 @@ public Task HasTvShowChangedSinceAsync(string tmdbId, DateTime since, Canc public Task HasMovieChangedSinceAsync(string tmdbId, DateTime since, CancellationToken cancellationToken = default) => Task.FromResult(true); + + public Task GetTvShowImdbIdAsync(string tmdbId, CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task GetMovieImdbIdAsync(string tmdbId, CancellationToken cancellationToken = default) => + Task.FromResult(null); } } From 894f8be7fbf243326ddb1d8809b4eb90296ba549 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 30 Jul 2026 21:40:07 +0200 Subject: [PATCH 38/80] Add explore for tv shows and movies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What Increment 1 added (backend foundation — no user-visible UI yet) Domain - ExploreItemType (Movie/TvShow/VideoGame), ExploreDismissalModel, ExploreSuggestionModel - IExploreDismissalRepository - FindTopRatedAsync(source, excludeIds, limit) on IMovieReferenceRepository / ITvShowReferenceRepository - FindLinkedReferenceIdsAsync(ownerId) on IMovieRepository / ITvShowRepository - ExploreService — the domain-generic orchestration (exclude-set union + create-from-reference shared via generic helpers; per-domain arms only differ by data shape) Infrastructure - ExploreDismissal entity + ExploreDismissalRepository (idempotent SetOnInsert upsert; reads project ids via Distinct, so no mapper needed) - FindTopRatedAsync (filter ratings..value exists, $nin exclusions, sort desc, limit) and FindLinkedReferenceIdsAsync implementations - DI registration for the repo and ExploreService (Scoped) Scripts — scripts/mongodb-create-index.js: partial indexes on ratings.{tmdb,imdb}.value for movie_reference/tvshow_reference, plus a unique explore_dismissal_key. Tests - ExploreServiceTest (unit, mocked): exclusion union, source-value projection, add-links-and-copies-rating, add-returns-false-when-gone, TV-show domain routing, dismiss/undo. - ExploreReferenceRepositoryTest (integration, real Mongo): the sort/filter/exclude/limit semantics a mock can't prove — this one needs your Mongo + Local.runsettings, so run it on your side. Notes for running it Nothing is exposed via API or UI yet (that's Increments 2 and 3), so there's nothing new to click in the app. When convenient, re-run scripts/mongodb-create-index.js to create the new indexes (queries work without them, just unindexed on the small shared collection). What Increment 2 added Contracts — ExploreSuggestionDto (referenceId, title, year, imageUrl, synopsis, genres, rating, ratingScale). WebApi - ExploreSuggestionDtoMapper (one-directional Model→Dto, InProgressShowDtoMapper shape), registered in Program.cs. - ExploreController ([Authorize], api/explore): - GET /api/explore/{type}?count= — top suggestions (default 24, clamped to 60), ranked by the domain's admin-selected primary rating source (GetPrimaryRatingSourceAsync — no new setting). - POST /api/explore/{type}/add/{referenceId} — one-click add; enforces the same free-tier quota as ordinary creates (FreeTierQuota, factor 1 for Movie/TV); 404 if the reference is gone. - POST / DELETE /api/explore/{type}/dismiss/{referenceId} — dismiss and undo. - {type} binds the shared ReferenceItemType; only Movie/TvShow are accepted today (Book/Album → 400; Video games join in Increment 4). Tests - ExploreResourceTest (integration, real Mongo + HTTP): seed a top-rated reference → it's suggested → dismiss hides it → undo restores it → add creates a linked movie and excludes it as tracked; plus the 400 (unsupported domain) and 401 (unauthenticated) cases. Runs on your side (needs Mongo + Firebase creds). What Increment 3 added (Blazor UI) - ExploreApiClient (Components/Explore/) — GetAsync/AddAsync/DismissAsync, registered in DI with AuthenticationTokenHandler. - ExplorePage.razor at route /explore: - Tabs Movies / TV shows (active tab persisted in ?tab=, so back/forward and refresh keep it), grid/list via the shared ListViewToggle (your saved preference). - Each suggestion card shows cover, title, year, and the ★ reference-rating pill, with ✓ Add (one-click add to your collection) and ✕ Dismiss actions — grid overlay + list-row buttons. - ↻ Refresh button (full reload), plus automatic top-up: when a tab drops below 8 cards after adds/dismisses, it appends fresh suggestions below the current ones (no reshuffle), so it never runs dry. - Loading / empty / error states; per-card busy guard against double-clicks. - Nav link "✦ Explore" — placed with Watch next / Wishlist (outside the MemberOnly block, since movies & TV are free-tier). - app.css — .kt-explore-actions / .kt-explore-row-actions overlay styles (add stays visible, unlike the hover-only delete). Shared logic stays un-duplicated: add and dismiss both go through one ActAsync (busy-guard → remove → top-up).What changed (the real fix) Explore now queries TMDB's top-rated lists — the provider, not the local DB. That was the core mistake; querying the local reference collection could only ever surface titles you'd already tracked (plus test junk like "Pride"). Now: - GET /api/explore/{Movie|TvShow} pulls TMDB /movie/top_rated and /tv/top_rated (paging through until it has enough), and excludes titles you already track (matched by TMDB id on your linked references) and anything you've dismissed. Movies will now be full of acclaimed films you don't own. - Add reuses the ordinary POST /api/movies / /api/tv-shows create path — so it gets create + auto-resolve (reference link + rating) + the free-tier quota for free, no duplicated logic. The button now uses the sharing page's AddToCollectionIcon in a kt-icon-btn, with the same ↻ spinner while it works — exactly as you asked. - Dismiss is keyed by the TMDB id (POST/DELETE /api/explore/{type}/dismiss/{tmdbId}), idempotent. - Ranking/rating shown is TMDB's own vote average. Removed the now-dead local-query code: FindTopRatedAsync (both reference repos), the Domain ExploreService/ExploreSuggestionModel/ExploreSuggestionDtoMapper, and the ratings.*.value indexes. The explore_dismissal collection now keys on external_id. Tests: new ExploreServiceTest (fake TMDB — exclusion of tracked/dismissed, provider paging to fill the limit, TV routing, dismiss/undo); resource test trimmed to auth + domain-guard + dismiss round-trip (the live-TMDB listing belongs in the Playwright suite, which already has a TMDB key).- The rating shown and the ordering follow the admin's primary source for the domain (via the shared RatingSourceCatalog.Resolve — same setting GetPrimaryRatingSourceAsync uses, now factored into one resolver so there's no duplicated logic): - TMDB selected (default): uses TMDB's own vote from the top-rated response — zero extra calls. - IMDb selected: enriches each returned title via OMDb (imdb id from TMDB's external_ids, then one OMDb call), then re-ranks the page by the IMDb rating (unrated last). If no OMDb key is configured, titles simply show no pill but are still suggested. - The discovery list still comes from TMDB top-rated — that's unavoidable: IMDb has no top-rated/catalogue API (OMDb only returns a rating for a known id). So IMDb mode = "TMDB's top pool, ranked and shown by IMDb score." New test GetSuggestionsAsync_WhenImdbIsThePrimarySource_ShowsAndRanksByImdbRatings proves the re-ranking and that the IMDb value is shown. One cost caveat worth knowing: in IMDb mode, each page load makes ~2 calls per shown title (TMDB external_ids + OMDb), bounded to the page (≤24). OMDb's free tier is 1000/day, so heavy scrolling can add up. TMDB mode has no such cost. Added: admin toggle to keep Explore on TMDB A global admin setting — "Explore: always rank and show TMDB ratings for movies & TV, even when IMDb is the default source" — on the reference-data admin page (in the Primary rating source card). When on, Explore uses TMDB's own vote for ranking/display and makes zero OMDb/imdb-id calls, so discovery stays fast and free regardless of your IMDb default. Wired end-to-end, reusing the existing app_setting mechanism: - Storage: new explore_use_tmdb field on the single app_setting doc; IAppSettingRepository.Get/SetExploreUseTmdbAsync (targeted $set, defaults false). - Service: ExploreService forces the source to TMDB when the flag is set (after resolving the primary source), so the IMDb enrichment path is skipped entirely. - API: GET/PUT /api/reference-data/explore-settings (admin-only) with ExploreSettingsDto { UseTmdbRanking }. - UI: a checkbox that saves on change, loaded with the rest of the admin page. - Test: GetSuggestionsAsync_WhenExploreIsForcedToTmdb_... asserts TMDB order/ratings and no OMDb/imdb-id calls even with IMDb as the primary source. So: leave the toggle off to have Explore follow your IMDb default (with per-title OMDb lookups), or turn it on to force cheap TMDB ranking while the rest of the app still uses IMDb for pills/sort.1. Add didn't link movies (worked for TV). The old add posted to /api/movies, whose background auto-resolve is a title search that only links on exactly one candidate — movies often return several, so no link. Now Explore has its own POST /api/explore/{type}/add/{tmdbId} that creates the item and calls ResolveMovieAsync/ResolveTvShowAsync with the exact TMDB id the suggestion came from. Reliable for both movies and TV (it's the id, not a guess). It's awaited, so by the time the card disappears the item is already linked (a few TMDB calls, covered by the button spinner). 2. Wrong order on refresh (low/no-rating first). Root cause: when IMDb was the source, I re-sorted the page by IMDb rating — and with partial/failed OMDb data (rate limits, no key), low- or null-rated titles floated to the top. Now the order is always TMDB's top-rated ranking (the only discovery list there is); IMDb only fills in the displayed number, so missing OMDb data can no longer scramble the list. 3. Dismiss didn't refill. The top-up only fired below 8 items. Now it refills whenever the list drops below the page size (24), so dismissing/adding a single card is promptly backfilled with the next title — appended at the bottom, never reshuffling what's on screen. 4. Checkbox alignment. Switched to a proper Bootstrap form-check (with a divider above it), so the box lines up with the label. On your "will more settings look like a mess" point: the toggle now sits in its own form-check block under a divider in the rating card, so additional Explore settings would stack cleanly there. If it grows beyond 2–3, the clean move is a dedicated "Explore" settings card — easy to split out when it's actually needed rather than pre-building it. One tradeoff to flag: with IMDb as the default source, Explore still does per-title OMDb lookups just to show the IMDb number (order is unaffected now). If you'd rather avoid those calls entirely, flip the admin "Explore: always use TMDB ratings" toggle — order is identical, and the numbers come free from TMDB. --- scripts/mongodb-create-index.js | 6 + .../Components/Explore/ExploreApiClient.cs | 33 +++ .../Components/Explore/ExplorePage.razor | 223 ++++++++++++++++++ src/BlazorApp/Components/Layout/NavMenu.razor | 5 + .../ReferenceDataAdminApiClient.cs | 11 + .../ReferenceDataAdminPage.razor | 24 ++ ...frastructureServiceCollectionExtensions.cs | 2 + src/BlazorApp/wwwroot/app.css | 16 ++ src/Domain/Models/ExploreDismissalModel.cs | 18 ++ src/Domain/Models/ExploreItemType.cs | 14 ++ .../Repositories/IAppSettingRepository.cs | 9 + .../IExploreDismissalRepository.cs | 22 ++ src/Domain/Repositories/IMovieRepository.cs | 6 + src/Domain/Repositories/ITvShowRepository.cs | 6 + .../Entities/AppSetting.cs | 8 + .../Entities/ExploreDismissal.cs | 26 ++ .../Repositories/AppSettingRepository.cs | 13 + .../ExploreDismissalRepository.cs | 43 ++++ .../Repositories/MovieRepository.cs | 11 + .../Repositories/TvShowRepository.cs | 11 + .../Dto/ExploreAddRequestDto.cs | 14 ++ .../Dto/ExploreSettingsDto.cs | 13 + .../Dto/ExploreSuggestionDto.cs | 35 +++ src/WebApi/Controllers/ExploreController.cs | 119 ++++++++++ ...frastructureServiceCollectionExtensions.cs | 1 + src/WebApi/Program.cs | 1 + src/WebApi/ReferenceData/ExploreService.cs | 140 +++++++++++ src/WebApi/ReferenceData/ITmdbClient.cs | 16 ++ .../ReferenceData/RatingSourceCatalog.cs | 11 + .../ReferenceDataAdminController.cs | 15 ++ .../ReferenceEnrichmentService.cs | 4 +- src/WebApi/ReferenceData/TmdbClient.cs | 19 ++ .../Resources/ExploreResourceTest.cs | 40 ++++ .../TvTimeImportServiceIdempotencyTest.cs | 6 + .../ReferenceData/ExploreServiceTest.cs | 151 ++++++++++++ .../ReferenceEnrichmentServiceTest.cs | 6 + .../ReferenceData/ReferenceSyncServiceTest.cs | 6 + 37 files changed, 1101 insertions(+), 3 deletions(-) create mode 100644 src/BlazorApp/Components/Explore/ExploreApiClient.cs create mode 100644 src/BlazorApp/Components/Explore/ExplorePage.razor create mode 100644 src/Domain/Models/ExploreDismissalModel.cs create mode 100644 src/Domain/Models/ExploreItemType.cs create mode 100644 src/Domain/Repositories/IExploreDismissalRepository.cs create mode 100644 src/Infrastructure.MongoDb/Entities/ExploreDismissal.cs create mode 100644 src/Infrastructure.MongoDb/Repositories/ExploreDismissalRepository.cs create mode 100644 src/WebApi.Contracts/Dto/ExploreAddRequestDto.cs create mode 100644 src/WebApi.Contracts/Dto/ExploreSettingsDto.cs create mode 100644 src/WebApi.Contracts/Dto/ExploreSuggestionDto.cs create mode 100644 src/WebApi/Controllers/ExploreController.cs create mode 100644 src/WebApi/ReferenceData/ExploreService.cs create mode 100644 test/WebApi.IntegrationTests/Resources/ExploreResourceTest.cs create mode 100644 test/WebApi.UnitTests/ReferenceData/ExploreServiceTest.cs diff --git a/scripts/mongodb-create-index.js b/scripts/mongodb-create-index.js index 13a67298..86df0eaa 100644 --- a/scripts/mongodb-create-index.js +++ b/scripts/mongodb-create-index.js @@ -278,3 +278,9 @@ ensureIndex( // index is what actually guarantees that, the same way the application-level upsert-by-owner-id logic in // UserPreferencesRepository is only "supposed to" prevent a second document. ensureIndex(db.user_preference, { owner_id: 1 }, { name: "user_preference_owner", unique: true }); + +// explore_dismissal: one document per (owner, type, provider external id) a user hid from their Explore list +// (Explore suggests provider titles - TMDB top-rated - not local references). Read by (owner_id, +// reference_type) to build the exclusion set; unique on the full natural key so a double-dismiss (the +// application also upserts idempotently) can never create a duplicate. +ensureIndex(db.explore_dismissal, { owner_id: 1, reference_type: 1, external_id: 1 }, { name: "explore_dismissal_key", unique: true }); diff --git a/src/BlazorApp/Components/Explore/ExploreApiClient.cs b/src/BlazorApp/Components/Explore/ExploreApiClient.cs new file mode 100644 index 00000000..b25e39b5 --- /dev/null +++ b/src/BlazorApp/Components/Explore/ExploreApiClient.cs @@ -0,0 +1,33 @@ +using System.Net.Http.Json; +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Explore; + +/// +/// Talks to the Explore endpoints (api/explore/{type}) for listing and dismissing suggestions. +/// is a member name ("Movie"/"TvShow"). Adding a +/// suggestion goes through the ordinary collection create endpoints, reusing their create + auto-resolve + +/// quota rather than a bespoke Explore add path. +/// +public sealed class ExploreApiClient(HttpClient http) +{ + public async Task> GetAsync(string type, int count) + { + var result = await http.GetFromJsonAsync>($"/api/explore/{type}?count={count}"); + return result ?? []; + } + + /// + /// Adds a suggestion to the caller's collection: the server creates the item and links it to the reference + /// resolved from the exact TMDB id. Throws on a non-success status (e.g. 403 over the free-preview quota). + /// + public async Task AddAsync(string type, string externalId, string? title, int? year) + { + var response = await http.PostAsJsonAsync($"/api/explore/{type}/add/{externalId}", new ExploreAddRequestDto { Title = title, Year = year }); + response.EnsureSuccessStatusCode(); + } + + /// Hides a title from the caller's Explore list. + public async Task DismissAsync(string type, string externalId) => + (await http.PostAsync($"/api/explore/{type}/dismiss/{externalId}", null)).EnsureSuccessStatusCode(); +} diff --git a/src/BlazorApp/Components/Explore/ExplorePage.razor b/src/BlazorApp/Components/Explore/ExplorePage.razor new file mode 100644 index 00000000..b5e61984 --- /dev/null +++ b/src/BlazorApp/Components/Explore/ExplorePage.razor @@ -0,0 +1,223 @@ +@page "/explore" +@attribute [Authorize] +@using System.Globalization +@using Keeptrack.BlazorApp.Components.Inventory +@using Keeptrack.BlazorApp.Components.Shared +@using Keeptrack.WebApi.Contracts.Dto + +
+

Explore

+
+ + +
+
+ +

+ Top-rated @(_tab == Tab.Movies ? "movies" : "TV shows") from TMDB you don't track yet. Add one to your + collection in a tap, or dismiss it to stop it coming back. +

+ +
+ + +
+ +@if (_error is not null) +{ +
@_error
+} + +@if (_loading && Current is null) +{ +
+
+ Loading… +
+} +else if (Current is { Count: 0 }) +{ +
+
+

No new suggestions right now - you already track the top-rated titles. Try Refresh, or dismiss fewer.

+
+} +else if (Current is { } items) +{ +
+ @if (_view == "grid") + { +
+ @foreach (var item in items) + { + + +
+ @if (_busy.Contains(item.ExternalId)) + { + + } + else + { + + } + +
+
+ + @if (item.Year > 0) + { + @item.Year + } + @if (item.Rating is not null) + { + @item.Rating.Value.ToString("0.0", CultureInfo.InvariantCulture) + } + +
+ } +
+ } + else + { +
+ @foreach (var item in items) + { +
+ +
+ @item.Title +
+ @if (item.Year > 0) + { + @item.Year + } + @if (item.Rating is not null) + { + @item.Rating.Value.ToString("0.0", CultureInfo.InvariantCulture) + } +
+
+
+ @if (_busy.Contains(item.ExternalId)) + { + + } + else + { + + } + +
+
+ } +
+ } +
+} + +@code { + private enum Tab { Movies, TvShows } + + [Inject] private ExploreApiClient Api { get; set; } = null!; + + [Inject] private NavigationManager Navigation { get; set; } = null!; + + [Inject] private ListViewPreference ViewPreference { get; set; } = null!; + + // Persisted in the URL (?tab=) like the Wishlist page, so back/forward and a refresh keep the active tab. + [SupplyParameterFromQuery(Name = "tab")] + public string? TabQuery { get; set; } + + /// How many suggestions to keep on screen; also the number requested per fetch. + private const int PageCount = 24; + + private Tab _tab; + private string _view = ""; + private bool _loading; + private string? _error; + + // one loaded list per tab, so switching back and forth doesn't refetch; the external ids currently being + // added/dismissed, to swap their button for a spinner and guard against a double click. + private readonly Dictionary> _cache = []; + private readonly HashSet _busy = []; + + private List? Current => _cache.TryGetValue(_tab, out var list) ? list : null; + + private string TypeName => _tab == Tab.Movies ? nameof(ReferenceItemType.Movie) : nameof(ReferenceItemType.TvShow); + + protected override void OnInitialized() => _view = ViewPreference.View; + + protected override async Task OnParametersSetAsync() + { + _tab = Enum.TryParse(TabQuery, out var tab) ? tab : Tab.Movies; + if (!_cache.ContainsKey(_tab)) await LoadAsync(); + } + + private void OnViewChanged(string view) => _view = view; + + private void SelectTab(Tab tab) => + Navigation.NavigateTo(Navigation.GetUriWithQueryParameters(new Dictionary { ["tab"] = tab.ToString() })); + + private async Task LoadAsync() + { + _error = null; + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); + _loading = false; + } + + private async Task FetchAsync() => _cache[_tab] = await Api.GetAsync(TypeName, PageCount); + + private async Task RefreshAsync() + { + _cache.Remove(_tab); + await LoadAsync(); + } + + private async Task AddAsync(ExploreSuggestionDto item) => await ActAsync(item, () => Api.AddAsync(TypeName, item.ExternalId, item.Title, item.Year)); + + private async Task DismissAsync(ExploreSuggestionDto item) => await ActAsync(item, () => Api.DismissAsync(TypeName, item.ExternalId)); + + // Add and dismiss share everything but the one call: run it, drop the card, and top the list back up if + // it's getting short - so the two handlers never duplicate the busy-guard / remove / top-up logic. + private async Task ActAsync(ExploreSuggestionDto item, Func action) + { + if (!_busy.Add(item.ExternalId)) return; + try + { + await action(); + Current?.Remove(item); + await TopUpAsync(); + _error = null; + } + catch (Exception) + { + _error = "Something went wrong - please try again."; + } + finally + { + _busy.Remove(item.ExternalId); + } + } + + // Keep the list topped up after an add/dismiss: append any newly-available suggestions below the current + // ones (never reshuffling what's on screen), so removing a card is promptly backfilled. A no-op once the + // server has nothing new left to add. + private async Task TopUpAsync() + { + var list = Current; + if (list is null || list.Count >= PageCount) return; + + var fresh = await Api.GetAsync(TypeName, PageCount); + var have = list.Select(x => x.ExternalId).ToHashSet(); + list.AddRange(fresh.Where(x => !have.Contains(x.ExternalId))); + } +} diff --git a/src/BlazorApp/Components/Layout/NavMenu.razor b/src/BlazorApp/Components/Layout/NavMenu.razor index 01c2042c..a78651ae 100644 --- a/src/BlazorApp/Components/Layout/NavMenu.razor +++ b/src/BlazorApp/Components/Layout/NavMenu.razor @@ -31,6 +31,11 @@ Wishlist
+ } +
+
+ + +
@if (_ratingSourceError is not null) {
@_ratingSourceError
@@ -480,16 +488,32 @@ private RecomputeRatingsResultDto? _recomputeResult; private ReferenceItemType? _recomputedDomain; private string? _ratingSourceError; + private bool _exploreUseTmdb; protected override async Task OnInitializedAsync() { _bookProviders = await Api.GetBookProvidersAsync(); _selectedProvider = _bookProviders.FirstOrDefault()?.Key; _ratingSources = await Api.GetRatingSourcesAsync(); + _exploreUseTmdb = (await Api.GetExploreSettingsAsync()).UseTmdbRanking; await LoadUnresolvedAsync(); await LoadSystemStatusAsync(); } + private async Task OnExploreUseTmdbChangedAsync(ChangeEventArgs e) + { + _exploreUseTmdb = e.Value as bool? ?? false; + try + { + await Api.SetExploreSettingsAsync(_exploreUseTmdb); + _ratingSourceError = null; + } + catch (Exception ex) + { + _ratingSourceError = ex.Message; + } + } + private static string SourceLabel(string source) => source switch { "tmdb" => "TMDB", diff --git a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 2d903780..9210d9d2 100644 --- a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -47,6 +47,8 @@ internal static void AddWebApiHttpClient(this IServiceCollection services, strin .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index 5d0c9884..3f371852 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -517,6 +517,22 @@ a.kt-item-row { color: inherit; text-decoration: none; } } .kt-grid-card:hover .kt-grid-delete, .kt-grid-delete:focus-visible { opacity: 1; } +/* Explore card actions (add / dismiss). Unlike the delete button they are the card's whole purpose, so + they stay visible at rest rather than appearing on hover. Add sits left, dismiss right, across the top. */ +.kt-explore-actions { + position: absolute; + top: 0.35rem; + left: 0.35rem; + right: 0.35rem; + z-index: 2; + display: flex; + justify-content: space-between; + gap: 0.35rem; +} +.kt-explore-actions .kt-icon-btn { background: rgba(0, 0, 0, 0.55); border-radius: 6px; } +/* list-row variant: actions on the right, always visible */ +.kt-explore-row-actions { position: relative; z-index: 2; display: flex; align-items: center; gap: 0.4rem; } + .kt-grid-caption { padding: 0.5rem 0.15rem 0; } .kt-grid-title { font-weight: 500; diff --git a/src/Domain/Models/ExploreDismissalModel.cs b/src/Domain/Models/ExploreDismissalModel.cs new file mode 100644 index 00000000..cec1345f --- /dev/null +++ b/src/Domain/Models/ExploreDismissalModel.cs @@ -0,0 +1,18 @@ +namespace Keeptrack.Domain.Models; + +/// +/// One "don't suggest this to me again" record: an owner has dismissed a specific provider title (by its +/// external id - a TMDB id today) from their Explore list for a given domain. Per-user (owner-scoped). The +/// natural key is (owner, type, external id) - dismissing the same suggestion twice is idempotent. +/// +public class ExploreDismissalModel +{ + public string? Id { get; set; } + + public required string OwnerId { get; set; } + + public required ExploreItemType ReferenceType { get; set; } + + /// The provider's external id (TMDB id) of the dismissed title. + public required string ExternalId { get; set; } +} diff --git a/src/Domain/Models/ExploreItemType.cs b/src/Domain/Models/ExploreItemType.cs new file mode 100644 index 00000000..f73224dd --- /dev/null +++ b/src/Domain/Models/ExploreItemType.cs @@ -0,0 +1,14 @@ +namespace Keeptrack.Domain.Models; + +/// +/// Which reference-ranked domain an Explore ("suggest top-rated, not-yet-added") action applies to. +/// Only the domains where a provider ranking is meaningful appear here - movies/TV today, video games +/// next; books and albums are deliberately excluded (an aggregate rank doesn't drive discovery there). +/// The Domain-side counterpart of the ReferenceItemType DTO enum, mapped by name in the controller. +/// +public enum ExploreItemType +{ + Movie, + TvShow, + VideoGame +} diff --git a/src/Domain/Repositories/IAppSettingRepository.cs b/src/Domain/Repositories/IAppSettingRepository.cs index 9ab9b9f5..ce7283ba 100644 --- a/src/Domain/Repositories/IAppSettingRepository.cs +++ b/src/Domain/Repositories/IAppSettingRepository.cs @@ -22,4 +22,13 @@ public interface IAppSettingRepository /// Sets (or replaces) the primary rating source for one domain. Upserts the single settings document. ///
Task SetReferenceRatingSourceAsync(string domainKey, string source); + + /// + /// Whether the Explore discovery feature should rank/show TMDB ratings for movies and TV shows even when + /// IMDb is the selected primary source (avoiding a per-title OMDb lookup). Defaults to false. + /// + Task GetExploreUseTmdbAsync(); + + /// Sets the Explore "use TMDB ranking" flag. Upserts the single settings document. + Task SetExploreUseTmdbAsync(bool useTmdb); } diff --git a/src/Domain/Repositories/IExploreDismissalRepository.cs b/src/Domain/Repositories/IExploreDismissalRepository.cs new file mode 100644 index 00000000..ca8ccd56 --- /dev/null +++ b/src/Domain/Repositories/IExploreDismissalRepository.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +/// +/// Persistence for - purpose-built rather than +/// (owner-scoped, looked up only by (owner, type), never paged/searched), +/// same reasoning as . +/// +public interface IExploreDismissalRepository +{ + /// The provider external ids this owner has dismissed for - part of the Explore exclusion set. + Task> FindDismissedExternalIdsAsync(string ownerId, ExploreItemType type); + + /// Records a dismissal. Idempotent: dismissing the same (owner, type, external id) twice is a no-op. + Task AddAsync(ExploreDismissalModel model); + + /// Undoes a dismissal so the title can be suggested again. Owner-scoped. + Task RemoveAsync(string ownerId, ExploreItemType type, string externalId); +} diff --git a/src/Domain/Repositories/IMovieRepository.cs b/src/Domain/Repositories/IMovieRepository.cs index 570f1947..48322a1a 100644 --- a/src/Domain/Repositories/IMovieRepository.cs +++ b/src/Domain/Repositories/IMovieRepository.cs @@ -27,4 +27,10 @@ public interface IMovieRepository : IDataRepository /// yet - feeds the admin curation queue. /// Task> FindDistinctUnresolvedTitleYearsAsync(); + + /// + /// Distinct non-empty s this owner already tracks - the "already added" + /// exclusion set for Explore suggestions (a reference the owner already has a movie linked to is never suggested). + /// + Task> FindLinkedReferenceIdsAsync(string ownerId); } diff --git a/src/Domain/Repositories/ITvShowRepository.cs b/src/Domain/Repositories/ITvShowRepository.cs index dd182f3d..84f33ca6 100644 --- a/src/Domain/Repositories/ITvShowRepository.cs +++ b/src/Domain/Repositories/ITvShowRepository.cs @@ -28,6 +28,12 @@ public interface ITvShowRepository : IDataRepository /// Task> FindDistinctUnresolvedTitleYearsAsync(); + /// + /// Distinct non-empty s this owner already tracks - the "already added" + /// exclusion set for Explore suggestions (a reference the owner already has a show linked to is never suggested). + /// + Task> FindLinkedReferenceIdsAsync(string ownerId); + /// /// Every tenant's shows marked that carry a reference link - the /// candidates the periodic finished-show status reconciliation re-checks against their (freshly synced) diff --git a/src/Infrastructure.MongoDb/Entities/AppSetting.cs b/src/Infrastructure.MongoDb/Entities/AppSetting.cs index 098807c6..c3813787 100644 --- a/src/Infrastructure.MongoDb/Entities/AppSetting.cs +++ b/src/Infrastructure.MongoDb/Entities/AppSetting.cs @@ -17,4 +17,12 @@ public class AppSetting /// Domain key (e.g. VideoGame) → primary rating source key (e.g. metacritic). [BsonElement("reference_rating_source")] public Dictionary ReferenceRatingSources { get; set; } = new(); + + /// + /// When true, the Explore discovery feature ranks/shows TMDB ratings for movies and TV shows even when + /// IMDb is the selected primary rating source - avoiding a per-title OMDb lookup on every page load. + /// Defaults to false (Explore follows the primary source). + /// + [BsonElement("explore_use_tmdb")] + public bool ExploreUseTmdb { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/ExploreDismissal.cs b/src/Infrastructure.MongoDb/Entities/ExploreDismissal.cs new file mode 100644 index 00000000..972e9e03 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/ExploreDismissal.cs @@ -0,0 +1,26 @@ +using Keeptrack.Domain.Models; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +/// +/// One owner's "don't suggest this again" record for the Explore feature (explore_dismissal). The +/// natural key is (owner_id, reference_type, external_id); see . +/// +public class ExploreDismissal +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("owner_id")] + public required string OwnerId { get; set; } + + // stored as its enum member name via the registered EnumRepresentationConvention(BsonType.String). + [BsonElement("reference_type")] + public required ExploreItemType ReferenceType { get; set; } + + [BsonElement("external_id")] + public required string ExternalId { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Repositories/AppSettingRepository.cs b/src/Infrastructure.MongoDb/Repositories/AppSettingRepository.cs index b9c87ef0..4dffd5b4 100644 --- a/src/Infrastructure.MongoDb/Repositories/AppSettingRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/AppSettingRepository.cs @@ -32,4 +32,17 @@ public async Task SetReferenceRatingSourceAsync(string domainKey, string source) var update = Builders.Update.Set($"reference_rating_source.{domainKey}", source); await Collection.UpdateOneAsync(s => s.Id == GlobalId, update, new UpdateOptions { IsUpsert = true }); } + + public async Task GetExploreUseTmdbAsync() + { + var entity = await Collection.Find(s => s.Id == GlobalId).FirstOrDefaultAsync(); + return entity?.ExploreUseTmdb ?? false; + } + + public async Task SetExploreUseTmdbAsync(bool useTmdb) + { + // targeted $set on just this field, same as the rating-source setter above + var update = Builders.Update.Set(s => s.ExploreUseTmdb, useTmdb); + await Collection.UpdateOneAsync(s => s.Id == GlobalId, update, new UpdateOptions { IsUpsert = true }); + } } diff --git a/src/Infrastructure.MongoDb/Repositories/ExploreDismissalRepository.cs b/src/Infrastructure.MongoDb/Repositories/ExploreDismissalRepository.cs new file mode 100644 index 00000000..75a6062e --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/ExploreDismissalRepository.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +/// +/// Persistence for Explore dismissals. Reads only ever project the external id (via Distinct) and +/// writes only ever set the natural-key fields, so this needs no full model <-> entity mapper. +/// +public class ExploreDismissalRepository(IMongoDatabase mongoDatabase) : IExploreDismissalRepository +{ + private const string CollectionName = "explore_dismissal"; + + private IMongoCollection Collection => mongoDatabase.GetCollection(CollectionName); + + public async Task> FindDismissedExternalIdsAsync(string ownerId, ExploreItemType type) + { + var filter = Builders.Filter.Eq(d => d.OwnerId, ownerId) + & Builders.Filter.Eq(d => d.ReferenceType, type); + return await Collection.Distinct(d => d.ExternalId, filter).ToListAsync(); + } + + public async Task AddAsync(ExploreDismissalModel model) + { + var filter = Builders.Filter.Eq(d => d.OwnerId, model.OwnerId) + & Builders.Filter.Eq(d => d.ReferenceType, model.ReferenceType) + & Builders.Filter.Eq(d => d.ExternalId, model.ExternalId); + // SetOnInsert-only upsert: inserts the record when missing, a no-op when it already exists - so + // dismissing the same suggestion twice never creates a duplicate row. + var update = Builders.Update + .SetOnInsert(d => d.OwnerId, model.OwnerId) + .SetOnInsert(d => d.ReferenceType, model.ReferenceType) + .SetOnInsert(d => d.ExternalId, model.ExternalId); + await Collection.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true }); + } + + public async Task RemoveAsync(string ownerId, ExploreItemType type, string externalId) => + await Collection.DeleteOneAsync(d => d.OwnerId == ownerId && d.ReferenceType == type && d.ExternalId == externalId); +} diff --git a/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs b/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs index 92d50513..abd16671 100644 --- a/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs @@ -75,6 +75,17 @@ public async Task SetReferenceRatingAsync(string referenceId, double? rati return groups.Select(g => (g.Title, g.Year, (string?)null)).ToList(); } + public async Task> FindLinkedReferenceIdsAsync(string ownerId) + { + var builder = Builders.Filter; + // "linked" is the inverse of UnresolvedFilter: a real reference id, not null and not the legacy empty-string sentinel. + var filter = builder.Eq(f => f.OwnerId, ownerId) + & builder.Ne(f => f.ReferenceId, null) + & builder.Ne(f => f.ReferenceId, string.Empty); + var ids = await GetCollection().Distinct(f => f.ReferenceId, filter).ToListAsync(); + return ids.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).ToList(); + } + /// /// "Has no reference link yet" means is null OR empty string, not /// just null: old documents (written before the AutoMapper -> Mapperly migration) can still store "" diff --git a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs index 64bf8c3d..bbb62013 100644 --- a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs @@ -83,6 +83,17 @@ public async Task> FindFinishedLinkedShowsAsync() return groups.Select(g => (g.Title, g.Year, (string?)null)).ToList(); } + public async Task> FindLinkedReferenceIdsAsync(string ownerId) + { + var builder = Builders.Filter; + // "linked" is the inverse of UnresolvedFilter - see FindFinishedLinkedShowsAsync for the same clause. + var filter = builder.Eq(f => f.OwnerId, ownerId) + & builder.Ne(f => f.ReferenceId, null) + & builder.Ne(f => f.ReferenceId, string.Empty); + var ids = await GetCollection().Distinct(f => f.ReferenceId, filter).ToListAsync(); + return ids.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).ToList(); + } + /// /// "Has no reference link yet" means is null OR empty string, not /// just null: old documents (written before the AutoMapper -> Mapperly migration) can still store "" diff --git a/src/WebApi.Contracts/Dto/ExploreAddRequestDto.cs b/src/WebApi.Contracts/Dto/ExploreAddRequestDto.cs new file mode 100644 index 00000000..81e16957 --- /dev/null +++ b/src/WebApi.Contracts/Dto/ExploreAddRequestDto.cs @@ -0,0 +1,14 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Body for adding an Explore suggestion to the caller's collection - the suggestion's own title and year, +/// used to create the item; the reference link is then made from the exact TMDB id in the route. +/// +public class ExploreAddRequestDto +{ + /// The suggestion's title (the created item's title, before reference resolution canonicalizes it). + public string? Title { get; set; } + + /// The suggestion's year. + public int? Year { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/ExploreSettingsDto.cs b/src/WebApi.Contracts/Dto/ExploreSettingsDto.cs new file mode 100644 index 00000000..9319ccd2 --- /dev/null +++ b/src/WebApi.Contracts/Dto/ExploreSettingsDto.cs @@ -0,0 +1,13 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Global Explore-feature settings an admin controls - see GET/PUT /api/reference-data/explore-settings. +/// +public class ExploreSettingsDto +{ + /// + /// When true, Explore ranks/shows TMDB ratings for movies and TV shows even when IMDb is the primary + /// rating source, so discovery avoids a per-title OMDb lookup on every page load. + /// + public bool UseTmdbRanking { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/ExploreSuggestionDto.cs b/src/WebApi.Contracts/Dto/ExploreSuggestionDto.cs new file mode 100644 index 00000000..12d4a59d --- /dev/null +++ b/src/WebApi.Contracts/Dto/ExploreSuggestionDto.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One Explore suggestion: a top-rated provider title the caller doesn't already track and hasn't dismissed. +/// (the provider/TMDB id) keys the dismiss action; adding uses / +/// to create and auto-resolve a collection item. +/// +public class ExploreSuggestionDto +{ + /// The provider's external id (TMDB id) of this title. + public required string ExternalId { get; set; } + + /// Canonical title from the reference provider. + public required string Title { get; set; } + + /// Release/first-air year, when known. + public int? Year { get; set; } + + /// Cover/poster image URL (hotlinked from the provider's CDN), when available. + public string? ImageUrl { get; set; } + + /// Provider synopsis, when available. + public string? Synopsis { get; set; } + + /// Provider genres, when available. + public List Genres { get; set; } = []; + + /// The ranking source's rating value (the value the suggestions were ordered by). + public double? Rating { get; set; } + + /// Scale of (10 for TMDB/IMDb, 5 for RAWG, 100 for Metacritic). + public double? RatingScale { get; set; } +} diff --git a/src/WebApi/Controllers/ExploreController.cs b/src/WebApi/Controllers/ExploreController.cs new file mode 100644 index 00000000..b768b59a --- /dev/null +++ b/src/WebApi/Controllers/ExploreController.cs @@ -0,0 +1,119 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.ReferenceData; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +/// +/// The Explore feature: acclaimed provider titles (TMDB top-rated) the caller doesn't already track, with a +/// one-click add and a dismiss/undo. Read-only aggregation plus a create, so a plain ; +/// the listing/dismissal logic lives in . Adding resolves the reference from the +/// exact TMDB id the suggestion came from (not a title search), so the new item is reliably linked. Available +/// to every authenticated account because movies and TV shows are the free preview tier. +/// +[ApiController] +[Authorize] +[Route("api/explore")] +public class ExploreController( + ExploreService exploreService, + ReferenceEnrichmentService enrichmentService, + IMovieRepository movieRepository, + ITvShowRepository tvShowRepository) : ControllerBase +{ + /// Default number of suggestions returned when the caller doesn't ask for a specific count. + private const int DefaultCount = 24; + + /// Upper bound on the count, so one request can't pull an unbounded number of provider pages. + private const int MaxCount = 60; + + /// Movies and TV shows are both part of the free preview tier, so adds count against the quota. + private const int FreeTierLimitFactor = 1; + + /// The top provider suggestions for a domain (movies or TV shows today). + [HttpGet("{type}")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task>> Get(ReferenceItemType type, [FromQuery] int? count, CancellationToken cancellationToken) + { + var limit = Math.Clamp(count ?? DefaultCount, 1, MaxCount); + var suggestions = await exploreService.GetSuggestionsAsync(ToDomainType(type), this.GetUserId(), limit, cancellationToken); + return Ok(suggestions); + } + + /// + /// Adds a suggestion to the caller's collection: creates the item and links it to the reference resolved + /// from the exact TMDB id (). 403 if a free-preview account is over its quota. + /// + [HttpPost("{type}/add/{externalId}")] + [ProducesResponseType(204)] + [ProducesResponseType(400)] + [ProducesResponseType(403)] + public async Task Add(ReferenceItemType type, string externalId, [FromBody] ExploreAddRequestDto request) + { + ToDomainType(type); // validates the domain (Book/Album -> 400) + if (string.IsNullOrWhiteSpace(request.Title)) return BadRequest(); + + var ownerId = this.GetUserId(); + var quotaError = await FreeTierQuota.CheckAsync(this, FreeTierLimitFactor, () => CountAsync(type, ownerId)); + if (quotaError is not null) + { + return StatusCode(StatusCodes.Status403Forbidden, new { error = quotaError }); + } + + // create the item unlinked, then resolve the reference by its exact TMDB id (upserts the shared + // reference document and links this just-created item by (title, year) - see ResolveMovieAsync). Using + // the id, not a title search, is why this links reliably where the ordinary create's search-based + // auto-resolve can miss a title with several TMDB candidates. + switch (type) + { + case ReferenceItemType.Movie: + await movieRepository.CreateAsync(new MovieModel { OwnerId = ownerId, Title = request.Title, Year = request.Year }); + await enrichmentService.ResolveMovieAsync(request.Title, request.Year, externalId); + break; + case ReferenceItemType.TvShow: + await tvShowRepository.CreateAsync(new TvShowModel { OwnerId = ownerId, Title = request.Title, Year = request.Year }); + await enrichmentService.ResolveTvShowAsync(request.Title, request.Year, externalId); + break; + } + + return NoContent(); + } + + /// Hides a provider title from the caller's Explore list permanently (until undone). Idempotent. + [HttpPost("{type}/dismiss/{externalId}")] + [ProducesResponseType(204)] + [ProducesResponseType(400)] + public async Task Dismiss(ReferenceItemType type, string externalId) + { + await exploreService.DismissAsync(ToDomainType(type), this.GetUserId(), externalId); + return NoContent(); + } + + /// Undoes a dismissal so the title can be suggested again. + [HttpDelete("{type}/dismiss/{externalId}")] + [ProducesResponseType(204)] + [ProducesResponseType(400)] + public async Task Undismiss(ReferenceItemType type, string externalId) + { + await exploreService.UndismissAsync(ToDomainType(type), this.GetUserId(), externalId); + return NoContent(); + } + + private Task CountAsync(ReferenceItemType type, string ownerId) => type switch + { + ReferenceItemType.Movie => movieRepository.CountAsync(ownerId), + ReferenceItemType.TvShow => tvShowRepository.CountAsync(ownerId), + _ => throw new ArgumentException($"Explore is not available for {type}.", nameof(type)) + }; + + // Only the reference-ranked domains where discovery is meaningful are exposed; Book/Album are rejected + // (ArgumentException -> 400 via ApiExceptionFilterAttribute). Video games join here in a later increment. + private static ExploreItemType ToDomainType(ReferenceItemType type) => type switch + { + ReferenceItemType.Movie => ExploreItemType.Movie, + ReferenceItemType.TvShow => ExploreItemType.TvShow, + _ => throw new ArgumentException($"Explore is not available for {type}.", nameof(type)) + }; +} diff --git a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 861174f3..81c6f7bf 100644 --- a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -57,6 +57,7 @@ internal static void AddMongoDbInfrastructure(this IServiceCollection services, services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index 9f219ef1..b1528796 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -118,6 +118,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddMongoDbInfrastructure(configuration); builder.Services.AddOpenApiWithBearerAuth(configuration); diff --git a/src/WebApi/ReferenceData/ExploreService.cs b/src/WebApi/ReferenceData/ExploreService.cs new file mode 100644 index 00000000..111f64b6 --- /dev/null +++ b/src/WebApi/ReferenceData/ExploreService.cs @@ -0,0 +1,140 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; + +namespace Keeptrack.WebApi.ReferenceData; + +/// +/// The Explore feature: reads the provider's own top-rated lists (TMDB today) and suggests acclaimed titles +/// the caller doesn't already track and hasn't dismissed. The discovery *list* has to come from TMDB - it's +/// the only movie/TV provider with a top-rated catalogue API (IMDb has none) - but the rating shown and the +/// ordering follow the admin's selected primary source, exactly like the rest of the app: TMDB's own vote by +/// default, or IMDb (via OMDb, keyed by the id TMDB exposes) when that's the chosen source. Querying the +/// provider - not the local reference collection, which only holds titles someone already tracked - is what +/// surfaces genuinely new things to watch. Lives in WebApi/ReferenceData (not Domain) as it depends on the +/// provider clients. Per-domain branching is confined to the small fetcher/lookup helpers. +/// +public class ExploreService( + ITmdbClient tmdbClient, + IOmdbClient omdbClient, + IAppSettingRepository appSettingRepository, + IMovieRepository movieRepository, + ITvShowRepository tvShowRepository, + IMovieReferenceRepository movieReferenceRepository, + ITvShowReferenceRepository tvShowReferenceRepository, + IExploreDismissalRepository dismissalRepository) +{ + private const string TmdbSourceKey = "tmdb"; + + /// TMDB and IMDb ratings are both on a 0-10 scale. + private const double RatingScale = 10; + + /// How many provider pages to pull through at most while filling a request (each ~20 titles). + private const int MaxProviderPages = 5; + + /// + /// The top- provider suggestions for , excluding titles the + /// owner already tracks (matched by TMDB id on their linked references) or dismissed. The rating shown and + /// the ordering follow the admin's primary source for the domain (TMDB by default, IMDb when selected). + /// + public async Task> GetSuggestionsAsync(ExploreItemType type, string ownerId, int limit, CancellationToken cancellationToken = default) + { + var overrides = await appSettingRepository.GetReferenceRatingSourcesAsync(); + var source = RatingSourceCatalog.Resolve(overrides, ToReferenceItemType(type)); + // an admin can force Explore onto TMDB (its own free vote average) even when IMDb is the primary + // rating source, so discovery never pays the per-title OMDb lookup cost. + if (await appSettingRepository.GetExploreUseTmdbAsync()) source = RatingSourceCatalog.Tmdb; + + var exclude = new HashSet(await dismissalRepository.FindDismissedExternalIdsAsync(ownerId, type)); + exclude.UnionWith(await TrackedExternalIdsAsync(type, ownerId)); + + var fetch = TopRatedFetcher(type); + var chosen = new List(); + for (var page = 1; page <= MaxProviderPages && chosen.Count < limit; page++) + { + var candidates = await fetch(page, cancellationToken); + if (candidates.Count == 0) break; + + foreach (var candidate in candidates) + { + if (!exclude.Add(candidate.TmdbId)) continue; // skip tracked/dismissed and any duplicate across pages + chosen.Add(candidate); + if (chosen.Count >= limit) break; + } + } + + return source == RatingSourceCatalog.Imdb + ? await MapWithImdbRatingsAsync(type, chosen, cancellationToken) + : chosen.Select(c => ToDto(c, c.VoteAverage)).ToList(); + } + + /// Hides a provider title from the owner's Explore list permanently (until undone). Idempotent. + public Task DismissAsync(ExploreItemType type, string ownerId, string externalId) => + dismissalRepository.AddAsync(new ExploreDismissalModel { OwnerId = ownerId, ReferenceType = type, ExternalId = externalId }); + + /// Undoes a dismissal so the title can be suggested again. + public Task UndismissAsync(ExploreItemType type, string ownerId, string externalId) => + dismissalRepository.RemoveAsync(ownerId, type, externalId); + + // Fill in each chosen title's IMDb rating for display (imdb id via TMDB's external_ids, then one OMDb + // call), bounded to the returned page. The ORDER stays TMDB's top-rated ranking - deliberately not + // re-sorted by IMDb: IMDb has no top-rated list API, so re-ranking a page by whatever OMDb happened to + // return (partial when rate-limited, blank with no key) would scramble the list and float low/unrated + // titles to the top. Task.WhenAll preserves the input (TMDB) order. + private async Task> MapWithImdbRatingsAsync(ExploreItemType type, List chosen, CancellationToken cancellationToken) + { + var imdbIdFetcher = ImdbIdFetcher(type); + var mapped = await Task.WhenAll(chosen.Select(async candidate => + { + var imdbId = await imdbIdFetcher(candidate.TmdbId, cancellationToken); + var rating = string.IsNullOrEmpty(imdbId) ? null : await omdbClient.GetRatingAsync(imdbId, cancellationToken); + return ToDto(candidate, rating?.Value); + })); + + return [.. mapped]; + } + + private Func>> TopRatedFetcher(ExploreItemType type) => type switch + { + ExploreItemType.Movie => tmdbClient.GetTopRatedMoviesAsync, + ExploreItemType.TvShow => tmdbClient.GetTopRatedTvShowsAsync, + _ => throw new ArgumentOutOfRangeException(nameof(type), $"Explore is not available for {type}.") + }; + + private Func> ImdbIdFetcher(ExploreItemType type) => type switch + { + ExploreItemType.Movie => tmdbClient.GetMovieImdbIdAsync, + ExploreItemType.TvShow => tmdbClient.GetTvShowImdbIdAsync, + _ => throw new ArgumentOutOfRangeException(nameof(type), $"Explore is not available for {type}.") + }; + + // The set of TMDB ids the owner already tracks: their linked reference ids -> those reference documents' + // own tmdb external id. Bounded by the owner's collection size; the reference docs are the small, shared + // metadata ones, not tenant rows. + private async Task> TrackedExternalIdsAsync(ExploreItemType type, string ownerId) => type switch + { + ExploreItemType.Movie => ExternalIdsOf(await movieReferenceRepository.FindByIdsAsync(await movieRepository.FindLinkedReferenceIdsAsync(ownerId)), r => r.ExternalIds), + ExploreItemType.TvShow => ExternalIdsOf(await tvShowReferenceRepository.FindByIdsAsync(await tvShowRepository.FindLinkedReferenceIdsAsync(ownerId)), r => r.ExternalIds), + _ => throw new ArgumentOutOfRangeException(nameof(type), $"Explore is not available for {type}.") + }; + + private static IEnumerable ExternalIdsOf(IEnumerable references, Func> externalIds) => + references.Select(r => externalIds(r).GetValueOrDefault(TmdbSourceKey)).Where(id => !string.IsNullOrEmpty(id)).Select(id => id!); + + private static ReferenceItemType ToReferenceItemType(ExploreItemType type) => type switch + { + ExploreItemType.Movie => ReferenceItemType.Movie, + ExploreItemType.TvShow => ReferenceItemType.TvShow, + _ => throw new ArgumentOutOfRangeException(nameof(type), $"Explore is not available for {type}.") + }; + + private static ExploreSuggestionDto ToDto(TmdbTopRatedItem item, double? rating) => new() + { + ExternalId = item.TmdbId, + Title = item.Title, + Year = item.Year, + ImageUrl = item.PosterUrl, + Synopsis = item.Synopsis, + Rating = rating, + RatingScale = rating is null ? null : RatingScale + }; +} diff --git a/src/WebApi/ReferenceData/ITmdbClient.cs b/src/WebApi/ReferenceData/ITmdbClient.cs index d1de85f8..0544128b 100644 --- a/src/WebApi/ReferenceData/ITmdbClient.cs +++ b/src/WebApi/ReferenceData/ITmdbClient.cs @@ -6,6 +6,12 @@ namespace Keeptrack.WebApi.ReferenceData; /// public record TmdbSearchResult(string TmdbId, string Title, int? Year, string? Synopsis, string? PosterUrl); +/// +/// One entry from a TMDB "top rated" list - the same fields as a search hit plus TMDB's own aggregate +/// vote average (0-10), which is what the Explore discovery feature ranks and displays by. +/// +public record TmdbTopRatedItem(string TmdbId, string Title, int? Year, string? Synopsis, string? PosterUrl, double? VoteAverage); + public record TmdbEpisode(int SeasonNumber, int EpisodeNumber, string Title, DateOnly? AirDate); public record TmdbTvShowDetails(string TmdbId, string Title, int? Year, string? Synopsis, List Episodes, List Genres, string? PosterUrl, double? VoteAverage = null, int? VoteCount = null, string? ImdbId = null); @@ -28,6 +34,16 @@ public interface ITmdbClient Task> SearchMovieAsync(string title, int? year, CancellationToken cancellationToken = default); + /// + /// One page (TMDB returns ~20 per page) of TMDB's top-rated movies, highest-rated first - the source + /// the Explore discovery feature reads. Fetching from the provider (not the local reference collection) + /// is the whole point: it surfaces acclaimed titles the user hasn't tracked yet. + /// + Task> GetTopRatedMoviesAsync(int page, CancellationToken cancellationToken = default); + + /// TV equivalent of . + Task> GetTopRatedTvShowsAsync(int page, CancellationToken cancellationToken = default); + Task GetTvShowDetailsAsync(string tmdbId, CancellationToken cancellationToken = default); Task GetMovieDetailsAsync(string tmdbId, CancellationToken cancellationToken = default); diff --git a/src/WebApi/ReferenceData/RatingSourceCatalog.cs b/src/WebApi/ReferenceData/RatingSourceCatalog.cs index dec26601..b63a3599 100644 --- a/src/WebApi/ReferenceData/RatingSourceCatalog.cs +++ b/src/WebApi/ReferenceData/RatingSourceCatalog.cs @@ -45,4 +45,15 @@ public static IReadOnlyList AvailableSources(ReferenceItemType domain) = public static string DefaultSource(ReferenceItemType domain) => AvailableSources(domain).FirstOrDefault() ?? throw new ArgumentOutOfRangeException(nameof(domain), $"No rating sources are declared for {domain}."); + + /// + /// The effective primary source for given the admin's stored overrides: the + /// stored choice when it still names an available source, otherwise the code default (an override for a + /// source since removed from the catalog is ignored). The single resolver shared by + /// ReferenceEnrichmentService.GetPrimaryRatingSourceAsync and the Explore feature. + /// + public static string Resolve(IReadOnlyDictionary overrides, ReferenceItemType domain) => + overrides.TryGetValue(domain.ToString(), out var stored) && AvailableSources(domain).Contains(stored) + ? stored + : DefaultSource(domain); } diff --git a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs index 8ab7be73..4d29ce48 100644 --- a/src/WebApi/ReferenceData/ReferenceDataAdminController.cs +++ b/src/WebApi/ReferenceData/ReferenceDataAdminController.cs @@ -273,6 +273,21 @@ public async Task SetRatingSource(ReferenceItemType domain, [From return NoContent(); } + /// The global Explore-feature settings (see ). + [HttpGet("explore-settings")] + [ProducesResponseType(200)] + public async Task> GetExploreSettings() => + Ok(new ExploreSettingsDto { UseTmdbRanking = await appSettingRepository.GetExploreUseTmdbAsync() }); + + /// Updates the global Explore-feature settings. + [HttpPut("explore-settings")] + [ProducesResponseType(204)] + public async Task SetExploreSettings([FromBody] ExploreSettingsDto request) + { + await appSettingRepository.SetExploreUseTmdbAsync(request.UseTmdbRanking); + return NoContent(); + } + /// /// Re-applies a domain's current primary rating source to every already-linked tenant item - a single /// bulk pass over the (small, shared) reference collection, no provider calls. Run after switching the diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs index f6fc82b6..af3f53fc 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.cs @@ -43,9 +43,7 @@ public partial class ReferenceEnrichmentService( public async Task GetPrimaryRatingSourceAsync(ReferenceItemType domain) { var overrides = await appSettingRepository.GetReferenceRatingSourcesAsync(); - return overrides.TryGetValue(domain.ToString(), out var stored) && RatingSourceCatalog.AvailableSources(domain).Contains(stored) - ? stored - : RatingSourceCatalog.DefaultSource(domain); + return RatingSourceCatalog.Resolve(overrides, domain); } /// diff --git a/src/WebApi/ReferenceData/TmdbClient.cs b/src/WebApi/ReferenceData/TmdbClient.cs index 8b2b2f97..f0cb8433 100644 --- a/src/WebApi/ReferenceData/TmdbClient.cs +++ b/src/WebApi/ReferenceData/TmdbClient.cs @@ -27,6 +27,22 @@ public async Task> SearchMovieAsync(string title r.Id.ToString(CultureInfo.InvariantCulture), r.Title ?? title, ParseYear(r.ReleaseDate), r.Overview, BuildImageUrl(r.PosterPath, PosterImageSize))).ToList() ?? []; } + public async Task> GetTopRatedMoviesAsync(int page, CancellationToken cancellationToken = default) + { + var response = await http.GetFromJsonAsync($"movie/top_rated?api_key={ApiKey}&page={page}", cancellationToken); + return response?.Results.Select(r => new TmdbTopRatedItem( + r.Id.ToString(CultureInfo.InvariantCulture), r.Title ?? string.Empty, ParseYear(r.ReleaseDate), r.Overview, + BuildImageUrl(r.PosterPath, PosterImageSize), r.VoteAverage)).ToList() ?? []; + } + + public async Task> GetTopRatedTvShowsAsync(int page, CancellationToken cancellationToken = default) + { + var response = await http.GetFromJsonAsync($"tv/top_rated?api_key={ApiKey}&page={page}", cancellationToken); + return response?.Results.Select(r => new TmdbTopRatedItem( + r.Id.ToString(CultureInfo.InvariantCulture), r.Name ?? string.Empty, ParseYear(r.FirstAirDate), r.Overview, + BuildImageUrl(r.PosterPath, PosterImageSize), r.VoteAverage)).ToList() ?? []; + } + public async Task GetTvShowDetailsAsync(string tmdbId, CancellationToken cancellationToken = default) { // append_to_response=external_ids folds the imdb id into this same details call - no extra request, @@ -156,6 +172,9 @@ private sealed class TmdbSearchItem [JsonPropertyName("poster_path")] public string? PosterPath { get; set; } + + [JsonPropertyName("vote_average")] + public double? VoteAverage { get; set; } } private sealed class TmdbTvShowDetailsResponse diff --git a/test/WebApi.IntegrationTests/Resources/ExploreResourceTest.cs b/test/WebApi.IntegrationTests/Resources/ExploreResourceTest.cs new file mode 100644 index 00000000..9e3a3216 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/ExploreResourceTest.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Threading.Tasks; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Explore controller coverage that doesn't depend on a live TMDB call: authentication, the domain guard +/// (Book/Album aren't reference-ranked discovery domains), and the dismiss/undo round-trip. The suggestion +/// listing itself hits the real TMDB top-rated API, so it's exercised by the Playwright smoke suite (which is +/// already provisioned with a TMDB key), not here. +/// +public class ExploreResourceTest(KestrelWebAppFactory factory) : ResourceTestBase(factory) +{ + [Fact] + public async Task Explore_RequiresAuthentication() + { + await GetAsync("/api/explore/Movie", HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Explore_RejectsADomainWhereDiscoveryDoesNotApply() + { + await Authenticate(); + // Book is not a reference-ranked Explore domain - the controller answers 400 (before any TMDB call). + await GetAsync("/api/explore/Book", HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Dismiss_AndUndo_AreIdempotentAndReturnNoContent() + { + await Authenticate(); + + // dismissing twice is idempotent (both 204); undo also 204 - none of this touches TMDB + await PostNoContentAsync("/api/explore/Movie/dismiss/999999", new { }); + await PostNoContentAsync("/api/explore/Movie/dismiss/999999", new { }); + await DeleteAsync("/api/explore/Movie/dismiss/999999"); + } +} diff --git a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs index ffa7de80..c26f6265 100644 --- a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs +++ b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs @@ -184,6 +184,9 @@ public Task SetReferenceRatingAsync(string referenceId, double? rating, do public Task> FindFinishedLinkedShowsAsync() => Task.FromResult>([]); + + public Task> FindLinkedReferenceIdsAsync(string ownerId) => + Task.FromResult>([]); } private sealed class FakeMovieRepository : InMemoryRepository, IMovieRepository @@ -196,6 +199,9 @@ public Task SetReferenceRatingAsync(string referenceId, double? rating, do public Task> FindDistinctUnresolvedTitleYearsAsync() => Task.FromResult>([]); + + public Task> FindLinkedReferenceIdsAsync(string ownerId) => + Task.FromResult>([]); } private sealed class FakeEpisodeRepository() diff --git a/test/WebApi.UnitTests/ReferenceData/ExploreServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ExploreServiceTest.cs new file mode 100644 index 00000000..74b98e58 --- /dev/null +++ b/test/WebApi.UnitTests/ReferenceData/ExploreServiceTest.cs @@ -0,0 +1,151 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.ReferenceData; +using Moq; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.ReferenceData; + +[Trait("Category", "UnitTests")] +public class ExploreServiceTest +{ + private readonly Mock _tmdbClient = new(); + private readonly Mock _omdbClient = new(); + private readonly Mock _appSettingRepository = new(); + private readonly Mock _movieRepository = new(); + private readonly Mock _tvShowRepository = new(); + private readonly Mock _movieReferenceRepository = new(); + private readonly Mock _tvShowReferenceRepository = new(); + private readonly Mock _dismissalRepository = new(); + + private ExploreService CreateService() + { + // no admin override by default => the code default source (tmdb) is used + _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()).ReturnsAsync(new Dictionary()); + return new( + _tmdbClient.Object, _omdbClient.Object, _appSettingRepository.Object, _movieRepository.Object, _tvShowRepository.Object, + _movieReferenceRepository.Object, _tvShowReferenceRepository.Object, _dismissalRepository.Object); + } + + private static TmdbTopRatedItem Item(string id, double rating) => new(id, $"Title {id}", 2000, "synopsis", "http://img", rating); + + [Fact] + public async Task GetSuggestionsAsync_ExcludesTitlesTheOwnerTracksOrHasDismissed_AndMapsTheRating() + { + // owner tracks reference "ref-a", whose TMDB id is "100" + _movieRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync(["ref-a"]); + _movieReferenceRepository.Setup(r => r.FindByIdsAsync(It.Is>(c => c.Contains("ref-a")))) + .ReturnsAsync([new MovieReferenceModel { Id = "ref-a", Title = "Tracked", TitleNormalized = "tracked", ExternalIds = new Dictionary { ["tmdb"] = "100" } }]); + _dismissalRepository.Setup(r => r.FindDismissedExternalIdsAsync("owner", ExploreItemType.Movie)).ReturnsAsync(["200"]); + // later pages are empty (the real client returns [] past the last page); only page 1 has results here + _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); + _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(1, It.IsAny())) + .ReturnsAsync([Item("100", 9.0), Item("200", 8.8), Item("300", 8.6)]); + + var suggestions = await CreateService().GetSuggestionsAsync(ExploreItemType.Movie, "owner", 24); + + // 100 is tracked, 200 is dismissed - only 300 survives + suggestions.Should().ContainSingle(); + suggestions[0].ExternalId.Should().Be("300"); + suggestions[0].Rating.Should().Be(8.6); + suggestions[0].RatingScale.Should().Be(10); + } + + [Fact] + public async Task GetSuggestionsAsync_PagesThroughTheProviderUntilTheLimitIsFilled() + { + _movieRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync([]); + _movieReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())).ReturnsAsync([]); + _dismissalRepository.Setup(r => r.FindDismissedExternalIdsAsync("owner", ExploreItemType.Movie)).ReturnsAsync([]); + _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(1, It.IsAny())).ReturnsAsync([Item("1", 9.0), Item("2", 8.9)]); + _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(2, It.IsAny())).ReturnsAsync([Item("3", 8.8), Item("4", 8.7)]); + + var suggestions = await CreateService().GetSuggestionsAsync(ExploreItemType.Movie, "owner", 3); + + suggestions.Select(s => s.ExternalId).Should().Equal(["1", "2", "3"], "it stops once the limit is filled, having pulled a second page"); + _tmdbClient.Verify(c => c.GetTopRatedMoviesAsync(3, It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetSuggestionsAsync_UsesTheTvShowProviderAndRepositories_ForTheTvShowDomain() + { + _tvShowRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync([]); + _tvShowReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())).ReturnsAsync([]); + _dismissalRepository.Setup(r => r.FindDismissedExternalIdsAsync("owner", ExploreItemType.TvShow)).ReturnsAsync([]); + _tmdbClient.Setup(c => c.GetTopRatedTvShowsAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); + _tmdbClient.Setup(c => c.GetTopRatedTvShowsAsync(1, It.IsAny())).ReturnsAsync([Item("tv1", 9.4)]); + + var suggestions = await CreateService().GetSuggestionsAsync(ExploreItemType.TvShow, "owner", 24); + + suggestions.Should().ContainSingle().Which.ExternalId.Should().Be("tv1"); + _tmdbClient.Verify(c => c.GetTopRatedMoviesAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetSuggestionsAsync_WhenImdbIsThePrimarySource_ShowsImdbRatingsButKeepsTmdbOrder() + { + var service = CreateService(); + // admin picked IMDb as the movie primary source + _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()).ReturnsAsync(new Dictionary { ["Movie"] = "imdb" }); + _movieRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync([]); + _movieReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())).ReturnsAsync([]); + _dismissalRepository.Setup(r => r.FindDismissedExternalIdsAsync("owner", ExploreItemType.Movie)).ReturnsAsync([]); + _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); + // TMDB top-rated order: "20" before "10" + _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(1, It.IsAny())).ReturnsAsync([Item("20", 9.0), Item("10", 5.0)]); + _tmdbClient.Setup(c => c.GetMovieImdbIdAsync("20", It.IsAny())).ReturnsAsync("tt20"); + _tmdbClient.Setup(c => c.GetMovieImdbIdAsync("10", It.IsAny())).ReturnsAsync("tt10"); + _omdbClient.Setup(c => c.GetRatingAsync("tt20", It.IsAny())).ReturnsAsync(new OmdbRating(7.0, 50)); + _omdbClient.Setup(c => c.GetRatingAsync("tt10", It.IsAny())).ReturnsAsync(new OmdbRating(8.5, 100)); + + var suggestions = await service.GetSuggestionsAsync(ExploreItemType.Movie, "owner", 24); + + // the shown value is IMDb's, but the ORDER stays TMDB's (no re-rank from partial OMDb data) + suggestions.Select(s => s.ExternalId).Should().Equal(["20", "10"]); + suggestions[0].Rating.Should().Be(7.0); + suggestions[1].Rating.Should().Be(8.5); + } + + [Fact] + public async Task GetSuggestionsAsync_WhenExploreIsForcedToTmdb_IgnoresTheImdbPrimarySource_AndMakesNoOmdbCalls() + { + var service = CreateService(); + _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()).ReturnsAsync(new Dictionary { ["Movie"] = "imdb" }); + _appSettingRepository.Setup(r => r.GetExploreUseTmdbAsync()).ReturnsAsync(true); + _movieRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync([]); + _movieReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())).ReturnsAsync([]); + _dismissalRepository.Setup(r => r.FindDismissedExternalIdsAsync("owner", ExploreItemType.Movie)).ReturnsAsync([]); + _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); + _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(1, It.IsAny())).ReturnsAsync([Item("20", 9.0), Item("10", 5.0)]); + + var suggestions = await service.GetSuggestionsAsync(ExploreItemType.Movie, "owner", 24); + + // TMDB order and TMDB ratings, despite IMDb being the primary source + suggestions.Select(s => s.ExternalId).Should().Equal(["20", "10"]); + suggestions[0].Rating.Should().Be(9.0); + _tmdbClient.Verify(c => c.GetMovieImdbIdAsync(It.IsAny(), It.IsAny()), Times.Never); + _omdbClient.Verify(c => c.GetRatingAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DismissAsync_RecordsADismissalKeyedByExternalId() + { + await CreateService().DismissAsync(ExploreItemType.TvShow, "owner", "tv9"); + + _dismissalRepository.Verify(r => r.AddAsync(It.Is(m => + m.OwnerId == "owner" && m.ReferenceType == ExploreItemType.TvShow && m.ExternalId == "tv9")), Times.Once); + } + + [Fact] + public async Task UndismissAsync_RemovesTheDismissal() + { + await CreateService().UndismissAsync(ExploreItemType.Movie, "owner", "42"); + + _dismissalRepository.Verify(r => r.RemoveAsync("owner", ExploreItemType.Movie, "42"), Times.Once); + } +} diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs index 5b2ac645..8ca5c077 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceEnrichmentServiceTest.cs @@ -1646,6 +1646,12 @@ private sealed class FakeTmdbClient : ITmdbClient public static FakeTmdbClient WithTvShowSearchResults(params TmdbSearchResult[] results) => new([.. results]); + public Task> GetTopRatedMoviesAsync(int page, CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task> GetTopRatedTvShowsAsync(int page, CancellationToken cancellationToken = default) => + Task.FromResult>([]); + public Task> SearchTvShowAsync(string title, int? year, CancellationToken cancellationToken = default) => Task.FromResult>(_tvShowSearchResults); diff --git a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs index 22300097..41b09d3e 100644 --- a/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ReferenceSyncServiceTest.cs @@ -159,6 +159,12 @@ private sealed class FakeTmdbClient : ITmdbClient public static FakeTmdbClient Empty() => new(); + public Task> GetTopRatedMoviesAsync(int page, CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task> GetTopRatedTvShowsAsync(int page, CancellationToken cancellationToken = default) => + Task.FromResult>([]); + public Task> SearchTvShowAsync(string title, int? year, CancellationToken cancellationToken = default) => Task.FromResult>([]); From a5a4d6333c78d7ccb08aad96d67a789be8ac2d80 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 31 Jul 2026 16:30:16 +0200 Subject: [PATCH 39/80] Add explore for video games and fix dismissal design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Video games are now a third Explore tab, and I reworked the parts of the existing implementation that were wrong rather than copying them. Discovery source — RAWG /games?ordering=-{rating|metacritic}, ranked by whatever the admin picked for VideoGame in the reference-data admin page. No exception needed like movies/TV: RAWG sorts natively on both of its sources and returns both values on every listing entry, so ranking costs zero extra calls (IRawgClient.GetTopRatedGamesAsync). One caveat I could not verify live (no RAWG key in Local.runsettings): RAWG's rating is a plain average with no vote-count filter or sort, so ordering the whole catalogue by -rating would rank an unknown game with a single 5-star vote above every classic. I constrain the pool server-side with metacritic=70,100 — "was reviewed by the professional press at all" is the closest equivalent to the minimum vote count TMDB's own top-rated list already applies. RawgClient.MinMetacritic is the knob if the list still reads as obscure. I deliberately did not filter low-vote entries client-side: the paging loop stops on an empty provider page, so a filter that can empty a page would silently truncate results. explore_dismissal, reshaped as agreed — {owner_id, item_type, external_source, external_id}, unique. reference_id genuinely can't work (a suggestion is by definition untracked, so there's no reference document until it's added); external_source is the discovery provider, not the rating source, since an IMDb-ranked movie is still a TMDB id. Index updated in scripts/mongodb-create-index.js — re-run it; ensureIndex drops and recreates on the changed spec. Two real bugs found while reading the existing code: - GetExploreUseTmdbAsync was applied unconditionally, so the admin's "force TMDB" toggle would have set video games' ranking source to tmdb — not a RAWG source at all. It's now only read when IMDb actually won the resolve, which also saves a settings read on the default path. Pinned by a test. - The exclusion set only matched by reference id, so anything the owner added manually or imported that never got linked (auto-resolve gives up when a title search returns several candidates — common) was suggested back forever. Added a normalized-title fallback as the second half. Not duplicated: FindLinkedReferenceIdsAsync was already copy-pasted identically in MovieRepository/TvShowRepository; rather than adding a third copy it now lives once in ExploreExclusionQueries, with IExploreSourceRepository declaring both projections for the three domains. ExploreService's paging/exclusion loop is written once over a shared ExploreCandidate instead of per provider. Access: video games are member-only, so ExploreController gates that domain per request (403) while movies/TV stay free-tier; the tab is behind and ?tab=VideoGames falls back to Movies for a free account. Add uses Explore's own endpoint with the exact RAWG id → ResolveVideoGameAsync, so the created game is reliably linked. Verified: dotnet build clean, 338/338 unit tests pass (7 new/reworked Explore tests). Not run: the integration suite (needs Mongo + Firebase creds) and Playwright — ExploreResourceTest gained a case proving a TMDB id and a RAWG id of the same number don't collide, which needs your side to execute. There's no Explore Playwright smoke test, for video games or the existing tabs. Zero warnings now, 338/338 still green. Three fixes: 1. xUnit1051 × 10 (ExploreServiceTest) — GetSuggestionsAsync calls now pass TestContext.Current.CancellationToken, matching the convention the rest of the suite already follows (MongoDbHealthCheckTest, OpenLibraryClientTest). 2. CS9107 × 2 (EpisodeRepository, TvShowRepository) — both passed mapper to the base constructor and used the primary-constructor parameter in their own body, so each type stored the mapper twice. MongoDbRepositoryBase.Mapper is now protected instead of private and those two call sites use it, so there's one stored instance. Pre-existing warnings, unrelated to the Explore work. 3. CS1574 — the I wrote in ExploreController's doc comment didn't resolve (attribute usage, not a type in scope); already changed to [Authorize] earlier in the session. One note on the dotnet clean I ran to force a full rebuild: it wiped obj/ and the next build hit NETSDK1064 for Microsoft.AspNetCore.OpenApi on the two test projects. dotnet restore fixed it — no lasting damage, but worth knowing that a bare clean needs a restore after it in this repo. --- CLAUDE.md | 43 ++++ scripts/mongodb-create-index.js | 12 +- .../Components/Explore/ExploreApiClient.cs | 9 +- .../Components/Explore/ExplorePage.razor | 44 +++- src/Domain/Models/ExploreDismissalModel.cs | 23 +- .../IExploreDismissalRepository.cs | 16 +- .../Repositories/IExploreSourceRepository.cs | 26 +++ src/Domain/Repositories/IMovieRepository.cs | 8 +- src/Domain/Repositories/ITvShowRepository.cs | 8 +- .../Repositories/IVideoGameRepository.cs | 2 +- .../Entities/ExploreDismissal.cs | 9 +- .../Repositories/EpisodeRepository.cs | 2 +- .../ExploreDismissalRepository.cs | 26 ++- .../Repositories/ExploreExclusionQueries.cs | 52 +++++ .../Repositories/MongoDbRepositoryBase.cs | 7 +- .../Repositories/MovieRepository.cs | 15 +- .../Repositories/TvShowRepository.cs | 17 +- .../Repositories/VideoGameRepository.cs | 6 + src/WebApi/Controllers/ExploreController.cs | 63 ++++-- src/WebApi/ReferenceData/ExploreService.cs | 205 +++++++++++++----- src/WebApi/ReferenceData/IRawgClient.cs | 17 ++ src/WebApi/ReferenceData/RawgClient.cs | 32 +++ .../Resources/ExploreResourceTest.cs | 23 +- .../TvTimeImportServiceIdempotencyTest.cs | 6 + .../ReferenceData/ExploreServiceTest.cs | 159 +++++++++++--- .../ReferenceData/FakeRawgClient.cs | 12 + 26 files changed, 671 insertions(+), 171 deletions(-) create mode 100644 src/Domain/Repositories/IExploreSourceRepository.cs create mode 100644 src/Infrastructure.MongoDb/Repositories/ExploreExclusionQueries.cs diff --git a/CLAUDE.md b/CLAUDE.md index 1ffcc14a..42f64605 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -597,6 +597,49 @@ This is the same air-date filter `WatchNextService` already applies for its "nex An episode TMDB lists with a future air date (a confirmed-but-unaired next season, e.g. a renewal announced months ahead) hasn't happened yet from the viewer's perspective - it shouldn't appear as a checkbox to mark watched. An entirely future season simply doesn't appear in the season picker at all once every one of its episodes is filtered out. +### Explore (discovery): suggest acclaimed titles the user doesn't have + +`ExploreController`/`ExploreService` (`WebApi/ReferenceData/`, `/explore` in the Blazor app) suggests top-rated titles the caller doesn't already track, with a one-click add and a dismiss/undo. +Covered domains are Movie, TvShow and VideoGame; Book/Album are rejected with a 400 (an aggregate rank doesn't drive discovery there, and neither provider offers a best-of listing to read). + +**The discovery list comes from the provider, never from the local `*_reference` collections.** +This was the original implementation's core mistake, since fixed: a reference document only exists because *someone already tracks* that title, so querying locally can only ever re-suggest things the user (or another tenant) has - the opposite of discovery. +Each domain reads its own reference provider's best-of listing: TMDB `/{movie,tv}/top_rated` for movies/TV, RAWG `/games?ordering=-{rating|metacritic}` for video games. + +**Ordering follows the admin-selected primary rating source** (`RatingSourceCatalog.Resolve`, the same setting and the same resolver the rest of the app ranks by - no Explore-specific setting). +Video games need no exception: RAWG sorts natively on both of its own sources (`rawg`'s 0-5 score and `metacritic`'s 0-100), and both values are already on every listing entry, so the selected one is picked with zero extra calls. +Movies/TV under **IMDb** are the one awkward case, and only because IMDb has no catalogue/top-rated API at all (OMDb only turns a known id into a rating): the *list* still comes from TMDB's ranking and OMDb only fills in the displayed number per title. +That page is deliberately **not** re-sorted by the IMDb value - partial OMDb data (rate-limited, or no key configured) would float low/unrated titles to the top. +The admin toggle `app_setting.explore_use_tmdb` (`IAppSettingRepository.Get/SetExploreUseTmdbAsync`) forces movies/TV back onto TMDB's own vote so discovery skips those per-title lookups entirely; it's read *only* when IMDb actually won the resolve, so it can never leak into the video game domain (whose sources don't include IMDb) - `GetSuggestionsAsync_ForVideoGames_IsUnaffectedByTheForceTmdbExploreFlag` pins that. + +**Gotcha: RAWG has no curated top-rated endpoint the way TMDB does, and its `rating` is a plain average with no vote-count filter or sort option.** +Ordering the whole ~900k-game catalogue by `-rating` would therefore rank an unknown game carrying a single 5-star vote above every classic. +`RawgClient.GetTopRatedGamesAsync` constrains the pool server-side with `metacritic={MinMetacritic},100` - requiring that the game was reviewed by the professional press at all is the closest available equivalent of the minimum vote count TMDB's own top-rated list already applies, and it costs no extra call. +`MinMetacritic` is the knob to raise if the list still reads as obscure. +Don't "fix" this by filtering low-vote entries client-side instead: the paging loop stops on an empty provider page, so a filter that can empty a whole page would silently truncate the results. + +**The "already have it" exclusion has two halves, and both are needed.** +The primary one is by provider id: the owner's linked reference ids (`IExploreSourceRepository.FindLinkedReferenceIdsAsync`) resolved to those reference documents' own `ExternalIds[provider]`. +The fallback is by normalized title (`FindDistinctTitlesAsync` + `TitleNormalizer`), because automatic resolution deliberately gives up when a title search returns several candidates - so a manually-added or imported item can easily have *no* reference link at all and would otherwise be suggested back forever. +Two genuinely different works sharing one title collapse under the fallback, which is an accepted trade (hiding one discovery card beats re-suggesting something the owner owns). +`IExploreSourceRepository` (`Domain/Repositories/`) declares both projections once; `ExploreExclusionQueries` (`Infrastructure.MongoDb/Repositories/`) implements them once for every domain - each repository contributes only the field expression, the same shape as the `SortTitleField` hook. + +**`explore_dismissal` is keyed on the *provider's* title, not on a reference document.** +A suggestion is by definition something nobody tracks yet, so there is usually no `reference_id` to point at until it's actually added - `{owner_id, item_type, external_source, external_id}` (unique) is the natural key. +`external_source` is the **discovery** provider (`tmdb` / `rawg`), deliberately not the rating source: an IMDb-ranked movie suggestion is still identified by a TMDB id. +It's stored rather than inferred from `item_type` because a RAWG id and a TMDB id are both plain integers, so nothing but an explicit provider keeps them from being read in the wrong number space if a domain's provider ever changes. +`ExploreService.DiscoverySource` is the single place a domain's provider is named. + +**Adding goes through Explore's own endpoint, not the ordinary `POST /api/{collection}` create.** +`POST /api/explore/{type}/add/{externalId}` creates the item and then calls `Resolve{Movie,TvShow,VideoGame}Async` with the *exact* provider id the suggestion came from. +The ordinary create's background auto-resolve is a title *search* that only links when there's exactly one candidate, so acclaimed titles with several candidates (common for movies) would be created unlinked - reliably linking is the whole reason this path exists. +It's awaited, so the card only disappears once the item is genuinely linked. +The free-tier quota is enforced here exactly as `DataCrudControllerBase.Post` does it (`FreeTierQuota.CheckAsync`). +The controller carries plain `[Authorize]`, not `MemberOnly`, because movies/TV are the free preview tier; video games are member-gated per request instead (`RequireAccessTo`, a 403), and the Blazor page hides the tab behind `` and falls back to the Movies tab if a free account lands on `?tab=VideoGames` - hiding is UX, the API is the enforcement. + +`ExplorePage.razor` keeps the active tab in `?tab=` (back/forward and refresh preserve it), caches one loaded list per tab, and tops a tab back up whenever it drops below the page size after an add/dismiss - appending below the current cards, never reshuffling what's on screen. +Add and dismiss share one `ActAsync` (busy-guard, remove, top-up) so the two handlers never duplicate that logic. + ### Keeping reference data fresh: periodic + on-demand TMDB sync TMDB's own data (episode air dates as seasons progress, genres, posters, cast) drifts out of date after the initial resolution - a show resolved months ago needs re-checking, not just a one-time fetch. diff --git a/scripts/mongodb-create-index.js b/scripts/mongodb-create-index.js index 86df0eaa..2666f32c 100644 --- a/scripts/mongodb-create-index.js +++ b/scripts/mongodb-create-index.js @@ -279,8 +279,10 @@ ensureIndex( // UserPreferencesRepository is only "supposed to" prevent a second document. ensureIndex(db.user_preference, { owner_id: 1 }, { name: "user_preference_owner", unique: true }); -// explore_dismissal: one document per (owner, type, provider external id) a user hid from their Explore list -// (Explore suggests provider titles - TMDB top-rated - not local references). Read by (owner_id, -// reference_type) to build the exclusion set; unique on the full natural key so a double-dismiss (the -// application also upserts idempotently) can never create a duplicate. -ensureIndex(db.explore_dismissal, { owner_id: 1, reference_type: 1, external_id: 1 }, { name: "explore_dismissal_key", unique: true }); +// explore_dismissal: one document per (owner, item type, provider, provider external id) a user hid from +// their Explore list. Explore suggests *provider* titles (TMDB top-rated for movies/TV, RAWG for video +// games), not local reference documents - a suggestion is by definition something nobody tracks yet - so the +// key carries the provider's own id plus the provider it belongs to, never a reference_id. Read by +// (owner_id, item_type, external_source) to build the exclusion set; unique on the full natural key so a +// double-dismiss (the application also upserts idempotently) can never create a duplicate. +ensureIndex(db.explore_dismissal, { owner_id: 1, item_type: 1, external_source: 1, external_id: 1 }, { name: "explore_dismissal_key", unique: true }); diff --git a/src/BlazorApp/Components/Explore/ExploreApiClient.cs b/src/BlazorApp/Components/Explore/ExploreApiClient.cs index b25e39b5..7cf86274 100644 --- a/src/BlazorApp/Components/Explore/ExploreApiClient.cs +++ b/src/BlazorApp/Components/Explore/ExploreApiClient.cs @@ -4,10 +4,8 @@ namespace Keeptrack.BlazorApp.Components.Explore; /// -/// Talks to the Explore endpoints (api/explore/{type}) for listing and dismissing suggestions. -/// is a member name ("Movie"/"TvShow"). Adding a -/// suggestion goes through the ordinary collection create endpoints, reusing their create + auto-resolve + -/// quota rather than a bespoke Explore add path. +/// Talks to the Explore endpoints (api/explore/{type}) for listing, adding and dismissing +/// suggestions. type is a member name ("Movie"/"TvShow"/"VideoGame"). /// public sealed class ExploreApiClient(HttpClient http) { @@ -19,7 +17,8 @@ public async Task> GetAsync(string type, int count) /// /// Adds a suggestion to the caller's collection: the server creates the item and links it to the reference - /// resolved from the exact TMDB id. Throws on a non-success status (e.g. 403 over the free-preview quota). + /// resolved from the exact provider id. Throws on a non-success status (e.g. 403 over the free-preview + /// quota, or on a member-only domain). /// public async Task AddAsync(string type, string externalId, string? title, int? year) { diff --git a/src/BlazorApp/Components/Explore/ExplorePage.razor b/src/BlazorApp/Components/Explore/ExplorePage.razor index b5e61984..1a138e45 100644 --- a/src/BlazorApp/Components/Explore/ExplorePage.razor +++ b/src/BlazorApp/Components/Explore/ExplorePage.razor @@ -14,13 +14,18 @@

- Top-rated @(_tab == Tab.Movies ? "movies" : "TV shows") from TMDB you don't track yet. Add one to your + Top-rated @TabLabel.ToLowerInvariant() from @ProviderName you don't track yet. Add one to your collection in a tap, or dismiss it to stop it coming back.

+ @* Video games are a member-only collection - a free preview account can't add one, so it isn't offered + one either. Hiding is UX, not security: the API applies the same gate to every Explore request. *@ + + +
@if (_error is not null) @@ -125,7 +130,7 @@ else if (Current is { } items) } @code { - private enum Tab { Movies, TvShows } + private enum Tab { Movies, TvShows, VideoGames } [Inject] private ExploreApiClient Api { get; set; } = null!; @@ -133,6 +138,10 @@ else if (Current is { } items) [Inject] private ListViewPreference ViewPreference { get; set; } = null!; + [Inject] private IAuthorizationService Authorization { get; set; } = null!; + + [CascadingParameter] private Task? AuthenticationState { get; set; } + // Persisted in the URL (?tab=) like the Wishlist page, so back/forward and a refresh keep the active tab. [SupplyParameterFromQuery(Name = "tab")] public string? TabQuery { get; set; } @@ -143,6 +152,7 @@ else if (Current is { } items) private Tab _tab; private string _view = ""; private bool _loading; + private bool _isMember; private string? _error; // one loaded list per tab, so switching back and forth doesn't refetch; the external ids currently being @@ -152,13 +162,39 @@ else if (Current is { } items) private List? Current => _cache.TryGetValue(_tab, out var list) ? list : null; - private string TypeName => _tab == Tab.Movies ? nameof(ReferenceItemType.Movie) : nameof(ReferenceItemType.TvShow); + private string TypeName => _tab switch + { + Tab.TvShows => nameof(ReferenceItemType.TvShow), + Tab.VideoGames => nameof(ReferenceItemType.VideoGame), + _ => nameof(ReferenceItemType.Movie) + }; + + private string TabLabel => _tab switch + { + Tab.TvShows => "TV shows", + Tab.VideoGames => "video games", + _ => "movies" + }; + + // Each domain discovers through its own reference provider, so the blurb names the one actually queried. + private string ProviderName => _tab == Tab.VideoGames ? "RAWG" : "TMDB"; - protected override void OnInitialized() => _view = ViewPreference.View; + protected override async Task OnInitializedAsync() + { + _view = ViewPreference.View; + if (AuthenticationState is not null) + { + var authState = await AuthenticationState; + _isMember = (await Authorization.AuthorizeAsync(authState.User, "MemberOnly")).Succeeded; + } + } protected override async Task OnParametersSetAsync() { _tab = Enum.TryParse(TabQuery, out var tab) ? tab : Tab.Movies; + // a free preview account can reach ?tab=VideoGames by hand or from a stale link; land it on the + // default tab rather than letting the API's own 403 surface as an error banner. + if (_tab == Tab.VideoGames && !_isMember) _tab = Tab.Movies; if (!_cache.ContainsKey(_tab)) await LoadAsync(); } diff --git a/src/Domain/Models/ExploreDismissalModel.cs b/src/Domain/Models/ExploreDismissalModel.cs index cec1345f..915e3ad9 100644 --- a/src/Domain/Models/ExploreDismissalModel.cs +++ b/src/Domain/Models/ExploreDismissalModel.cs @@ -1,9 +1,12 @@ namespace Keeptrack.Domain.Models; /// -/// One "don't suggest this to me again" record: an owner has dismissed a specific provider title (by its -/// external id - a TMDB id today) from their Explore list for a given domain. Per-user (owner-scoped). The -/// natural key is (owner, type, external id) - dismissing the same suggestion twice is idempotent. +/// One "don't suggest this to me again" record: an owner has dismissed a specific provider title from their +/// Explore list for a given domain. Per-user (owner-scoped). The natural key is +/// (owner, item type, external source, external id) - dismissing the same suggestion twice is idempotent. +/// A dismissal deliberately points at the *provider's* title, not at a local reference document: an Explore +/// suggestion is by definition something nobody tracks yet, so there is usually no reference document to +/// point at until the title is actually added. /// public class ExploreDismissalModel { @@ -11,8 +14,18 @@ public class ExploreDismissalModel public required string OwnerId { get; set; } - public required ExploreItemType ReferenceType { get; set; } + /// Which domain the dismissed title belongs to. + public required ExploreItemType ItemType { get; set; } - /// The provider's external id (TMDB id) of the dismissed title. + /// + /// Which provider belongs to ("tmdb" for movies/TV, "rawg" for video games) - + /// the *discovery* provider the suggestion came from, not the admin-selected rating source (an IMDb-ranked + /// movie suggestion is still a TMDB id). Stored explicitly rather than inferred from + /// , so an id can never be read against the wrong provider's number space if a + /// domain's discovery provider ever changes. + /// + public required string ExternalSource { get; set; } + + /// The provider's own id of the dismissed title, in 's number space. public required string ExternalId { get; set; } } diff --git a/src/Domain/Repositories/IExploreDismissalRepository.cs b/src/Domain/Repositories/IExploreDismissalRepository.cs index ca8ccd56..b3079527 100644 --- a/src/Domain/Repositories/IExploreDismissalRepository.cs +++ b/src/Domain/Repositories/IExploreDismissalRepository.cs @@ -6,17 +6,21 @@ namespace Keeptrack.Domain.Repositories; /// /// Persistence for - purpose-built rather than -/// (owner-scoped, looked up only by (owner, type), never paged/searched), -/// same reasoning as . +/// (owner-scoped, looked up only by (owner, item type, source), never +/// paged/searched), same reasoning as . /// public interface IExploreDismissalRepository { - /// The provider external ids this owner has dismissed for - part of the Explore exclusion set. - Task> FindDismissedExternalIdsAsync(string ownerId, ExploreItemType type); + /// + /// The ids this owner has dismissed for - part of + /// the Explore exclusion set. Scoped to the source so ids are always read back in the number space they + /// were written in. + /// + Task> FindDismissedExternalIdsAsync(string ownerId, ExploreItemType type, string externalSource); - /// Records a dismissal. Idempotent: dismissing the same (owner, type, external id) twice is a no-op. + /// Records a dismissal. Idempotent: dismissing the same (owner, item type, source, external id) twice is a no-op. Task AddAsync(ExploreDismissalModel model); /// Undoes a dismissal so the title can be suggested again. Owner-scoped. - Task RemoveAsync(string ownerId, ExploreItemType type, string externalId); + Task RemoveAsync(string ownerId, ExploreItemType type, string externalSource, string externalId); } diff --git a/src/Domain/Repositories/IExploreSourceRepository.cs b/src/Domain/Repositories/IExploreSourceRepository.cs new file mode 100644 index 00000000..a28fc147 --- /dev/null +++ b/src/Domain/Repositories/IExploreSourceRepository.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Keeptrack.Domain.Repositories; + +/// +/// The owner-scoped projections the Explore feature needs from a trackable collection to answer "does this +/// caller already have this title?" - implemented by every domain Explore can suggest for (movies, TV shows, +/// video games today). Declared once here so ExploreService can pick one repository per domain rather +/// than branching per method, and so the three interfaces don't each re-declare the same pair. +/// +public interface IExploreSourceRepository +{ + /// + /// Distinct non-empty reference ids this owner already tracks - the primary "already added" exclusion + /// (a reference the owner already has an item linked to is never suggested). + /// + Task> FindLinkedReferenceIdsAsync(string ownerId); + + /// + /// Distinct raw titles this owner tracks - the fallback exclusion, since an item added manually or + /// imported may never have been linked to a reference document and so is invisible to + /// . Callers normalize before comparing. + /// + Task> FindDistinctTitlesAsync(string ownerId); +} diff --git a/src/Domain/Repositories/IMovieRepository.cs b/src/Domain/Repositories/IMovieRepository.cs index 48322a1a..5ad7982a 100644 --- a/src/Domain/Repositories/IMovieRepository.cs +++ b/src/Domain/Repositories/IMovieRepository.cs @@ -4,7 +4,7 @@ namespace Keeptrack.Domain.Repositories; -public interface IMovieRepository : IDataRepository +public interface IMovieRepository : IDataRepository, IExploreSourceRepository { /// /// Sets , , @@ -27,10 +27,4 @@ public interface IMovieRepository : IDataRepository /// yet - feeds the admin curation queue. /// Task> FindDistinctUnresolvedTitleYearsAsync(); - - /// - /// Distinct non-empty s this owner already tracks - the "already added" - /// exclusion set for Explore suggestions (a reference the owner already has a movie linked to is never suggested). - /// - Task> FindLinkedReferenceIdsAsync(string ownerId); } diff --git a/src/Domain/Repositories/ITvShowRepository.cs b/src/Domain/Repositories/ITvShowRepository.cs index 84f33ca6..68b1b5dc 100644 --- a/src/Domain/Repositories/ITvShowRepository.cs +++ b/src/Domain/Repositories/ITvShowRepository.cs @@ -4,7 +4,7 @@ namespace Keeptrack.Domain.Repositories; -public interface ITvShowRepository : IDataRepository +public interface ITvShowRepository : IDataRepository, IExploreSourceRepository { /// /// Sets and (to the reference's @@ -28,12 +28,6 @@ public interface ITvShowRepository : IDataRepository /// Task> FindDistinctUnresolvedTitleYearsAsync(); - /// - /// Distinct non-empty s this owner already tracks - the "already added" - /// exclusion set for Explore suggestions (a reference the owner already has a show linked to is never suggested). - /// - Task> FindLinkedReferenceIdsAsync(string ownerId); - /// /// Every tenant's shows marked that carry a reference link - the /// candidates the periodic finished-show status reconciliation re-checks against their (freshly synced) diff --git a/src/Domain/Repositories/IVideoGameRepository.cs b/src/Domain/Repositories/IVideoGameRepository.cs index 3be8e040..9cbd86cc 100644 --- a/src/Domain/Repositories/IVideoGameRepository.cs +++ b/src/Domain/Repositories/IVideoGameRepository.cs @@ -4,7 +4,7 @@ namespace Keeptrack.Domain.Repositories; -public interface IVideoGameRepository : IDataRepository +public interface IVideoGameRepository : IDataRepository, IExploreSourceRepository { /// /// Sets , and diff --git a/src/Infrastructure.MongoDb/Entities/ExploreDismissal.cs b/src/Infrastructure.MongoDb/Entities/ExploreDismissal.cs index 972e9e03..92013c2b 100644 --- a/src/Infrastructure.MongoDb/Entities/ExploreDismissal.cs +++ b/src/Infrastructure.MongoDb/Entities/ExploreDismissal.cs @@ -6,7 +6,7 @@ namespace Keeptrack.Infrastructure.MongoDb.Entities; /// /// One owner's "don't suggest this again" record for the Explore feature (explore_dismissal). The -/// natural key is (owner_id, reference_type, external_id); see . +/// natural key is (owner_id, item_type, external_source, external_id); see . /// public class ExploreDismissal { @@ -18,8 +18,11 @@ public class ExploreDismissal public required string OwnerId { get; set; } // stored as its enum member name via the registered EnumRepresentationConvention(BsonType.String). - [BsonElement("reference_type")] - public required ExploreItemType ReferenceType { get; set; } + [BsonElement("item_type")] + public required ExploreItemType ItemType { get; set; } + + [BsonElement("external_source")] + public required string ExternalSource { get; set; } [BsonElement("external_id")] public required string ExternalId { get; set; } diff --git a/src/Infrastructure.MongoDb/Repositories/EpisodeRepository.cs b/src/Infrastructure.MongoDb/Repositories/EpisodeRepository.cs index cbee11b2..f904ade2 100644 --- a/src/Infrastructure.MongoDb/Repositories/EpisodeRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/EpisodeRepository.cs @@ -29,6 +29,6 @@ public async Task> FindByShowIdsAsync(string ownerId, IReadOn // owner_id + tv_show_id In(...) matches the leading fields of the episode_last_watched index (owner_id, tv_show_id, watched_at). var filter = builder.Eq(f => f.OwnerId, ownerId) & builder.In(f => f.TvShowId, tvShowIds); var entities = await GetCollection().Find(filter).ToListAsync(); - return mapper.ToModels(entities); + return Mapper.ToModels(entities); } } diff --git a/src/Infrastructure.MongoDb/Repositories/ExploreDismissalRepository.cs b/src/Infrastructure.MongoDb/Repositories/ExploreDismissalRepository.cs index 75a6062e..adc0a6bb 100644 --- a/src/Infrastructure.MongoDb/Repositories/ExploreDismissalRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/ExploreDismissalRepository.cs @@ -17,27 +17,31 @@ public class ExploreDismissalRepository(IMongoDatabase mongoDatabase) : IExplore private IMongoCollection Collection => mongoDatabase.GetCollection(CollectionName); - public async Task> FindDismissedExternalIdsAsync(string ownerId, ExploreItemType type) - { - var filter = Builders.Filter.Eq(d => d.OwnerId, ownerId) - & Builders.Filter.Eq(d => d.ReferenceType, type); - return await Collection.Distinct(d => d.ExternalId, filter).ToListAsync(); - } + public async Task> FindDismissedExternalIdsAsync(string ownerId, ExploreItemType type, string externalSource) => + await Collection.Distinct(d => d.ExternalId, KeyFilter(ownerId, type, externalSource)).ToListAsync(); public async Task AddAsync(ExploreDismissalModel model) { - var filter = Builders.Filter.Eq(d => d.OwnerId, model.OwnerId) - & Builders.Filter.Eq(d => d.ReferenceType, model.ReferenceType) + var filter = KeyFilter(model.OwnerId, model.ItemType, model.ExternalSource) & Builders.Filter.Eq(d => d.ExternalId, model.ExternalId); // SetOnInsert-only upsert: inserts the record when missing, a no-op when it already exists - so // dismissing the same suggestion twice never creates a duplicate row. var update = Builders.Update .SetOnInsert(d => d.OwnerId, model.OwnerId) - .SetOnInsert(d => d.ReferenceType, model.ReferenceType) + .SetOnInsert(d => d.ItemType, model.ItemType) + .SetOnInsert(d => d.ExternalSource, model.ExternalSource) .SetOnInsert(d => d.ExternalId, model.ExternalId); await Collection.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true }); } - public async Task RemoveAsync(string ownerId, ExploreItemType type, string externalId) => - await Collection.DeleteOneAsync(d => d.OwnerId == ownerId && d.ReferenceType == type && d.ExternalId == externalId); + public async Task RemoveAsync(string ownerId, ExploreItemType type, string externalSource, string externalId) => + await Collection.DeleteOneAsync( + KeyFilter(ownerId, type, externalSource) & Builders.Filter.Eq(d => d.ExternalId, externalId)); + + // the (owner, domain, provider) prefix of the natural key - shared by every read and write so the three + // never drift on what a dismissal is scoped to. + private static FilterDefinition KeyFilter(string ownerId, ExploreItemType type, string externalSource) => + Builders.Filter.Eq(d => d.OwnerId, ownerId) + & Builders.Filter.Eq(d => d.ItemType, type) + & Builders.Filter.Eq(d => d.ExternalSource, externalSource); } diff --git a/src/Infrastructure.MongoDb/Repositories/ExploreExclusionQueries.cs b/src/Infrastructure.MongoDb/Repositories/ExploreExclusionQueries.cs new file mode 100644 index 00000000..b1887833 --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/ExploreExclusionQueries.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Keeptrack.Common.System; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +/// +/// The two owner-scoped projections that build the Explore feature's "the caller already has this" exclusion +/// set. Both are the same query for every domain and differ only by which field they read, so they live here +/// once rather than being copy-pasted into MovieRepository/TvShowRepository/VideoGameRepository +/// (the first two already carried an identical hand-written copy of the reference-id one). +/// They aren't hooks on MongoDbRepositoryBase because that base is generic over entities with no +/// reference id and no title at all - passing the field in keeps the field declaration next to the entity it +/// belongs to, exactly like the SortTitleField hook does for sorting. +/// +internal static class ExploreExclusionQueries +{ + /// + /// Every distinct reference document id this owner's items link to. "Linked" is the inverse of the + /// repositories' UnresolvedFilter: a real id, neither null nor the legacy empty-string sentinel + /// (see CLAUDE.md on why an empty string has to be treated as unset here). + /// + internal static async Task> FindLinkedReferenceIdsAsync( + IMongoCollection collection, string ownerId, Expression> referenceIdField) + where TEntity : IHasIdAndOwnerId + { + var builder = Builders.Filter; + var filter = builder.Eq(f => f.OwnerId, ownerId) + & builder.Ne(referenceIdField, null) + & builder.Ne(referenceIdField, string.Empty); + var ids = await collection.Distinct(referenceIdField, filter).ToListAsync(); + return ids.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).ToList(); + } + + /// + /// Every distinct title this owner tracks, raw (normalization is the caller's job - TitleNormalizer + /// lives in the layers that compare them, not in a Mongo query). This is the fallback half of the + /// exclusion set: an item the owner added manually or imported may never have been linked to a reference + /// document, so matching on the reference id alone would keep suggesting something they already have. + /// + internal static async Task> FindDistinctTitlesAsync( + IMongoCollection collection, string ownerId, Expression> titleField) + where TEntity : IHasIdAndOwnerId + { + var titles = await collection.Distinct(titleField, Builders.Filter.Eq(f => f.OwnerId, ownerId)).ToListAsync(); + return titles.Where(title => !string.IsNullOrWhiteSpace(title)).ToList(); + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs b/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs index 8e53e794..a0822332 100644 --- a/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs +++ b/src/Infrastructure.MongoDb/Repositories/MongoDbRepositoryBase.cs @@ -23,7 +23,12 @@ public abstract class MongoDbRepositoryBase( protected ILogger> Logger { get; } = logger; - private IStorageMapper Mapper { get; } = mapper; + /// + /// The entity <-> model mapper. Protected rather than private so a subclass with its own hand-written + /// query maps through this single stored instance: capturing the primary-constructor parameter as well + /// would store the same mapper twice on the type (CS9107). + /// + protected IStorageMapper Mapper { get; } = mapper; public async Task FindOneAsync(string id, string ownerId) { diff --git a/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs b/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs index abd16671..bb4bc912 100644 --- a/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/MovieRepository.cs @@ -75,16 +75,11 @@ public async Task SetReferenceRatingAsync(string referenceId, double? rati return groups.Select(g => (g.Title, g.Year, (string?)null)).ToList(); } - public async Task> FindLinkedReferenceIdsAsync(string ownerId) - { - var builder = Builders.Filter; - // "linked" is the inverse of UnresolvedFilter: a real reference id, not null and not the legacy empty-string sentinel. - var filter = builder.Eq(f => f.OwnerId, ownerId) - & builder.Ne(f => f.ReferenceId, null) - & builder.Ne(f => f.ReferenceId, string.Empty); - var ids = await GetCollection().Distinct(f => f.ReferenceId, filter).ToListAsync(); - return ids.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).ToList(); - } + public Task> FindLinkedReferenceIdsAsync(string ownerId) => + ExploreExclusionQueries.FindLinkedReferenceIdsAsync(GetCollection(), ownerId, f => f.ReferenceId); + + public Task> FindDistinctTitlesAsync(string ownerId) => + ExploreExclusionQueries.FindDistinctTitlesAsync(GetCollection(), ownerId, f => f.Title); /// /// "Has no reference link yet" means is null OR empty string, not diff --git a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs index bbb62013..11424227 100644 --- a/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/TvShowRepository.cs @@ -70,7 +70,7 @@ public async Task> FindFinishedLinkedShowsAsync() & builder.Ne(f => f.ReferenceId, null) & builder.Ne(f => f.ReferenceId, string.Empty); var entities = await GetCollection().Find(filter).ToListAsync(); - return mapper.ToModels(entities); + return Mapper.ToModels(entities); } public async Task> FindDistinctUnresolvedTitleYearsAsync() @@ -83,16 +83,11 @@ public async Task> FindFinishedLinkedShowsAsync() return groups.Select(g => (g.Title, g.Year, (string?)null)).ToList(); } - public async Task> FindLinkedReferenceIdsAsync(string ownerId) - { - var builder = Builders.Filter; - // "linked" is the inverse of UnresolvedFilter - see FindFinishedLinkedShowsAsync for the same clause. - var filter = builder.Eq(f => f.OwnerId, ownerId) - & builder.Ne(f => f.ReferenceId, null) - & builder.Ne(f => f.ReferenceId, string.Empty); - var ids = await GetCollection().Distinct(f => f.ReferenceId, filter).ToListAsync(); - return ids.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).ToList(); - } + public Task> FindLinkedReferenceIdsAsync(string ownerId) => + ExploreExclusionQueries.FindLinkedReferenceIdsAsync(GetCollection(), ownerId, f => f.ReferenceId); + + public Task> FindDistinctTitlesAsync(string ownerId) => + ExploreExclusionQueries.FindDistinctTitlesAsync(GetCollection(), ownerId, f => f.Title); /// /// "Has no reference link yet" means is null OR empty string, not diff --git a/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs b/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs index 15e87646..33e8d398 100644 --- a/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/VideoGameRepository.cs @@ -76,6 +76,12 @@ public async Task SetReferenceRatingAsync(string referenceId, double? rati return result.ModifiedCount; } + public Task> FindLinkedReferenceIdsAsync(string ownerId) => + ExploreExclusionQueries.FindLinkedReferenceIdsAsync(GetCollection(), ownerId, f => f.ReferenceId); + + public Task> FindDistinctTitlesAsync(string ownerId) => + ExploreExclusionQueries.FindDistinctTitlesAsync(GetCollection(), ownerId, f => f.Title); + public async Task> FindDistinctUnresolvedTitleYearsAsync() { var groups = await GetCollection().Aggregate() diff --git a/src/WebApi/Controllers/ExploreController.cs b/src/WebApi/Controllers/ExploreController.cs index b768b59a..eccb740d 100644 --- a/src/WebApi/Controllers/ExploreController.cs +++ b/src/WebApi/Controllers/ExploreController.cs @@ -7,11 +7,12 @@ namespace Keeptrack.WebApi.Controllers; /// -/// The Explore feature: acclaimed provider titles (TMDB top-rated) the caller doesn't already track, with a -/// one-click add and a dismiss/undo. Read-only aggregation plus a create, so a plain ; -/// the listing/dismissal logic lives in . Adding resolves the reference from the -/// exact TMDB id the suggestion came from (not a title search), so the new item is reliably linked. Available -/// to every authenticated account because movies and TV shows are the free preview tier. +/// The Explore feature: acclaimed provider titles the caller doesn't already track, with a one-click add and +/// a dismiss/undo. Read-only aggregation plus a create, so a plain ; the +/// listing/dismissal logic lives in . Adding resolves the reference from the +/// exact provider id the suggestion came from (not a title search), so the new item is reliably linked. +/// Plain [Authorize] rather than the "MemberOnly" policy because movies and TV shows are the free +/// preview tier; the member-only domains are gated per request instead (see ). /// [ApiController] [Authorize] @@ -20,7 +21,8 @@ public class ExploreController( ExploreService exploreService, ReferenceEnrichmentService enrichmentService, IMovieRepository movieRepository, - ITvShowRepository tvShowRepository) : ControllerBase + ITvShowRepository tvShowRepository, + IVideoGameRepository videoGameRepository) : ControllerBase { /// Default number of suggestions returned when the caller doesn't ask for a specific count. private const int DefaultCount = 24; @@ -28,15 +30,21 @@ public class ExploreController( /// Upper bound on the count, so one request can't pull an unbounded number of provider pages. private const int MaxCount = 60; - /// Movies and TV shows are both part of the free preview tier, so adds count against the quota. + /// Movies and TV shows are part of the free preview tier, so adds count against the quota. private const int FreeTierLimitFactor = 1; - /// The top provider suggestions for a domain (movies or TV shows today). + /// Video games are a member-only collection, where the free-tier creation quota never applies. + private const int MemberOnlyLimitFactor = 0; + + /// The top provider suggestions for a domain (movies, TV shows or video games). [HttpGet("{type}")] [ProducesResponseType(200)] [ProducesResponseType(400)] + [ProducesResponseType(403)] public async Task>> Get(ReferenceItemType type, [FromQuery] int? count, CancellationToken cancellationToken) { + if (RequireAccessTo(type) is { } denied) return denied; + var limit = Math.Clamp(count ?? DefaultCount, 1, MaxCount); var suggestions = await exploreService.GetSuggestionsAsync(ToDomainType(type), this.GetUserId(), limit, cancellationToken); return Ok(suggestions); @@ -44,7 +52,8 @@ public async Task>> Get(ReferenceItemTyp /// /// Adds a suggestion to the caller's collection: creates the item and links it to the reference resolved - /// from the exact TMDB id (). 403 if a free-preview account is over its quota. + /// from the exact provider id (). 403 if a free-preview account is over its + /// quota, or if the domain is member-only. /// [HttpPost("{type}/add/{externalId}")] [ProducesResponseType(204)] @@ -53,19 +62,20 @@ public async Task>> Get(ReferenceItemTyp public async Task Add(ReferenceItemType type, string externalId, [FromBody] ExploreAddRequestDto request) { ToDomainType(type); // validates the domain (Book/Album -> 400) + if (RequireAccessTo(type) is { } denied) return denied; if (string.IsNullOrWhiteSpace(request.Title)) return BadRequest(); var ownerId = this.GetUserId(); - var quotaError = await FreeTierQuota.CheckAsync(this, FreeTierLimitFactor, () => CountAsync(type, ownerId)); + var quotaError = await FreeTierQuota.CheckAsync(this, LimitFactor(type), () => CountAsync(type, ownerId)); if (quotaError is not null) { return StatusCode(StatusCodes.Status403Forbidden, new { error = quotaError }); } - // create the item unlinked, then resolve the reference by its exact TMDB id (upserts the shared + // create the item unlinked, then resolve the reference by its exact provider id (upserts the shared // reference document and links this just-created item by (title, year) - see ResolveMovieAsync). Using // the id, not a title search, is why this links reliably where the ordinary create's search-based - // auto-resolve can miss a title with several TMDB candidates. + // auto-resolve can miss a title with several provider candidates. switch (type) { case ReferenceItemType.Movie: @@ -76,6 +86,10 @@ public async Task Add(ReferenceItemType type, string externalId, await tvShowRepository.CreateAsync(new TvShowModel { OwnerId = ownerId, Title = request.Title, Year = request.Year }); await enrichmentService.ResolveTvShowAsync(request.Title, request.Year, externalId); break; + case ReferenceItemType.VideoGame: + await videoGameRepository.CreateAsync(new VideoGameModel { OwnerId = ownerId, Title = request.Title, Year = request.Year }); + await enrichmentService.ResolveVideoGameAsync(request.Title, request.Year, externalId); + break; } return NoContent(); @@ -85,8 +99,11 @@ public async Task Add(ReferenceItemType type, string externalId, [HttpPost("{type}/dismiss/{externalId}")] [ProducesResponseType(204)] [ProducesResponseType(400)] + [ProducesResponseType(403)] public async Task Dismiss(ReferenceItemType type, string externalId) { + if (RequireAccessTo(type) is { } denied) return denied; + await exploreService.DismissAsync(ToDomainType(type), this.GetUserId(), externalId); return NoContent(); } @@ -95,25 +112,45 @@ public async Task Dismiss(ReferenceItemType type, string external [HttpDelete("{type}/dismiss/{externalId}")] [ProducesResponseType(204)] [ProducesResponseType(400)] + [ProducesResponseType(403)] public async Task Undismiss(ReferenceItemType type, string externalId) { + if (RequireAccessTo(type) is { } denied) return denied; + await exploreService.UndismissAsync(ToDomainType(type), this.GetUserId(), externalId); return NoContent(); } + /// + /// Per-domain membership gate, standing in for the controller-wide "MemberOnly" policy that movies and TV + /// shows must stay outside of. A free-preview account can't hold video games at all, so it gets neither + /// the suggestions (which would cost provider calls for titles it could never add) nor the dismissals. + /// Returns null when the caller may proceed, mirroring 's shape. + /// + private ObjectResult? RequireAccessTo(ReferenceItemType type) => + LimitFactor(type) == MemberOnlyLimitFactor && !this.IsMember() + ? StatusCode(StatusCodes.Status403Forbidden, new { error = $"{type} tracking is a membership feature." }) + : null; + + private static int LimitFactor(ReferenceItemType type) => + type == ReferenceItemType.VideoGame ? MemberOnlyLimitFactor : FreeTierLimitFactor; + private Task CountAsync(ReferenceItemType type, string ownerId) => type switch { ReferenceItemType.Movie => movieRepository.CountAsync(ownerId), ReferenceItemType.TvShow => tvShowRepository.CountAsync(ownerId), + ReferenceItemType.VideoGame => videoGameRepository.CountAsync(ownerId), _ => throw new ArgumentException($"Explore is not available for {type}.", nameof(type)) }; // Only the reference-ranked domains where discovery is meaningful are exposed; Book/Album are rejected - // (ArgumentException -> 400 via ApiExceptionFilterAttribute). Video games join here in a later increment. + // (ArgumentException -> 400 via ApiExceptionFilterAttribute) - an aggregate rank doesn't drive discovery + // there, and neither provider offers a best-of listing to read. private static ExploreItemType ToDomainType(ReferenceItemType type) => type switch { ReferenceItemType.Movie => ExploreItemType.Movie, ReferenceItemType.TvShow => ExploreItemType.TvShow, + ReferenceItemType.VideoGame => ExploreItemType.VideoGame, _ => throw new ArgumentException($"Explore is not available for {type}.", nameof(type)) }; } diff --git a/src/WebApi/ReferenceData/ExploreService.cs b/src/WebApi/ReferenceData/ExploreService.cs index 111f64b6..f438947c 100644 --- a/src/WebApi/ReferenceData/ExploreService.cs +++ b/src/WebApi/ReferenceData/ExploreService.cs @@ -1,62 +1,74 @@ +using Keeptrack.Common.System; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; namespace Keeptrack.WebApi.ReferenceData; /// -/// The Explore feature: reads the provider's own top-rated lists (TMDB today) and suggests acclaimed titles -/// the caller doesn't already track and hasn't dismissed. The discovery *list* has to come from TMDB - it's -/// the only movie/TV provider with a top-rated catalogue API (IMDb has none) - but the rating shown and the -/// ordering follow the admin's selected primary source, exactly like the rest of the app: TMDB's own vote by -/// default, or IMDb (via OMDb, keyed by the id TMDB exposes) when that's the chosen source. Querying the -/// provider - not the local reference collection, which only holds titles someone already tracked - is what -/// surfaces genuinely new things to watch. Lives in WebApi/ReferenceData (not Domain) as it depends on the -/// provider clients. Per-domain branching is confined to the small fetcher/lookup helpers. +/// The Explore feature: reads a provider's own best-of listing and suggests acclaimed titles the caller +/// doesn't already track and hasn't dismissed. Querying the provider - not the local reference collections, +/// which only hold titles someone already tracks - is what surfaces genuinely new things. Each domain reads +/// its own reference provider (TMDB for movies/TV, RAWG for video games) and ranks by the admin-selected +/// primary rating source for that domain, exactly like the rest of the app. The one wrinkle is movies/TV +/// under IMDb: IMDb has no catalogue/top-rated API at all, so the *list* still comes from TMDB and only the +/// displayed number is enriched per title. Video games need no such exception - RAWG sorts natively on both +/// of its own sources. Lives in WebApi/ReferenceData (not Domain) as it depends on the provider clients; +/// per-domain branching is confined to the small fetcher/lookup helpers. /// public class ExploreService( ITmdbClient tmdbClient, IOmdbClient omdbClient, + IRawgClient rawgClient, IAppSettingRepository appSettingRepository, IMovieRepository movieRepository, ITvShowRepository tvShowRepository, + IVideoGameRepository videoGameRepository, IMovieReferenceRepository movieReferenceRepository, ITvShowReferenceRepository tvShowReferenceRepository, + IVideoGameReferenceRepository videoGameReferenceRepository, IExploreDismissalRepository dismissalRepository) { - private const string TmdbSourceKey = "tmdb"; + /// + /// The provider each domain discovers through, and therefore the ExternalIds key its suggestion + /// ids live in. Deliberately distinct from the *rating* source: an IMDb-ranked movie suggestion is still + /// identified by a TMDB id. + /// + private const string TmdbProviderKey = "tmdb"; + + private const string RawgProviderKey = "rawg"; + + /// TMDB and IMDb ratings are both on a 0-10 scale; RAWG's own score is 0-5 and Metacritic's 0-100. + private const double TmdbRatingScale = 10; + + private const double RawgRatingScale = 5; - /// TMDB and IMDb ratings are both on a 0-10 scale. - private const double RatingScale = 10; + private const double MetacriticRatingScale = 100; - /// How many provider pages to pull through at most while filling a request (each ~20 titles). + /// How many provider pages to pull through at most while filling a request. private const int MaxProviderPages = 5; /// /// The top- provider suggestions for , excluding titles the - /// owner already tracks (matched by TMDB id on their linked references) or dismissed. The rating shown and - /// the ordering follow the admin's primary source for the domain (TMDB by default, IMDb when selected). + /// owner already tracks or has dismissed. The rating shown and the ordering follow the admin's primary + /// source for the domain. /// public async Task> GetSuggestionsAsync(ExploreItemType type, string ownerId, int limit, CancellationToken cancellationToken = default) { - var overrides = await appSettingRepository.GetReferenceRatingSourcesAsync(); - var source = RatingSourceCatalog.Resolve(overrides, ToReferenceItemType(type)); - // an admin can force Explore onto TMDB (its own free vote average) even when IMDb is the primary - // rating source, so discovery never pays the per-title OMDb lookup cost. - if (await appSettingRepository.GetExploreUseTmdbAsync()) source = RatingSourceCatalog.Tmdb; + var source = await ResolveRankingSourceAsync(type); + var excludedIds = await BuildExcludedExternalIdsAsync(type, ownerId); + var excludedTitles = await BuildExcludedTitlesAsync(type, ownerId); - var exclude = new HashSet(await dismissalRepository.FindDismissedExternalIdsAsync(ownerId, type)); - exclude.UnionWith(await TrackedExternalIdsAsync(type, ownerId)); - - var fetch = TopRatedFetcher(type); - var chosen = new List(); + var fetch = TopRatedFetcher(type, source); + var chosen = new List(); for (var page = 1; page <= MaxProviderPages && chosen.Count < limit; page++) { var candidates = await fetch(page, cancellationToken); - if (candidates.Count == 0) break; + if (candidates.Count == 0) break; // past the provider's last page foreach (var candidate in candidates) { - if (!exclude.Add(candidate.TmdbId)) continue; // skip tracked/dismissed and any duplicate across pages + if (!excludedIds.Add(candidate.ExternalId)) continue; // tracked, dismissed, or a duplicate across pages + if (excludedTitles.Contains(TitleNormalizer.Normalize(candidate.Title))) continue; chosen.Add(candidate); if (chosen.Count >= limit) break; } @@ -64,39 +76,72 @@ public async Task> GetSuggestionsAsync(ExploreItemTyp return source == RatingSourceCatalog.Imdb ? await MapWithImdbRatingsAsync(type, chosen, cancellationToken) - : chosen.Select(c => ToDto(c, c.VoteAverage)).ToList(); + : chosen.Select(ToDto).ToList(); } /// Hides a provider title from the owner's Explore list permanently (until undone). Idempotent. public Task DismissAsync(ExploreItemType type, string ownerId, string externalId) => - dismissalRepository.AddAsync(new ExploreDismissalModel { OwnerId = ownerId, ReferenceType = type, ExternalId = externalId }); + dismissalRepository.AddAsync(new ExploreDismissalModel + { + OwnerId = ownerId, + ItemType = type, + ExternalSource = DiscoverySource(type), + ExternalId = externalId + }); /// Undoes a dismissal so the title can be suggested again. public Task UndismissAsync(ExploreItemType type, string ownerId, string externalId) => - dismissalRepository.RemoveAsync(ownerId, type, externalId); + dismissalRepository.RemoveAsync(ownerId, type, DiscoverySource(type), externalId); + + /// + /// The admin-selected primary rating source for the domain - the same setting (and the same resolver) the + /// rest of the app ranks and displays by. An admin can additionally force movies/TV back onto TMDB so + /// discovery never pays the per-title OMDb lookup the IMDb path below costs; that flag is only ever read + /// when IMDb actually won, and it cannot apply to video games (RAWG's two sources don't include IMDb). + /// + private async Task ResolveRankingSourceAsync(ExploreItemType type) + { + var overrides = await appSettingRepository.GetReferenceRatingSourcesAsync(); + var source = RatingSourceCatalog.Resolve(overrides, ToReferenceItemType(type)); + return source == RatingSourceCatalog.Imdb && await appSettingRepository.GetExploreUseTmdbAsync() + ? RatingSourceCatalog.Tmdb + : source; + } // Fill in each chosen title's IMDb rating for display (imdb id via TMDB's external_ids, then one OMDb // call), bounded to the returned page. The ORDER stays TMDB's top-rated ranking - deliberately not // re-sorted by IMDb: IMDb has no top-rated list API, so re-ranking a page by whatever OMDb happened to // return (partial when rate-limited, blank with no key) would scramble the list and float low/unrated // titles to the top. Task.WhenAll preserves the input (TMDB) order. - private async Task> MapWithImdbRatingsAsync(ExploreItemType type, List chosen, CancellationToken cancellationToken) + private async Task> MapWithImdbRatingsAsync(ExploreItemType type, List chosen, CancellationToken cancellationToken) { var imdbIdFetcher = ImdbIdFetcher(type); var mapped = await Task.WhenAll(chosen.Select(async candidate => { - var imdbId = await imdbIdFetcher(candidate.TmdbId, cancellationToken); + var imdbId = await imdbIdFetcher(candidate.ExternalId, cancellationToken); var rating = string.IsNullOrEmpty(imdbId) ? null : await omdbClient.GetRatingAsync(imdbId, cancellationToken); - return ToDto(candidate, rating?.Value); + return ToDto(candidate with { Rating = rating?.Value, RatingScale = rating is null ? null : TmdbRatingScale }); })); return [.. mapped]; } - private Func>> TopRatedFetcher(ExploreItemType type) => type switch + // The one place a domain's discovery provider is named: which client answers, and which ExternalIds / + // dismissal key its ids belong to. only reaches RAWG, which sorts natively on + // both of its sources; TMDB has a single top-rated list whatever the ranking source is (see + // MapWithImdbRatingsAsync). + private Func>> TopRatedFetcher(ExploreItemType type, string source) => type switch + { + ExploreItemType.Movie => async (page, token) => ToCandidates(await tmdbClient.GetTopRatedMoviesAsync(page, token)), + ExploreItemType.TvShow => async (page, token) => ToCandidates(await tmdbClient.GetTopRatedTvShowsAsync(page, token)), + ExploreItemType.VideoGame => async (page, token) => ToCandidates(await rawgClient.GetTopRatedGamesAsync(page, source, token), source), + _ => throw new ArgumentOutOfRangeException(nameof(type), $"Explore is not available for {type}.") + }; + + private static string DiscoverySource(ExploreItemType type) => type switch { - ExploreItemType.Movie => tmdbClient.GetTopRatedMoviesAsync, - ExploreItemType.TvShow => tmdbClient.GetTopRatedTvShowsAsync, + ExploreItemType.Movie or ExploreItemType.TvShow => TmdbProviderKey, + ExploreItemType.VideoGame => RawgProviderKey, _ => throw new ArgumentOutOfRangeException(nameof(type), $"Explore is not available for {type}.") }; @@ -107,34 +152,92 @@ private async Task> MapWithImdbRatingsAsync(ExploreIt _ => throw new ArgumentOutOfRangeException(nameof(type), $"Explore is not available for {type}.") }; - // The set of TMDB ids the owner already tracks: their linked reference ids -> those reference documents' - // own tmdb external id. Bounded by the owner's collection size; the reference docs are the small, shared - // metadata ones, not tenant rows. - private async Task> TrackedExternalIdsAsync(ExploreItemType type, string ownerId) => type switch + private IExploreSourceRepository SourceRepository(ExploreItemType type) => type switch { - ExploreItemType.Movie => ExternalIdsOf(await movieReferenceRepository.FindByIdsAsync(await movieRepository.FindLinkedReferenceIdsAsync(ownerId)), r => r.ExternalIds), - ExploreItemType.TvShow => ExternalIdsOf(await tvShowReferenceRepository.FindByIdsAsync(await tvShowRepository.FindLinkedReferenceIdsAsync(ownerId)), r => r.ExternalIds), + ExploreItemType.Movie => movieRepository, + ExploreItemType.TvShow => tvShowRepository, + ExploreItemType.VideoGame => videoGameRepository, _ => throw new ArgumentOutOfRangeException(nameof(type), $"Explore is not available for {type}.") }; - private static IEnumerable ExternalIdsOf(IEnumerable references, Func> externalIds) => - references.Select(r => externalIds(r).GetValueOrDefault(TmdbSourceKey)).Where(id => !string.IsNullOrEmpty(id)).Select(id => id!); + // Provider ids the owner must not be suggested: what they dismissed, plus what they already track. The + // latter is their linked reference ids resolved to those reference documents' own provider id - bounded + // by the owner's collection size; the reference docs are the small, shared metadata ones, not tenant rows. + private async Task> BuildExcludedExternalIdsAsync(ExploreItemType type, string ownerId) + { + var excluded = new HashSet(await dismissalRepository.FindDismissedExternalIdsAsync(ownerId, type, DiscoverySource(type))); + excluded.UnionWith(await TrackedExternalIdsAsync(type, await SourceRepository(type).FindLinkedReferenceIdsAsync(ownerId))); + return excluded; + } + + // The second half of "don't suggest what they already have": an item the owner typed in or imported may + // never have been linked to a reference document at all (automatic resolution deliberately gives up when + // a title search returns several candidates), so it has no provider id to exclude by. Matching normalized + // titles catches those. Two genuinely different works sharing one title is possible, but hiding one + // discovery card is a far smaller cost than repeatedly suggesting something the owner already owns. + private async Task> BuildExcludedTitlesAsync(ExploreItemType type, string ownerId) => + [.. (await SourceRepository(type).FindDistinctTitlesAsync(ownerId)).Select(TitleNormalizer.Normalize)]; + + private async Task> TrackedExternalIdsAsync(ExploreItemType type, IReadOnlyList referenceIds) + { + var provider = DiscoverySource(type); + return type switch + { + ExploreItemType.Movie => ExternalIdsOf(await movieReferenceRepository.FindByIdsAsync(referenceIds), r => r.ExternalIds, provider), + ExploreItemType.TvShow => ExternalIdsOf(await tvShowReferenceRepository.FindByIdsAsync(referenceIds), r => r.ExternalIds, provider), + ExploreItemType.VideoGame => ExternalIdsOf(await videoGameReferenceRepository.FindByIdsAsync(referenceIds), r => r.ExternalIds, provider), + _ => throw new ArgumentOutOfRangeException(nameof(type), $"Explore is not available for {type}.") + }; + } + + private static IEnumerable ExternalIdsOf( + IEnumerable references, Func> externalIds, string provider) => + references.Select(r => externalIds(r).GetValueOrDefault(provider)).Where(id => !string.IsNullOrEmpty(id)).Select(id => id!); private static ReferenceItemType ToReferenceItemType(ExploreItemType type) => type switch { ExploreItemType.Movie => ReferenceItemType.Movie, ExploreItemType.TvShow => ReferenceItemType.TvShow, + ExploreItemType.VideoGame => ReferenceItemType.VideoGame, _ => throw new ArgumentOutOfRangeException(nameof(type), $"Explore is not available for {type}.") }; - private static ExploreSuggestionDto ToDto(TmdbTopRatedItem item, double? rating) => new() + private static IReadOnlyList ToCandidates(IReadOnlyList items) => + [.. items.Select(i => new ExploreCandidate( + i.TmdbId, i.Title, i.Year, i.Synopsis, i.PosterUrl, i.VoteAverage, i.VoteAverage is null ? null : TmdbRatingScale))]; + + // RAWG reports both of its scores on every listing entry, so the one the admin selected is picked here + // with no second request - and its scale travels with it (0-5 for RAWG's own, 0-100 for Metacritic's). + private static IReadOnlyList ToCandidates(IReadOnlyList items, string source) { - ExternalId = item.TmdbId, - Title = item.Title, - Year = item.Year, - ImageUrl = item.PosterUrl, - Synopsis = item.Synopsis, - Rating = rating, - RatingScale = rating is null ? null : RatingScale + var metacritic = source == RatingSourceCatalog.Metacritic; + return + [ + .. items.Select(i => + { + var rating = metacritic ? i.Metacritic : i.Rating; + return new ExploreCandidate( + i.ExternalId, i.Title, i.Year, null, i.ImageUrl, rating, + rating is null ? null : metacritic ? MetacriticRatingScale : RawgRatingScale); + }) + ]; + } + + private static ExploreSuggestionDto ToDto(ExploreCandidate candidate) => new() + { + ExternalId = candidate.ExternalId, + Title = candidate.Title, + Year = candidate.Year, + ImageUrl = candidate.ImageUrl, + Synopsis = candidate.Synopsis, + Rating = candidate.Rating, + RatingScale = candidate.RatingScale }; + + /// + /// One provider suggestion, normalized across providers so the paging/exclusion loop above is written + /// once instead of per domain. The rating carries its own scale because the domains don't share one. + /// + private sealed record ExploreCandidate( + string ExternalId, string Title, int? Year, string? Synopsis, string? ImageUrl, double? Rating, double? RatingScale); } diff --git a/src/WebApi/ReferenceData/IRawgClient.cs b/src/WebApi/ReferenceData/IRawgClient.cs index dc13599c..9d2ec0ea 100644 --- a/src/WebApi/ReferenceData/IRawgClient.cs +++ b/src/WebApi/ReferenceData/IRawgClient.cs @@ -8,6 +8,13 @@ public record RawgSearchResult(string ExternalId, string Title, int? Year, strin public record RawgGameDetails(string ExternalId, string Title, int? Year, string? Synopsis, List Genres, List Platforms, string? ImageUrl, double? Rating = null, int? RatingsCount = null, int? Metacritic = null); +/// +/// One entry from a RAWG "top rated" page - the fields a discovery card needs plus both of RAWG's aggregate +/// scores, so the caller picks whichever the admin selected as the primary source without a second request. +/// RAWG's list response carries no description, so there is no synopsis here (unlike TMDB's, which does). +/// +public record RawgTopRatedItem(string ExternalId, string Title, int? Year, string? ImageUrl, double? Rating, int? Metacritic); + /// /// Thin wrapper over the RAWG Video Games Database REST API. Interface exists so tests use a fake - /// never call the real RAWG API from a test. @@ -17,4 +24,14 @@ public interface IRawgClient Task> SearchGamesAsync(string title, int? year, CancellationToken cancellationToken = default); Task GetGameDetailsAsync(string externalId, CancellationToken cancellationToken = default); + + /// + /// One page of RAWG's best games, highest first, ordered by - the source + /// the Explore discovery feature reads for video games. is a + /// key RAWG can sort on natively ( + /// or ), so no per-title enrichment call is ever needed to + /// rank the list - unlike movies/TV, whose IMDb ranking has no provider-side ordering to lean on. + /// Returns an empty list past the last page. + /// + Task> GetTopRatedGamesAsync(int page, string ratingSource, CancellationToken cancellationToken = default); } diff --git a/src/WebApi/ReferenceData/RawgClient.cs b/src/WebApi/ReferenceData/RawgClient.cs index 30ad1757..4ce3897c 100644 --- a/src/WebApi/ReferenceData/RawgClient.cs +++ b/src/WebApi/ReferenceData/RawgClient.cs @@ -30,8 +30,32 @@ public async Task> SearchGamesAsync(string title details.BackgroundImage, details.Rating, details.RatingsCount, details.Metacritic); } + public async Task> GetTopRatedGamesAsync(int page, string ratingSource, CancellationToken cancellationToken = default) + { + var ordering = ratingSource == RatingSourceCatalog.Metacritic ? "metacritic" : "rating"; + var query = $"games?key={ApiKey}&ordering=-{ordering}&metacritic={MinMetacritic},100&page={page}&page_size={TopRatedPageSize}"; + var response = await http.GetFromJsonAsync(query, cancellationToken); + return response?.Results.Select(r => new RawgTopRatedItem( + r.Id.ToString(CultureInfo.InvariantCulture), r.Name ?? string.Empty, ParseYear(r.Released), r.BackgroundImage, + r.Rating, r.Metacritic)).ToList() ?? []; + } + private const int MaxResults = 5; + /// RAWG's per-page maximum, so a discovery request needs as few round-trips as possible. + private const int TopRatedPageSize = 40; + + /// + /// Notability floor on the discovery pool: only games Metacritic rates "generally favorable" or better are + /// candidates, whichever score the list is then *ordered* by. RAWG has no curated top-rated endpoint like + /// TMDB's (whose own list already applies a minimum vote count), and its rating is a plain average + /// with no vote-count filter or sort option - so ordering the whole ~900k-game catalogue by -rating + /// would rank an unknown game carrying a single 5-star vote above every classic. Requiring a Metacritic + /// score (i.e. the game was reviewed by the professional press at all) is the closest server-side + /// equivalent of TMDB's vote threshold, and it costs no extra call. Raise it for a stricter list. + /// + private const int MinMetacritic = 70; + private string ApiKey => settings.ApiKey; private static string Encode(string value) => HttpUtility.UrlEncode(value); @@ -58,6 +82,14 @@ private sealed class RawgSearchItem [JsonPropertyName("background_image")] public string? BackgroundImage { get; set; } + + // only populated on the top-rated listing (the search path ignores both) - RAWG's list serializer + // returns the same shape for every /games query. + [JsonPropertyName("rating")] + public double? Rating { get; set; } + + [JsonPropertyName("metacritic")] + public int? Metacritic { get; set; } } private sealed class RawgGameDetailsResponse diff --git a/test/WebApi.IntegrationTests/Resources/ExploreResourceTest.cs b/test/WebApi.IntegrationTests/Resources/ExploreResourceTest.cs index 9e3a3216..352785d4 100644 --- a/test/WebApi.IntegrationTests/Resources/ExploreResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/ExploreResourceTest.cs @@ -6,10 +6,10 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// -/// Explore controller coverage that doesn't depend on a live TMDB call: authentication, the domain guard -/// (Book/Album aren't reference-ranked discovery domains), and the dismiss/undo round-trip. The suggestion -/// listing itself hits the real TMDB top-rated API, so it's exercised by the Playwright smoke suite (which is -/// already provisioned with a TMDB key), not here. +/// Explore controller coverage that doesn't depend on a live provider call: authentication, the domain guard +/// (Book/Album aren't reference-ranked discovery domains), and the dismiss/undo round-trip for each domain's +/// own provider id space. The suggestion listing itself hits the real TMDB/RAWG top-rated APIs, so it's +/// exercised by the Playwright smoke suite (already provisioned with those keys), not here. /// public class ExploreResourceTest(KestrelWebAppFactory factory) : ResourceTestBase(factory) { @@ -32,9 +32,22 @@ public async Task Dismiss_AndUndo_AreIdempotentAndReturnNoContent() { await Authenticate(); - // dismissing twice is idempotent (both 204); undo also 204 - none of this touches TMDB + // dismissing twice is idempotent (both 204); undo also 204 - none of this touches a provider await PostNoContentAsync("/api/explore/Movie/dismiss/999999", new { }); await PostNoContentAsync("/api/explore/Movie/dismiss/999999", new { }); await DeleteAsync("/api/explore/Movie/dismiss/999999"); } + + [Fact] + public async Task Dismiss_KeepsEachDomainsProviderIdSpaceSeparate() + { + await Authenticate(); + + // the same bare number means a TMDB movie and a RAWG game - two different titles. The unique key + // carries the provider, so both inserts succeed and undoing one leaves the other in place. + await PostNoContentAsync("/api/explore/Movie/dismiss/424242", new { }); + await PostNoContentAsync("/api/explore/VideoGame/dismiss/424242", new { }); + await DeleteAsync("/api/explore/Movie/dismiss/424242"); + await DeleteAsync("/api/explore/VideoGame/dismiss/424242"); + } } diff --git a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs index c26f6265..bb939bad 100644 --- a/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs +++ b/test/WebApi.UnitTests/Import/TvTimeImportServiceIdempotencyTest.cs @@ -187,6 +187,9 @@ public Task> FindFinishedLinkedShowsAsync() => public Task> FindLinkedReferenceIdsAsync(string ownerId) => Task.FromResult>([]); + + public Task> FindDistinctTitlesAsync(string ownerId) => + Task.FromResult>([]); } private sealed class FakeMovieRepository : InMemoryRepository, IMovieRepository @@ -202,6 +205,9 @@ public Task SetReferenceRatingAsync(string referenceId, double? rating, do public Task> FindLinkedReferenceIdsAsync(string ownerId) => Task.FromResult>([]); + + public Task> FindDistinctTitlesAsync(string ownerId) => + Task.FromResult>([]); } private sealed class FakeEpisodeRepository() diff --git a/test/WebApi.UnitTests/ReferenceData/ExploreServiceTest.cs b/test/WebApi.UnitTests/ReferenceData/ExploreServiceTest.cs index 74b98e58..77bb3130 100644 --- a/test/WebApi.UnitTests/ReferenceData/ExploreServiceTest.cs +++ b/test/WebApi.UnitTests/ReferenceData/ExploreServiceTest.cs @@ -16,38 +16,62 @@ public class ExploreServiceTest { private readonly Mock _tmdbClient = new(); private readonly Mock _omdbClient = new(); + private readonly Mock _rawgClient = new(); private readonly Mock _appSettingRepository = new(); private readonly Mock _movieRepository = new(); private readonly Mock _tvShowRepository = new(); + private readonly Mock _videoGameRepository = new(); private readonly Mock _movieReferenceRepository = new(); private readonly Mock _tvShowReferenceRepository = new(); + private readonly Mock _videoGameReferenceRepository = new(); private readonly Mock _dismissalRepository = new(); private ExploreService CreateService() { - // no admin override by default => the code default source (tmdb) is used + // no admin override by default => the code default source per domain (tmdb / rawg) is used _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()).ReturnsAsync(new Dictionary()); return new( - _tmdbClient.Object, _omdbClient.Object, _appSettingRepository.Object, _movieRepository.Object, _tvShowRepository.Object, - _movieReferenceRepository.Object, _tvShowReferenceRepository.Object, _dismissalRepository.Object); + _tmdbClient.Object, _omdbClient.Object, _rawgClient.Object, _appSettingRepository.Object, + _movieRepository.Object, _tvShowRepository.Object, _videoGameRepository.Object, + _movieReferenceRepository.Object, _tvShowReferenceRepository.Object, _videoGameReferenceRepository.Object, + _dismissalRepository.Object); } private static TmdbTopRatedItem Item(string id, double rating) => new(id, $"Title {id}", 2000, "synopsis", "http://img", rating); + private static RawgTopRatedItem Game(string id, double? rating, int? metacritic) => new(id, $"Game {id}", 2010, "http://img", rating, metacritic); + + // "this owner tracks nothing and has dismissed nothing" - the starting point of every test that isn't + // specifically about the exclusion set. + private void NoExclusions(ExploreItemType type) + { + _dismissalRepository.Setup(r => r.FindDismissedExternalIdsAsync("owner", type, It.IsAny())).ReturnsAsync([]); + _movieRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync([]); + _tvShowRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync([]); + _videoGameRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync([]); + _movieRepository.Setup(r => r.FindDistinctTitlesAsync("owner")).ReturnsAsync([]); + _tvShowRepository.Setup(r => r.FindDistinctTitlesAsync("owner")).ReturnsAsync([]); + _videoGameRepository.Setup(r => r.FindDistinctTitlesAsync("owner")).ReturnsAsync([]); + _movieReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())).ReturnsAsync([]); + _tvShowReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())).ReturnsAsync([]); + _videoGameReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())).ReturnsAsync([]); + } + [Fact] public async Task GetSuggestionsAsync_ExcludesTitlesTheOwnerTracksOrHasDismissed_AndMapsTheRating() { + NoExclusions(ExploreItemType.Movie); // owner tracks reference "ref-a", whose TMDB id is "100" _movieRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync(["ref-a"]); _movieReferenceRepository.Setup(r => r.FindByIdsAsync(It.Is>(c => c.Contains("ref-a")))) .ReturnsAsync([new MovieReferenceModel { Id = "ref-a", Title = "Tracked", TitleNormalized = "tracked", ExternalIds = new Dictionary { ["tmdb"] = "100" } }]); - _dismissalRepository.Setup(r => r.FindDismissedExternalIdsAsync("owner", ExploreItemType.Movie)).ReturnsAsync(["200"]); + _dismissalRepository.Setup(r => r.FindDismissedExternalIdsAsync("owner", ExploreItemType.Movie, "tmdb")).ReturnsAsync(["200"]); // later pages are empty (the real client returns [] past the last page); only page 1 has results here _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(1, It.IsAny())) .ReturnsAsync([Item("100", 9.0), Item("200", 8.8), Item("300", 8.6)]); - var suggestions = await CreateService().GetSuggestionsAsync(ExploreItemType.Movie, "owner", 24); + var suggestions = await CreateService().GetSuggestionsAsync(ExploreItemType.Movie, "owner", 24, TestContext.Current.CancellationToken); // 100 is tracked, 200 is dismissed - only 300 survives suggestions.Should().ContainSingle(); @@ -56,16 +80,31 @@ public async Task GetSuggestionsAsync_ExcludesTitlesTheOwnerTracksOrHasDismissed suggestions[0].RatingScale.Should().Be(10); } + [Fact] + public async Task GetSuggestionsAsync_ExcludesATitleTheOwnerTracks_EvenWhenItHasNoReferenceLink() + { + NoExclusions(ExploreItemType.Movie); + // an item the owner typed in or imported that never got linked has no provider id to exclude by - + // only its title. Matching goes through the app-wide TitleNormalizer, so it is exactly as forgiving + // as every other title match in the codebase (case and surrounding whitespace), no more. + _movieRepository.Setup(r => r.FindDistinctTitlesAsync("owner")).ReturnsAsync([" the GODFATHER "]); + _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); + _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(1, It.IsAny())) + .ReturnsAsync([new TmdbTopRatedItem("1", "The Godfather", 1972, null, null, 8.7), Item("2", 8.6)]); + + var suggestions = await CreateService().GetSuggestionsAsync(ExploreItemType.Movie, "owner", 24, TestContext.Current.CancellationToken); + + suggestions.Should().ContainSingle().Which.ExternalId.Should().Be("2"); + } + [Fact] public async Task GetSuggestionsAsync_PagesThroughTheProviderUntilTheLimitIsFilled() { - _movieRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync([]); - _movieReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())).ReturnsAsync([]); - _dismissalRepository.Setup(r => r.FindDismissedExternalIdsAsync("owner", ExploreItemType.Movie)).ReturnsAsync([]); + NoExclusions(ExploreItemType.Movie); _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(1, It.IsAny())).ReturnsAsync([Item("1", 9.0), Item("2", 8.9)]); _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(2, It.IsAny())).ReturnsAsync([Item("3", 8.8), Item("4", 8.7)]); - var suggestions = await CreateService().GetSuggestionsAsync(ExploreItemType.Movie, "owner", 3); + var suggestions = await CreateService().GetSuggestionsAsync(ExploreItemType.Movie, "owner", 3, TestContext.Current.CancellationToken); suggestions.Select(s => s.ExternalId).Should().Equal(["1", "2", "3"], "it stops once the limit is filled, having pulled a second page"); _tmdbClient.Verify(c => c.GetTopRatedMoviesAsync(3, It.IsAny()), Times.Never); @@ -74,13 +113,11 @@ public async Task GetSuggestionsAsync_PagesThroughTheProviderUntilTheLimitIsFill [Fact] public async Task GetSuggestionsAsync_UsesTheTvShowProviderAndRepositories_ForTheTvShowDomain() { - _tvShowRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync([]); - _tvShowReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())).ReturnsAsync([]); - _dismissalRepository.Setup(r => r.FindDismissedExternalIdsAsync("owner", ExploreItemType.TvShow)).ReturnsAsync([]); + NoExclusions(ExploreItemType.TvShow); _tmdbClient.Setup(c => c.GetTopRatedTvShowsAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); _tmdbClient.Setup(c => c.GetTopRatedTvShowsAsync(1, It.IsAny())).ReturnsAsync([Item("tv1", 9.4)]); - var suggestions = await CreateService().GetSuggestionsAsync(ExploreItemType.TvShow, "owner", 24); + var suggestions = await CreateService().GetSuggestionsAsync(ExploreItemType.TvShow, "owner", 24, TestContext.Current.CancellationToken); suggestions.Should().ContainSingle().Which.ExternalId.Should().Be("tv1"); _tmdbClient.Verify(c => c.GetTopRatedMoviesAsync(It.IsAny(), It.IsAny()), Times.Never); @@ -92,9 +129,7 @@ public async Task GetSuggestionsAsync_WhenImdbIsThePrimarySource_ShowsImdbRating var service = CreateService(); // admin picked IMDb as the movie primary source _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()).ReturnsAsync(new Dictionary { ["Movie"] = "imdb" }); - _movieRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync([]); - _movieReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())).ReturnsAsync([]); - _dismissalRepository.Setup(r => r.FindDismissedExternalIdsAsync("owner", ExploreItemType.Movie)).ReturnsAsync([]); + NoExclusions(ExploreItemType.Movie); _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); // TMDB top-rated order: "20" before "10" _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(1, It.IsAny())).ReturnsAsync([Item("20", 9.0), Item("10", 5.0)]); @@ -103,7 +138,7 @@ public async Task GetSuggestionsAsync_WhenImdbIsThePrimarySource_ShowsImdbRating _omdbClient.Setup(c => c.GetRatingAsync("tt20", It.IsAny())).ReturnsAsync(new OmdbRating(7.0, 50)); _omdbClient.Setup(c => c.GetRatingAsync("tt10", It.IsAny())).ReturnsAsync(new OmdbRating(8.5, 100)); - var suggestions = await service.GetSuggestionsAsync(ExploreItemType.Movie, "owner", 24); + var suggestions = await service.GetSuggestionsAsync(ExploreItemType.Movie, "owner", 24, TestContext.Current.CancellationToken); // the shown value is IMDb's, but the ORDER stays TMDB's (no re-rank from partial OMDb data) suggestions.Select(s => s.ExternalId).Should().Equal(["20", "10"]); @@ -117,13 +152,11 @@ public async Task GetSuggestionsAsync_WhenExploreIsForcedToTmdb_IgnoresTheImdbPr var service = CreateService(); _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()).ReturnsAsync(new Dictionary { ["Movie"] = "imdb" }); _appSettingRepository.Setup(r => r.GetExploreUseTmdbAsync()).ReturnsAsync(true); - _movieRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync([]); - _movieReferenceRepository.Setup(r => r.FindByIdsAsync(It.IsAny>())).ReturnsAsync([]); - _dismissalRepository.Setup(r => r.FindDismissedExternalIdsAsync("owner", ExploreItemType.Movie)).ReturnsAsync([]); + NoExclusions(ExploreItemType.Movie); _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(It.IsAny(), It.IsAny())).ReturnsAsync([]); _tmdbClient.Setup(c => c.GetTopRatedMoviesAsync(1, It.IsAny())).ReturnsAsync([Item("20", 9.0), Item("10", 5.0)]); - var suggestions = await service.GetSuggestionsAsync(ExploreItemType.Movie, "owner", 24); + var suggestions = await service.GetSuggestionsAsync(ExploreItemType.Movie, "owner", 24, TestContext.Current.CancellationToken); // TMDB order and TMDB ratings, despite IMDb being the primary source suggestions.Select(s => s.ExternalId).Should().Equal(["20", "10"]); @@ -133,12 +166,88 @@ public async Task GetSuggestionsAsync_WhenExploreIsForcedToTmdb_IgnoresTheImdbPr } [Fact] - public async Task DismissAsync_RecordsADismissalKeyedByExternalId() + public async Task GetSuggestionsAsync_ForVideoGames_ReadsRawgOrderedByItsOwnScore_ByDefault() + { + NoExclusions(ExploreItemType.VideoGame); + _rawgClient.Setup(c => c.GetTopRatedGamesAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([]); + _rawgClient.Setup(c => c.GetTopRatedGamesAsync(1, "rawg", It.IsAny())) + .ReturnsAsync([Game("g1", 4.7, 96), Game("g2", 4.5, 92)]); + + var suggestions = await CreateService().GetSuggestionsAsync(ExploreItemType.VideoGame, "owner", 24, TestContext.Current.CancellationToken); + + suggestions.Select(s => s.ExternalId).Should().Equal(["g1", "g2"]); + // RAWG's own score on its own 0-5 scale, taken straight off the listing - no per-title call + suggestions[0].Rating.Should().Be(4.7); + suggestions[0].RatingScale.Should().Be(5); + _rawgClient.Verify(c => c.GetGameDetailsAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetSuggestionsAsync_ForVideoGames_WhenMetacriticIsThePrimarySource_OrdersAndShowsMetacriticScores() + { + var service = CreateService(); + _appSettingRepository.Setup(r => r.GetReferenceRatingSourcesAsync()).ReturnsAsync(new Dictionary { ["VideoGame"] = "metacritic" }); + NoExclusions(ExploreItemType.VideoGame); + _rawgClient.Setup(c => c.GetTopRatedGamesAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([]); + _rawgClient.Setup(c => c.GetTopRatedGamesAsync(1, "metacritic", It.IsAny())) + .ReturnsAsync([Game("g9", 4.1, 98)]); + + var suggestions = await service.GetSuggestionsAsync(ExploreItemType.VideoGame, "owner", 24, TestContext.Current.CancellationToken); + + // RAWG orders by the selected source itself, so unlike IMDb there is nothing to enrich or re-rank + suggestions.Should().ContainSingle().Which.ExternalId.Should().Be("g9"); + suggestions[0].Rating.Should().Be(98); + suggestions[0].RatingScale.Should().Be(100); + _rawgClient.Verify(c => c.GetTopRatedGamesAsync(It.IsAny(), "rawg", It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetSuggestionsAsync_ForVideoGames_IsUnaffectedByTheForceTmdbExploreFlag() { - await CreateService().DismissAsync(ExploreItemType.TvShow, "owner", "tv9"); + var service = CreateService(); + // the flag exists only to keep movie/TV discovery off the per-title OMDb lookups - "tmdb" is not a + // video game rating source at all, so it must never leak into this domain's ranking. + _appSettingRepository.Setup(r => r.GetExploreUseTmdbAsync()).ReturnsAsync(true); + NoExclusions(ExploreItemType.VideoGame); + _rawgClient.Setup(c => c.GetTopRatedGamesAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([]); + _rawgClient.Setup(c => c.GetTopRatedGamesAsync(1, "rawg", It.IsAny())).ReturnsAsync([Game("g1", 4.7, 96)]); + + var suggestions = await service.GetSuggestionsAsync(ExploreItemType.VideoGame, "owner", 24, TestContext.Current.CancellationToken); + suggestions.Should().ContainSingle().Which.RatingScale.Should().Be(5); + _tmdbClient.VerifyNoOtherCalls(); + } + + [Fact] + public async Task GetSuggestionsAsync_ForVideoGames_ExcludesGamesTheOwnerAlreadyTracks() + { + NoExclusions(ExploreItemType.VideoGame); + _videoGameRepository.Setup(r => r.FindLinkedReferenceIdsAsync("owner")).ReturnsAsync(["ref-g"]); + // the tracked game's reference carries the RAWG id - matched against the RAWG suggestion ids, never a TMDB one + _videoGameReferenceRepository.Setup(r => r.FindByIdsAsync(It.Is>(c => c.Contains("ref-g")))) + .ReturnsAsync([new VideoGameReferenceModel { Id = "ref-g", Title = "Owned", TitleNormalized = "owned", ExternalIds = new Dictionary { ["rawg"] = "g1" } }]); + _rawgClient.Setup(c => c.GetTopRatedGamesAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([]); + _rawgClient.Setup(c => c.GetTopRatedGamesAsync(1, "rawg", It.IsAny())) + .ReturnsAsync([Game("g1", 4.7, 96), Game("g2", 4.5, 92)]); + + var suggestions = await CreateService().GetSuggestionsAsync(ExploreItemType.VideoGame, "owner", 24, TestContext.Current.CancellationToken); + + suggestions.Should().ContainSingle().Which.ExternalId.Should().Be("g2"); + } + + [Fact] + public async Task DismissAsync_RecordsADismissalKeyedByTheDomainsDiscoveryProvider() + { + var service = CreateService(); + + await service.DismissAsync(ExploreItemType.TvShow, "owner", "tv9"); + await service.DismissAsync(ExploreItemType.VideoGame, "owner", "g9"); + + _dismissalRepository.Verify(r => r.AddAsync(It.Is(m => + m.OwnerId == "owner" && m.ItemType == ExploreItemType.TvShow && m.ExternalSource == "tmdb" && m.ExternalId == "tv9")), Times.Once); + // a RAWG id and a TMDB id are both plain integers, so the provider is stored, never inferred _dismissalRepository.Verify(r => r.AddAsync(It.Is(m => - m.OwnerId == "owner" && m.ReferenceType == ExploreItemType.TvShow && m.ExternalId == "tv9")), Times.Once); + m.OwnerId == "owner" && m.ItemType == ExploreItemType.VideoGame && m.ExternalSource == "rawg" && m.ExternalId == "g9")), Times.Once); } [Fact] @@ -146,6 +255,6 @@ public async Task UndismissAsync_RemovesTheDismissal() { await CreateService().UndismissAsync(ExploreItemType.Movie, "owner", "42"); - _dismissalRepository.Verify(r => r.RemoveAsync("owner", ExploreItemType.Movie, "42"), Times.Once); + _dismissalRepository.Verify(r => r.RemoveAsync("owner", ExploreItemType.Movie, "tmdb", "42"), Times.Once); } } diff --git a/test/WebApi.UnitTests/ReferenceData/FakeRawgClient.cs b/test/WebApi.UnitTests/ReferenceData/FakeRawgClient.cs index f86e5514..feae3d88 100644 --- a/test/WebApi.UnitTests/ReferenceData/FakeRawgClient.cs +++ b/test/WebApi.UnitTests/ReferenceData/FakeRawgClient.cs @@ -11,6 +11,12 @@ internal sealed class FakeRawgClient : IRawgClient public Dictionary Details { get; } = new(); + /// One page of Explore discovery results, keyed by page number - empty pages end the paging loop. + public Dictionary> TopRatedPages { get; } = new(); + + /// The rating source the last call asked RAWG to order by. + public string? LastTopRatedOrdering { get; private set; } + private FakeRawgClient(List searchResults) => _searchResults = searchResults; public static FakeRawgClient Empty() => new([]); @@ -22,4 +28,10 @@ public Task> SearchGamesAsync(string title, int? public Task GetGameDetailsAsync(string externalId, CancellationToken cancellationToken = default) => Task.FromResult(Details.GetValueOrDefault(externalId)); + + public Task> GetTopRatedGamesAsync(int page, string ratingSource, CancellationToken cancellationToken = default) + { + LastTopRatedOrdering = ratingSource; + return Task.FromResult(TopRatedPages.GetValueOrDefault(page, [])); + } } From 0d03dd74015585ead4b523bdbaf6c27137e80028 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 31 Jul 2026 18:53:54 +0200 Subject: [PATCH 40/80] Clean db when tests run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was wrong Two root causes, both silent: 1. Tests were writing to keeptrack_dev. The in-process host runs as Development, so when Infrastructure__MongoDB__DatabaseName isn't exported it falls back to appsettings.Development.json → your real database. That's the documented filtered-run workflow (--filter-method can't be combined with --settings), so it's easy to hit by accident. Evidence in dev: 180 test-lease-*, 65 Export Test Actor, E2e Smoke Collectible/Gear. 2. A cleanup that deleted nothing and reported success. Builders.Filter.Eq("_id", stringId) doesn't match a document whose _id is an ObjectId. The typed Eq(x => x.Id, …) form works; the string-field-name form doesn't. Tests using it passed for months while leaking. Plus: reference fixtures hardcoded tmdb:"1" / openlibrary:"OL1W", which your new unique partial indexes now reject — that's the thing that blocked you. What changed - TestDatabaseGuard fails the run fast if the database isn't explicitly a test one (wired into both suites' hosts). - Cleanup registered at creation time, not in try/finally — a finally never covered fixtures created before the try opened. DatabaseTestBase/ResourceTestBase for integration, SmokeTestBase for Playwright; cleanups run under CancellationToken.None (the old code used the test token, which is cancelled exactly when you most need cleanup). - Per-test unique external ids (TestExternalId.New()). - Playwright: the seeded "Playwright Chronicles" reference now has a fixed id (22 copies had accumulated) and is removed at teardown, along with the ephemeral user's user_preference/background_job rows. Verified Integration suite: 141 tests green, and a document-count diff across the run shows zero drift, twice consecutively. Deleted 301 test documents from keeptrack_dev (your real data untouched — 1539 movies, 9922 episodes intact), 133 from keeptrack_integrationtests, 11 from keeptrack_e2e. Two things needing your call - Pre-existing Playwright flake: one test per full run fails on a books-list row not appearing (different test each time; passes in isolation). I confirmed it fails identically on the pre-change code, then stopped. Triaged in docs/code-quality-findings.md with what's already ruled out, so nobody re-derives it. Want me to look at it separately? - Deleting a TV show doesn't cascade to its episodes (House/HealthProfile do cascade). I worked around it in the test rather than changing delete semantics — but that asymmetry looks like a real app gap. --- CLAUDE.md | 46 ++- CONTRIBUTING.md | 19 ++ docs/code-quality-findings.md | 19 ++ .../Hosting/End2EndFixture.cs | 80 ++++- .../Smoke/AlbumSmokeTest.cs | 16 +- .../Smoke/AmazonImportSmokeTest.cs | 38 +-- .../Smoke/BookSmokeTest.cs | 2 + .../Smoke/CarSmokeTest.cs | 2 + .../Smoke/CollectibleSmokeTest.cs | 2 + .../Smoke/GearSmokeTest.cs | 2 + .../Smoke/GenericImportSmokeTest.cs | 38 +-- .../Smoke/GenericVideoGameImportSmokeTest.cs | 38 +-- .../Smoke/GoogleBooksSmokeTest.cs | 20 +- .../Smoke/HealthSmokeTest.cs | 2 + .../Smoke/HouseSmokeTest.cs | 2 + .../Smoke/ListStateSmokeTest.cs | 2 + .../Smoke/MovieSmokeTest.cs | 14 +- .../Smoke/OwnershipSmokeTest.cs | 2 + .../Smoke/PlaylistSmokeTest.cs | 2 + .../Smoke/QuickAddSmokeTest.cs | 52 +--- .../Smoke/ReferenceSmokeTest.cs | 2 + .../Smoke/SharedWishlistSmokeTest.cs | 47 ++- .../Smoke/SharingSmokeTest.cs | 148 ++++------ .../Smoke/SmokeTestBase.cs | 73 +++++ .../Smoke/TvShowSmokeTest.cs | 14 +- .../Smoke/TvTimeImportSmokeTest.cs | 29 +- .../Smoke/VideoGamePlatformSmokeTest.cs | 2 + .../Smoke/VideoGameSmokeTest.cs | 14 +- .../Smoke/WatchNextSmokeTest.cs | 59 ++-- .../Support/ReferenceFixtureZipBuilder.cs | 9 + .../Hosting/TestDatabaseGuard.cs | 64 ++++ .../Hosting/KestrelWebAppFactory.cs | 7 + .../Resources/AlbumReferenceRepositoryTest.cs | 73 ++--- .../Resources/AlbumResourceTest.cs | 108 +++---- .../Resources/AmazonImportResourceTest.cs | 278 ++++++++---------- .../Resources/BackgroundJobRepositoryTest.cs | 32 +- .../BookProviderSearchAndLinkResourceTest.cs | 44 +-- .../Resources/BookReferenceRepositoryTest.cs | 105 +++---- .../Resources/BookResourceTest.cs | 75 ++--- .../Resources/BookUnresolvedQueueTest.cs | 48 ++- .../Resources/CarHistoryResourceTest.cs | 60 ++-- .../Resources/CarResourceTest.cs | 70 ++--- .../Resources/CollectibleResourceTest.cs | 74 ++--- .../Resources/DatabaseTestBase.cs | 133 +++++++++ .../Resources/EpisodeRepositoryTest.cs | 47 ++- .../Resources/ExploreResourceTest.cs | 19 +- .../Resources/GearResourceTest.cs | 165 ++++------- .../Resources/GenericImportResourceTest.cs | 155 +++++----- .../GenericVideoGameImportResourceTest.cs | 165 +++++------ .../Resources/HealthProfileResourceTest.cs | 123 ++++---- .../Resources/HealthRecordResourceTest.cs | 49 ++- .../Resources/HouseHistoryResourceTest.cs | 60 ++-- .../Resources/HouseResourceTest.cs | 70 ++--- .../Resources/LeaseRepositoryTest.cs | 31 +- .../Resources/ListSortingRepositoryTest.cs | 85 +++--- .../MovieReferenceRatingRepositoryTest.cs | 132 ++++----- .../Resources/MovieResourceTest.cs | 98 +++--- .../PersonReferenceRepositoryTest.cs | 51 ++-- .../Resources/PlaylistResourceTest.cs | 52 ++-- .../ReferenceDataAdminResourceTest.cs | 4 + .../ReferenceDataExportImportTest.cs | 143 +++------ .../Resources/RefreshReferenceResourceTest.cs | 115 +++----- .../Resources/ResourceTestBase.cs | 102 ++++++- .../Resources/ShareResourceTest.cs | 173 +++++------ .../Resources/SongResourceTest.cs | 56 ++-- .../Resources/StatsResourceTest.cs | 20 +- .../Resources/SystemStatusResourceTest.cs | 3 + .../Resources/TestExternalId.cs | 21 ++ .../Resources/TvShowReferenceLinkingTest.cs | 82 +++--- .../TvShowReferenceRepositoryTest.cs | 126 +++----- .../Resources/TvShowResourceTest.cs | 19 +- .../Resources/TvTimeImportResourceTest.cs | 140 ++++----- .../Resources/UnlinkReferenceResourceTest.cs | 96 +++--- .../Resources/UserPreferencesResourceTest.cs | 6 + .../VideoGameReferenceRepositoryTest.cs | 67 ++--- .../Resources/VideoGameResourceTest.cs | 94 ++---- .../Resources/WishlistResourceTest.cs | 41 +-- .../Resources/WishlistShareResourceTest.cs | 42 ++- 78 files changed, 2170 insertions(+), 2448 deletions(-) create mode 100644 test/Testing.Shared/Hosting/TestDatabaseGuard.cs create mode 100644 test/WebApi.IntegrationTests/Resources/DatabaseTestBase.cs create mode 100644 test/WebApi.IntegrationTests/Resources/TestExternalId.cs diff --git a/CLAUDE.md b/CLAUDE.md index 42f64605..87eb2477 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1011,11 +1011,50 @@ the scoped file itself has to be edited. ## Tests +### Which database a suite writes to, and leaving it as it was found + +The integration and Playwright suites host the real `WebApi` in-process against a **real, long-lived MongoDB** - there is no per-test throwaway database. +Two rules follow from that, and both have already been broken in ways that cost real debugging time. + +**Every suite must be pointed at a dedicated database, never `keeptrack_dev`.** +`Infrastructure__MongoDB__DatabaseName` selects it (`keeptrack_integrationtests` and `keeptrack_e2e` by convention; see CONTRIBUTING.md). +The trap is that this is *silent* when unset: the in-process host runs as `Development`, so it falls straight back to `src/WebApi/appsettings.Development.json` - i.e. `keeptrack_dev`, the database the developer actually browses in the app. +Nothing errors; the suite just creates, mutates and deletes documents in real data. +It's easy to hit by accident rather than carelessness, because the documented way to run a *filtered* subset (see the `--settings`/`--filter-method` gotcha above) is to export the runsettings' variables into the shell yourself - forget that step and the run lands on `keeptrack_dev`. +That is exactly how the dev database ended up holding 180 `test-lease-*` documents, 65 `Export Test Actor` person references and stray `E2e Smoke *` items. +`Testing.Shared/Hosting/TestDatabaseGuard.EnsureExplicitTestDatabase` now fails the run fast instead, called from `WebApi.IntegrationTests`' `KestrelWebAppFactory` constructor (so every fixture inherits it) and from `End2EndFixture` in self-hosted mode. + +**Every test removes what it created, on success and on failure.** +This isn't tidiness: `scripts/mongodb-create-index.js` enforces natural-key and `external_ids` uniqueness, so yesterday's leftover makes today's run fail with a duplicate-key error. +Cleanup is registered at the moment of creation rather than written as a per-test `try`/`finally` - a `finally` only covers what was created before the `try` opened, and the common "create two fixtures, then open the try" shape leaked whenever the second create failed. + +- `DatabaseTestBase` (`test/WebApi.IntegrationTests/Resources/`) holds the registry every integration test inherits: `TrackCleanup(Func)` for anything (a repository `DeleteAsync`), `TrackDocument(collection, id)` for a raw document, and `TrackDocumentsWhere(collection, filter)` for an owner-scoped singleton with no id the test ever sees (`user_preference`). + `ResourceTestBase` extends it with the HTTP-level ones: `CreateAsync` (POST + register, the shape almost every test wants), `TrackResource(endpoint, id)`, and `TrackResourcesMatching(endpoint, searchTerm)` for the import endpoints, whose commit creates items the test never learns the ids of. +- `SmokeTestBase` mirrors it for Playwright: `TrackOpenItem(apiRoute)` reads the id out of the detail page's URL (the only place a UI-driven test can learn it), `CreateItemAsync` seeds through the API, `TrackItemsMatching` covers the import commits, and `TrackCleanup` handles the rest. +- `DisposeAsync` **drains** the registry rather than iterating a cached count, because `TrackResourcesMatching` can only discover what an import created at cleanup time and then registers each id it found. +- Cleanups run under `CancellationToken.None`, never `TestContext.Current.CancellationToken` - that token is cancelled exactly when a test times out or the run is interrupted, which is precisely when leftovers are most likely. + +**Gotcha, and the reason this class of bug is so persistent: a delete filter that matches nothing looks exactly like a delete that worked.** +`Builders.Filter.Eq("_id", id)` with a `string` id against a document whose `_id` is an `ObjectId` matches nothing, deletes nothing, and reports success - the tests using it kept passing for months while 65 `Export Test Actor` documents piled up. +The typed `Eq(x => x.Id, id)` form works; the string-field-name form does not. +`TrackDocument` sidesteps it entirely by filtering over `BsonDocument` and converting the id to an `ObjectId` when it parses (`lease`/`background_job` ids are genuine strings and simply don't, so one helper covers both). +Never assert cleanup worked by reading the test's exit status - **verify with a document-count diff across the run**, which is what proved the current state: the integration suite returns `keeptrack_integrationtests` to its exact baseline, run after run. + +**Reference documents created by linking a *real* provider title are deliberately left in place** (TMDB's "The Terminator", the cast rows behind it, a Google Books volume). +They're shared canonical facts, deduplicated by provider id, so a re-run reuses the same document instead of adding another - they don't accumulate, and deleting them only forces the next run to re-fetch. +Synthetic fixtures are the opposite and must always be removed: they use made-up ids, so they *do* accumulate, and a stale one collides with the unique partial index. +For that reason reference fixtures generate their external id per test (`TestExternalId.New()`) instead of the shared literal `"1"`/`"OL1W"` they used to hardcode. +Two test classes running in parallel both inserting `tmdb: "1"` is a duplicate-key failure, not a theoretical one. + +**Gotcha:** deleting a TV show does **not** cascade to its episodes (unlike `House`/`HealthProfile`, which have an `OnDeletedAsync` hook), so any test that marks an episode watched has to delete the episodes itself or it orphans them. + - `test/WebApi.UnitTests`: xunit v3 unit tests, e.g. the TV Time import parsers (`Import/Parsers/`, pure stream-in/records-out, no I/O beyond an in-memory stream), and `WatchNextService` (pure next-episode computation). Mapper configuration validation is a compile-time concern now (Mapperly's `RMG012`/`RMG020` diagnostics, escalated to build errors in `.editorconfig`), not a unit test - there's no equivalent of the old `AutoMapperConfigurationTest` to run here anymore. - `test/WebApi.IntegrationTests`: xunit v3 tests booted against a real Kestrel host (`KestrelWebAppFactory`) and a real MongoDB instance. - `ResourceTestBase` provides typed `GetAsync`/`PostAsync`/`PutAsync`/`DeleteAsync`/`PostFileAsync` helpers and an `Authenticate()` helper that logs in against Firebase to obtain a bearer token. + `ResourceTestBase` provides typed `GetAsync`/`PostAsync`/`PutAsync`/`DeleteAsync`/`PostFileAsync` helpers, an `Authenticate()` helper that logs in against Firebase to obtain a bearer token, and the cleanup-registration helpers described above. + `Authenticate()` also exposes `AuthenticatedUserId`, the same value the API stamps as `OwnerId`, for the cleanups that can only identify a document by its owner. + It's the `user_id` claim read straight out of the token payload - no signature check, since the API validates the token on every call. Resource tests (`BookResourceTest`, `MovieResourceTest`, `TvTimeImportResourceTest`) exercise a full create/read/update/delete (or upsert) cycle against the live API and clean up what they create. `SyncNow_PollingReachesACompletedResult` self-skips unless `REFERENCE_SYNC_POLL_ENABLED=true` - polling a full live-provider sync to completion grows with the shared database and flakes on provider latency, so only the job-start half runs by default (see CONTRIBUTING.md). @@ -1028,6 +1067,11 @@ the scoped file itself has to be edited. `E2eFixture` (an xunit v3 `[AssemblyFixture]`) hosts both `WebApi` and `BlazorApp` in-process via the shared `KestrelWebAppFactory` (extern-alias wiring for their two generated `Program` classes). It signs in exactly once for the whole run (`POST /auth/callback` + saved Playwright storage state, reusing `Testing.Shared`'s `AccountRepository` sign-in cache). It also seeds a synthetic book reference via `POST /api/reference-data/import` so "check for reference match" never calls a real provider. + That seed carries a **fixed** `ReferenceFixtureZipBuilder.ReferenceId` rather than letting the import mint a new one. + The import is only idempotent for a document that already has an id (each reference repository's `UpsertAsync` replaces by id), so without it every run inserted another copy - 22 identical "The Playwright Chronicles" documents had accumulated. + The fixture removes it again on dispose, through the hosted `IBookReferenceRepository` rather than HTTP, since no admin endpoint deletes a single reference document and inventing one just to let tests tidy up would be the wrong trade. + It also deletes the run's ephemeral user's own `user_preference`/`background_job` rows (owner-scoped, no delete endpoint, and nothing else can reach them). + That's guarded on the user actually being ephemeral, so a run pointed at a real account via `E2E_USERNAME` never wipes that person's saved preferences. `Pages/PageBase` holds the sidebar nav locators and typed `OpenAsync()` helpers that return the next page object. `ListPage` is one class parameterized by route/title covering all ten inventory list pages, since `InventoryList` renders them all identically. A handful of fields across every inventory type's Add-form and detail page got a minimal `data-testid` added because their ` -public class BookReferenceRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +public class BookReferenceRepositoryTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) { [Fact] public async Task FindByTitleYearAsync_MatchesAnAliasWhoseConfirmedYearDiffersFromTheDocumentsOwnCanonicalYear() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var alternateTitle = $"Alternate Book Title {Guid.NewGuid()}"; - var created = await repository.UpsertAsync(new BookReferenceModel + var created = await CreateReferenceAsync(repository, new BookReferenceModel { Title = "Canonical Book Title", TitleNormalized = "canonical book title", Year = 2005, - ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" }, + ExternalIds = new Dictionary { ["openlibrary"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2004, Creator = "some author" }] }); - try - { - var found = await repository.FindByTitleYearAsync(alternateTitle, 2004, "Some Author"); + var found = await repository.FindByTitleYearAsync(alternateTitle, 2004, "Some Author"); - found.Should().NotBeNull(); - found!.Id.Should().Be(created.Id); - } - finally - { - await DeleteAsync(scope, created.Id!); - } + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); } [Fact] public async Task FindByTitleAsync_MatchesAnAlternateTitle_IgnoringYear() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var alternateTitle = $"Alternate Book Title {Guid.NewGuid()}"; - var created = await repository.UpsertAsync(new BookReferenceModel + var created = await CreateReferenceAsync(repository, new BookReferenceModel { Title = "Canonical Book Title", TitleNormalized = "canonical book title", Year = 2005, - ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" }, + ExternalIds = new Dictionary { ["openlibrary"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2005, Creator = "some author" }] }); - try - { - var found = await repository.FindByTitleAsync(alternateTitle, "Some Author"); + var found = await repository.FindByTitleAsync(alternateTitle, "Some Author"); - found.Should().NotBeNull(); - found!.Id.Should().Be(created.Id); - } - finally - { - await DeleteAsync(scope, created.Id!); - } + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); } /// @@ -86,72 +71,56 @@ public async Task FindByTitleAsync_MatchesAnAlternateTitle_IgnoringYear() [Fact] public async Task FindByExternalIdAsync_FindsTheSameDocument_ByEitherOfTwoCoexistingProviderKeys() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Multi Provider Book Title {Guid.NewGuid()}"; - // unique per run - other tests in this file already reuse the literal "OL1W" placeholder across - // several documents, so FindByExternalIdAsync could otherwise resolve to one of theirs instead of - // this test's own document (confirmed: this is exactly what happened before this fix). - var openLibraryId = $"OL-{Guid.NewGuid():N}"; + var openLibraryId = TestExternalId.New(); var bnfId = $"ark:/12148/{Guid.NewGuid():N}"; - var created = await repository.UpsertAsync(new BookReferenceModel + var created = await CreateReferenceAsync(repository, new BookReferenceModel { Title = title, TitleNormalized = title.ToLowerInvariant(), ExternalIds = new Dictionary { ["openlibrary"] = openLibraryId, ["bnf"] = bnfId } }); - try - { - var foundByOpenLibrary = await repository.FindByExternalIdAsync("openlibrary", openLibraryId); - var foundByBnf = await repository.FindByExternalIdAsync("bnf", bnfId); - - foundByOpenLibrary.Should().NotBeNull(); - foundByBnf.Should().NotBeNull(); - foundByOpenLibrary!.Id.Should().Be(created.Id); - foundByBnf!.Id.Should().Be(created.Id); - } - finally - { - await DeleteAsync(scope, created.Id!); - } + var foundByOpenLibrary = await repository.FindByExternalIdAsync("openlibrary", openLibraryId); + var foundByBnf = await repository.FindByExternalIdAsync("bnf", bnfId); + + foundByOpenLibrary.Should().NotBeNull(); + foundByBnf.Should().NotBeNull(); + foundByOpenLibrary!.Id.Should().Be(created.Id); + foundByBnf!.Id.Should().Be(created.Id); } [Fact] public async Task UpsertAsync_AlwaysIncludesTheCanonicalTitleAndYearInMatchedAliases_EvenIfTheCallerForgot() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Canonical Only Book Title {Guid.NewGuid()}"; - var created = await repository.UpsertAsync(new BookReferenceModel + var created = await CreateReferenceAsync(repository, new BookReferenceModel { Title = title, TitleNormalized = title.ToLowerInvariant(), Year = 2010, - ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" } + ExternalIds = new Dictionary { ["openlibrary"] = TestExternalId.New() } }); - try - { - // this safety-net alias has no Creator (the model only carries AuthorReferenceId, not - // denormalized text - see BookReferenceRepository.UpsertAsync), so it's unreachable via the - // creator-required FindByTitleAsync/FindByTitleYearAsync; assert on the stored alias directly. - var found = await repository.FindByIdAsync(created.Id!); - - found.Should().NotBeNull(); - found!.MatchedAliases.Should().ContainSingle(m => string.Equals(m.Title, title, StringComparison.OrdinalIgnoreCase) && m.Year == 2010); - } - finally - { - await DeleteAsync(scope, created.Id!); - } + // this safety-net alias has no Creator (the model only carries AuthorReferenceId, not + // denormalized text - see BookReferenceRepository.UpsertAsync), so it's unreachable via the + // creator-required FindByTitleAsync/FindByTitleYearAsync; assert on the stored alias directly. + var found = await repository.FindByIdAsync(created.Id!); + + found.Should().NotBeNull(); + found!.MatchedAliases.Should().ContainSingle(m => string.Equals(m.Title, title, StringComparison.OrdinalIgnoreCase) && m.Year == 2010); } - private static async Task DeleteAsync(IServiceScope scope, string id) + private async Task CreateReferenceAsync(IBookReferenceRepository repository, BookReferenceModel model) { - var collection = scope.ServiceProvider.GetRequiredService().GetCollection("book_reference"); - await collection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, id), TestContext.Current.CancellationToken); + var created = await repository.UpsertAsync(model); + TrackDocument("book_reference", created.Id); + return created; } } diff --git a/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs b/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs index 0360f89f..80d7f748 100644 --- a/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/BookResourceTest.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -12,7 +12,6 @@ using Keeptrack.WebApi.Contracts.Dto; using Keeptrack.WebApi.IntegrationTests.Hosting; using Microsoft.Extensions.DependencyInjection; -using MongoDB.Driver; using Xunit; namespace Keeptrack.WebApi.IntegrationTests.Resources; @@ -42,26 +41,19 @@ public async Task BookResourceFullCycle_IsOk() o.Isbn = f.Random.Replace("##########"); }) .Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); + var created = await CreateAsync($"/{ResourceEndpoint}", input); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.Title = "New shiny title"; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created, x => x.Excluding(item => item.FirstReadAt)); // issue with DateTime and MongoDB - - var finalItems = await GetAsync>($"/{ResourceEndpoint}"); - var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); - firstItem.Should().NotBeNull(); - firstItem.Title.Should().Be(updated.Title); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created, x => x.Excluding(item => item.FirstReadAt)); // issue with DateTime and MongoDB + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); } [Fact] @@ -69,7 +61,7 @@ public async Task BookResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItems_ { await Authenticate(); - var uniqueTitle = $"OwnedWishlistTarget-{System.Guid.NewGuid():N}"; + var uniqueTitle = $"OwnedWishlistTarget-{Guid.NewGuid():N}"; var input = new Faker() .Rules((f, o) => { @@ -80,21 +72,14 @@ public async Task BookResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItems_ o.IsWishlisted = true; }) .Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); + var created = await CreateAsync($"/{ResourceEndpoint}", input); - try - { - var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={uniqueTitle}"); - owned.Items.Should().ContainSingle(b => b.Id == created.Id); - - // this is the WishlistController filter-probe, not a list-page UI filter (removed) - still real API behavior - var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={uniqueTitle}"); - wishlisted.Items.Should().ContainSingle(b => b.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={uniqueTitle}"); + owned.Items.Should().ContainSingle(b => b.Id == created.Id); + + // this is the WishlistController filter-probe, not a list-page UI filter (removed) - still real API behavior + var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={uniqueTitle}"); + wishlisted.Items.Should().ContainSingle(b => b.Id == created.Id); } /// @@ -114,13 +99,14 @@ public async Task BookResourceList_CustomImageUrlOverridesTheLinkedReferencesCov { Title = "Some Reference Title", TitleNormalized = "some reference title", - ExternalIds = new Dictionary { ["googlebooks"] = $"gb-{Guid.NewGuid():N}" }, + ExternalIds = new Dictionary { ["googlebooks"] = TestExternalId.New() }, ImageUrl = "https://example.com/reference-cover.jpg" }); + TrackDocument("book_reference", reference.Id); await Authenticate(); const string customImageUrl = "https://example.com/custom-cover.jpg"; - var created = await PostAsync($"/{ResourceEndpoint}", new BookDto + var created = await CreateAsync($"/{ResourceEndpoint}", new BookDto { Title = uniqueTitle, Author = "Some Author", @@ -128,17 +114,8 @@ public async Task BookResourceList_CustomImageUrlOverridesTheLinkedReferencesCov CustomImageUrl = customImageUrl }); - try - { - var list = await GetAsync>($"/{ResourceEndpoint}?search={uniqueTitle}"); - var item = list.Items.Should().ContainSingle(b => b.Id == created.Id).Subject; - item.ImageUrl.Should().Be(customImageUrl); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - var referenceCollection = scope.ServiceProvider.GetRequiredService().GetCollection("book_reference"); - await referenceCollection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, reference.Id), TestContext.Current.CancellationToken); - } + var list = await GetAsync>($"/{ResourceEndpoint}?search={uniqueTitle}"); + var item = list.Items.Should().ContainSingle(b => b.Id == created.Id).Subject; + item.ImageUrl.Should().Be(customImageUrl); } } diff --git a/test/WebApi.IntegrationTests/Resources/BookUnresolvedQueueTest.cs b/test/WebApi.IntegrationTests/Resources/BookUnresolvedQueueTest.cs index c03692a9..0b420e21 100644 --- a/test/WebApi.IntegrationTests/Resources/BookUnresolvedQueueTest.cs +++ b/test/WebApi.IntegrationTests/Resources/BookUnresolvedQueueTest.cs @@ -14,32 +14,24 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// against real MongoDB - the $group + $first accumulator translation is exactly the kind of driver-level /// behavior a mocked unit test can never validate (same rationale as ). /// -public class BookUnresolvedQueueTest(KestrelWebAppFactory factory) : IClassFixture> +public class BookUnresolvedQueueTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) { [Fact] public async Task FindDistinctUnresolvedTitleYearsAsync_CarriesATenantsAuthorAsTheCreator() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Unresolved Queue Test Book {Guid.NewGuid()}"; const string author = "Unresolved Queue Test Author"; - var bookA = await repository.CreateAsync(new BookModel { OwnerId = "unresolved-book-tenant-a", Title = title, Author = author, Year = 2003 }); - var bookB = await repository.CreateAsync(new BookModel { OwnerId = "unresolved-book-tenant-b", Title = title, Author = author, Year = 2003 }); + await CreateBookAsync(repository, new BookModel { OwnerId = "unresolved-book-tenant-a", Title = title, Author = author, Year = 2003 }); + await CreateBookAsync(repository, new BookModel { OwnerId = "unresolved-book-tenant-b", Title = title, Author = author, Year = 2003 }); - try - { - var unresolved = await repository.FindDistinctUnresolvedTitleYearsAsync(); + var unresolved = await repository.FindDistinctUnresolvedTitleYearsAsync(); - // both unlinked copies collapse into one queue entry, and it carries an author for search prefill - unresolved.Should().ContainSingle(p => p.Title == title && p.Year == 2003) - .Which.Creator.Should().Be(author); - } - finally - { - await repository.DeleteAsync(bookA.Id!, "unresolved-book-tenant-a"); - await repository.DeleteAsync(bookB.Id!, "unresolved-book-tenant-b"); - } + // both unlinked copies collapse into one queue entry, and it carries an author for search prefill + unresolved.Should().ContainSingle(p => p.Title == title && p.Year == 2003) + .Which.Creator.Should().Be(author); } /// @@ -50,23 +42,23 @@ public async Task FindDistinctUnresolvedTitleYearsAsync_CarriesATenantsAuthorAsT [Fact] public async Task FindDistinctUnresolvedTitleYearsAsync_CarriesATenantsIsbn() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Unresolved Queue Isbn Test Book {Guid.NewGuid()}"; const string isbn = "9780000000042"; - var book = await repository.CreateAsync(new BookModel { OwnerId = "unresolved-book-isbn-tenant", Title = title, Author = "Some Author", Year = 2003, Isbn = isbn }); + await CreateBookAsync(repository, new BookModel { OwnerId = "unresolved-book-isbn-tenant", Title = title, Author = "Some Author", Year = 2003, Isbn = isbn }); - try - { - var unresolved = await repository.FindDistinctUnresolvedTitleYearsAsync(); + var unresolved = await repository.FindDistinctUnresolvedTitleYearsAsync(); - unresolved.Should().ContainSingle(p => p.Title == title && p.Year == 2003) - .Which.Isbn.Should().Be(isbn); - } - finally - { - await repository.DeleteAsync(book.Id!, "unresolved-book-isbn-tenant"); - } + unresolved.Should().ContainSingle(p => p.Title == title && p.Year == 2003) + .Which.Isbn.Should().Be(isbn); + } + + private async Task CreateBookAsync(IBookRepository repository, BookModel book) + { + var created = await repository.CreateAsync(book); + TrackCleanup(() => repository.DeleteAsync(created.Id!, book.OwnerId)); + return created; } } diff --git a/test/WebApi.IntegrationTests/Resources/CarHistoryResourceTest.cs b/test/WebApi.IntegrationTests/Resources/CarHistoryResourceTest.cs index f8b3bf40..753403e3 100644 --- a/test/WebApi.IntegrationTests/Resources/CarHistoryResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/CarHistoryResourceTest.cs @@ -44,24 +44,17 @@ public async Task CarHistoryResourceFullCycle_IsOk() var carId = Guid.NewGuid().ToString(); var initialItems = await GetAsync>($"/{ResourceEndpoint}?CarId={carId}"); - var created = await PostAsync($"/{ResourceEndpoint}", NewEntry(carId)); + var created = await CreateAsync($"/{ResourceEndpoint}", NewEntry(carId)); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.Cost = 55.0; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created); - - var finalItems = await GetAsync>($"/{ResourceEndpoint}?CarId={carId}"); - finalItems.TotalCount.Should().BeGreaterThan(initialItems.TotalCount); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + created.Cost = 55.0; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}?CarId={carId}"); + finalItems.TotalCount.Should().BeGreaterThan(initialItems.TotalCount); } [Fact] @@ -71,20 +64,12 @@ public async Task CarHistoryResourceFilter_ByCarId_OnlyReturnsThatCarsEntries_Is var carId = Guid.NewGuid().ToString(); var otherCarId = Guid.NewGuid().ToString(); - var created = await PostAsync($"/{ResourceEndpoint}", NewEntry(carId)); - var otherCreated = await PostAsync($"/{ResourceEndpoint}", NewEntry(otherCarId)); - - try - { - var results = await GetAsync>($"/{ResourceEndpoint}?CarId={carId}"); - results.Items.Should().ContainSingle(x => x.Id == created.Id); - results.Items.Should().NotContain(x => x.Id == otherCreated.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{otherCreated.Id}"); - } + var created = await CreateAsync($"/{ResourceEndpoint}", NewEntry(carId)); + var otherCreated = await CreateAsync($"/{ResourceEndpoint}", NewEntry(otherCarId)); + + var results = await GetAsync>($"/{ResourceEndpoint}?CarId={carId}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); + results.Items.Should().NotContain(x => x.Id == otherCreated.Id); } /// @@ -100,16 +85,9 @@ public async Task CarHistoryResourceFilter_ByCarIdAndSearch_DoesNotThrow_IsOk() var description = Guid.NewGuid().ToString(); var entry = NewEntry(carId); entry.Description = description; - var created = await PostAsync($"/{ResourceEndpoint}", entry); - - try - { - var results = await GetAsync>($"/{ResourceEndpoint}?CarId={carId}&search={description}"); - results.Items.Should().ContainSingle(x => x.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var created = await CreateAsync($"/{ResourceEndpoint}", entry); + + var results = await GetAsync>($"/{ResourceEndpoint}?CarId={carId}&search={description}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); } } diff --git a/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs b/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs index 32646e1c..04375e5d 100644 --- a/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -39,26 +40,19 @@ public async Task CarResourceFullCycle_IsOk() o.ImageUrl = f.Internet.Url(); }) .Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); + var created = await CreateAsync($"/{ResourceEndpoint}", input); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.Name = "New shiny name"; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created); - - var finalItems = await GetAsync>($"/{ResourceEndpoint}"); - var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); - firstItem.Should().NotBeNull(); - firstItem.Name.Should().Be(updated.Name); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + created.Name = "New shiny name"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Name.Should().Be(updated.Name); } /// @@ -71,18 +65,11 @@ public async Task CarResourceSearch_FiltersByName_IsOk() { await Authenticate(); - var name = System.Guid.NewGuid().ToString(); - var created = await PostAsync($"/{ResourceEndpoint}", new CarDto { Name = name }); - - try - { - var results = await GetAsync>($"/{ResourceEndpoint}?search={name}"); - results.Items.Should().ContainSingle(x => x.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var name = Guid.NewGuid().ToString(); + var created = await CreateAsync($"/{ResourceEndpoint}", new CarDto { Name = name }); + + var results = await GetAsync>($"/{ResourceEndpoint}?search={name}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); } [Fact] @@ -103,20 +90,13 @@ public async Task CarResourceMetrics_ReturnsEmptyMetrics_ForACarWithNoHistoryYet { await Authenticate(); - var created = await PostAsync($"/{ResourceEndpoint}", new CarDto { Name = System.Guid.NewGuid().ToString() }); - - try - { - var metrics = await GetAsync($"/{ResourceEndpoint}/{created.Id}/metrics"); - metrics.FuelConsumption.Should().BeEmpty(); - metrics.ElectricConsumption.Should().BeEmpty(); - metrics.CostHistory.Should().BeEmpty(); - metrics.MileageWarnings.Should().BeEmpty(); - metrics.LastRecords.Should().BeEmpty(); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var created = await CreateAsync($"/{ResourceEndpoint}", new CarDto { Name = Guid.NewGuid().ToString() }); + + var metrics = await GetAsync($"/{ResourceEndpoint}/{created.Id}/metrics"); + metrics.FuelConsumption.Should().BeEmpty(); + metrics.ElectricConsumption.Should().BeEmpty(); + metrics.CostHistory.Should().BeEmpty(); + metrics.MileageWarnings.Should().BeEmpty(); + metrics.LastRecords.Should().BeEmpty(); } } diff --git a/test/WebApi.IntegrationTests/Resources/CollectibleResourceTest.cs b/test/WebApi.IntegrationTests/Resources/CollectibleResourceTest.cs index 9c3d6ae1..2cebb386 100644 --- a/test/WebApi.IntegrationTests/Resources/CollectibleResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/CollectibleResourceTest.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -36,26 +37,19 @@ public async Task CollectibleResourceFullCycle_IsOk() o.ImageUrl = f.Internet.Url(); }) .Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); + var created = await CreateAsync($"/{ResourceEndpoint}", input); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.Title = "New shiny title"; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created); + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); - var finalItems = await GetAsync>($"/{ResourceEndpoint}"); - var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); - firstItem.Should().NotBeNull(); - firstItem.Title.Should().Be(updated.Title); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); } [Fact] @@ -63,18 +57,11 @@ public async Task CollectibleResourceSearch_FiltersByTitle_IsOk() { await Authenticate(); - var title = System.Guid.NewGuid().ToString(); - var created = await PostAsync($"/{ResourceEndpoint}", new CollectibleDto { Title = title }); + var title = Guid.NewGuid().ToString(); + var created = await CreateAsync($"/{ResourceEndpoint}", new CollectibleDto { Title = title }); - try - { - var results = await GetAsync>($"/{ResourceEndpoint}?search={title}"); - results.Items.Should().ContainSingle(x => x.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var results = await GetAsync>($"/{ResourceEndpoint}?search={title}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); } [Fact] @@ -82,33 +69,24 @@ public async Task CollectibleResourceOwnedAndFavoriteFilters_OnlyReturnMatchingI { await Authenticate(); - var title = $"OwnedTarget-{System.Guid.NewGuid():N}"; - var owned = await PostAsync($"/{ResourceEndpoint}", new CollectibleDto + var title = $"OwnedTarget-{Guid.NewGuid():N}"; + var owned = await CreateAsync($"/{ResourceEndpoint}", new CollectibleDto { Title = title, // "owned" is derived from having at least one owned version, not a stored flag OwnedVersions = [new OwnedVersionDto { CopyType = CopyType.Physical, Price = 42.50m, ProductName = "Ultimate Collector's Edition" }] }); - var favorite = await PostAsync($"/{ResourceEndpoint}", new CollectibleDto { Title = title, IsFavorite = true }); - var plain = await PostAsync($"/{ResourceEndpoint}", new CollectibleDto { Title = title }); + var favorite = await CreateAsync($"/{ResourceEndpoint}", new CollectibleDto { Title = title, IsFavorite = true }); + var plain = await CreateAsync($"/{ResourceEndpoint}", new CollectibleDto { Title = title }); - try - { - var ownedResults = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); - ownedResults.Items.Should().ContainSingle(x => x.Id == owned.Id); - ownedResults.Items.Should().NotContain(x => x.Id == plain.Id); - // the version's fields must survive the full DTO -> model -> BSON round trip, including ProductName - ownedResults.Items.Single(x => x.Id == owned.Id).OwnedVersions.Should().BeEquivalentTo(owned.OwnedVersions); + var ownedResults = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); + ownedResults.Items.Should().ContainSingle(x => x.Id == owned.Id); + ownedResults.Items.Should().NotContain(x => x.Id == plain.Id); + // the version's fields must survive the full DTO -> model -> BSON round trip, including ProductName + ownedResults.Items.Single(x => x.Id == owned.Id).OwnedVersions.Should().BeEquivalentTo(owned.OwnedVersions); - var favoriteResults = await GetAsync>($"/{ResourceEndpoint}?IsFavorite=true&search={title}"); - favoriteResults.Items.Should().ContainSingle(x => x.Id == favorite.Id); - favoriteResults.Items.Should().NotContain(x => x.Id == plain.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{owned.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{favorite.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{plain.Id}"); - } + var favoriteResults = await GetAsync>($"/{ResourceEndpoint}?IsFavorite=true&search={title}"); + favoriteResults.Items.Should().ContainSingle(x => x.Id == favorite.Id); + favoriteResults.Items.Should().NotContain(x => x.Id == plain.Id); } } diff --git a/test/WebApi.IntegrationTests/Resources/DatabaseTestBase.cs b/test/WebApi.IntegrationTests/Resources/DatabaseTestBase.cs new file mode 100644 index 00000000..f325a6a0 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/DatabaseTestBase.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Bson; +using MongoDB.Driver; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Base for every test in this suite that writes to the shared test database, holding the one thing all of +/// them need: a registry of "undo this" actions that runs when the test ends, whether it passed or failed. +/// +/// The suite runs against a real, long-lived MongoDB rather than a per-test throwaway one, so a test that +/// leaves documents behind isn't just untidy - the natural-key and external-id uniqueness indexes +/// (scripts/mongodb-create-index.js) mean yesterday's leftover makes today's run fail with a +/// duplicate-key error. Leaving the database as we found it is what keeps the suite re-runnable. +/// +/// +/// Registration replaces the per-test try/finally this suite used to write. A finally only +/// covers what was created before the try opened, so the common "create two items, then open the +/// try" shape leaked its fixtures whenever the second create failed. Registering at the moment of creation +/// has no such gap, and it removes the same cleanup block repeated in ~40 files. +/// +/// +public abstract class DatabaseTestBase(KestrelWebAppFactory factory) + : IClassFixture>, IAsyncLifetime +{ + private readonly List> _cleanups = []; + + /// + /// Exposes the factory to subclasses that need a DI scope (e.g. to seed data directly through a + /// repository) - avoids a second, redundant capture of the same constructor parameter as its own field. + /// + protected KestrelWebAppFactory Factory => factory; + + /// + /// Registers an action that undoes something this test created. Call it as soon as the thing exists, + /// not at the end of the test. + /// + protected void TrackCleanup(Func cleanup) => _cleanups.Add(cleanup); + + /// + /// Registers a raw MongoDB document for deletion by _id - for collections reached through a + /// purpose-built repository with no delete method of its own (the owner-less reference collections, + /// lease, background_job). + /// + /// The filter is built over with the _id explicitly converted to an + /// when the string is one, because getting this wrong is silent. An earlier + /// version of this cleanup filtered a mapped entity collection by the string field name "_id", + /// which compares a BSON string against a document whose _id is an ObjectId: it matches nothing, + /// deletes nothing, and reports success. That is exactly how 65 stray Export Test Actor + /// documents accumulated while the tests that created them kept passing. Collections whose id is a + /// genuine string (lease, background_job) simply don't parse as an ObjectId and are + /// filtered as strings, so one helper covers both. + /// + /// + protected void TrackDocument(string collectionName, string? id) + { + if (string.IsNullOrEmpty(id)) return; + + TrackCleanup(async () => + { + var collection = factory.Services.GetRequiredService().GetCollection(collectionName); + var documentId = ObjectId.TryParse(id, out var objectId) ? (BsonValue)objectId : id; + await collection.DeleteOneAsync(Builders.Filter.Eq("_id", documentId), CancellationToken.None); + }); + } + + /// + /// Registers every document matching a filter for deletion - for the owner-scoped singletons that have + /// no id a test can hold onto (user_preference is written by the server on the caller's behalf, + /// so the test only ever knows the owner it belongs to). + /// + protected void TrackDocumentsWhere(string collectionName, FilterDefinition filter) + { + TrackCleanup(async () => + { + var collection = factory.Services.GetRequiredService().GetCollection(collectionName); + await collection.DeleteManyAsync(filter, CancellationToken.None); + }); + } + + public virtual ValueTask InitializeAsync() => ValueTask.CompletedTask; + + /// + /// Runs every registered cleanup in reverse order of registration, so a child is removed before the + /// parent it references. + /// + /// The list is drained rather than indexed over a cached count, because a cleanup may itself register + /// more: TrackResourcesMatching can only discover what a bulk import created by querying for it + /// at cleanup time, and then registers each id it found. Draining runs those too, still last-in-first-out. + /// + /// + /// Two further deliberate choices. Cleanups run under , never + /// TestContext.Current.CancellationToken: that token is cancelled exactly when a test times out + /// or the run is interrupted, which is precisely when leftovers are most likely and cleanup matters + /// most. And one failing cleanup never skips the rest - failures are collected and reported together, + /// so a broken cleanup surfaces as a test error instead of quietly leaving data behind. + /// + /// + public virtual async ValueTask DisposeAsync() + { + List? failures = null; + + while (_cleanups.Count > 0) + { + var cleanup = _cleanups[^1]; + _cleanups.RemoveAt(_cleanups.Count - 1); + + try + { + await cleanup(); + } + catch (Exception exception) + { + (failures ??= []).Add(exception); + } + } + + GC.SuppressFinalize(this); + + if (failures is not null) + { + throw new AggregateException( + $"{failures.Count} test cleanup action(s) failed - the test database may still hold data created by this test.", + failures); + } + } +} diff --git a/test/WebApi.IntegrationTests/Resources/EpisodeRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/EpisodeRepositoryTest.cs index 3fc7b330..ca91f9d1 100644 --- a/test/WebApi.IntegrationTests/Resources/EpisodeRepositoryTest.cs +++ b/test/WebApi.IntegrationTests/Resources/EpisodeRepositoryTest.cs @@ -16,44 +16,34 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// discarding non-current shows in memory - verified against a real database, not mocks, since it's a /// hand-written Mongo filter (the class of code that has hidden bugs before, see docs/code-quality-findings.md). /// -public class EpisodeRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +public class EpisodeRepositoryTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) { [Fact] public async Task FindByShowIdsAsync_ReturnsOnlyTheRequestedShowsEpisodes_ScopedToTheOwner() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var ownerId = $"owner-{Guid.NewGuid()}"; var otherOwnerId = $"owner-{Guid.NewGuid()}"; var wantedShowId = $"show-{Guid.NewGuid()}"; var otherShowId = $"show-{Guid.NewGuid()}"; - var wanted = await repository.CreateAsync(NewEpisode(ownerId, wantedShowId, 1, 1)); - var wantedSecond = await repository.CreateAsync(NewEpisode(ownerId, wantedShowId, 1, 2)); + var wanted = await CreateEpisodeAsync(repository, ownerId, wantedShowId, 1, 1); + var wantedSecond = await CreateEpisodeAsync(repository, ownerId, wantedShowId, 1, 2); // same owner, a show that was NOT requested - must be excluded - var unrelatedShow = await repository.CreateAsync(NewEpisode(ownerId, otherShowId, 1, 1)); + await CreateEpisodeAsync(repository, ownerId, otherShowId, 1, 1); // a different owner tracking the very same show id - must be excluded (owner scoping) - var otherOwner = await repository.CreateAsync(NewEpisode(otherOwnerId, wantedShowId, 1, 1)); + await CreateEpisodeAsync(repository, otherOwnerId, wantedShowId, 1, 1); - try - { - var found = await repository.FindByShowIdsAsync(ownerId, [wantedShowId]); + var found = await repository.FindByShowIdsAsync(ownerId, [wantedShowId]); - found.Select(e => e.Id).Should().BeEquivalentTo([wanted.Id, wantedSecond.Id]); - } - finally - { - foreach (var id in new[] { wanted.Id!, wantedSecond.Id!, unrelatedShow.Id!, otherOwner.Id! }) - { - await repository.DeleteAsync(id, id == otherOwner.Id ? otherOwnerId : ownerId); - } - } + found.Select(e => e.Id).Should().BeEquivalentTo([wanted.Id, wantedSecond.Id]); } [Fact] public async Task FindByShowIdsAsync_ReturnsEmpty_WhenNoShowIdsAreGiven() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var found = await repository.FindByShowIdsAsync($"owner-{Guid.NewGuid()}", []); @@ -61,12 +51,17 @@ public async Task FindByShowIdsAsync_ReturnsEmpty_WhenNoShowIdsAreGiven() found.Should().BeEmpty(); } - private static EpisodeModel NewEpisode(string ownerId, string showId, int season, int episode) => new() + private async Task CreateEpisodeAsync(IEpisodeRepository repository, string ownerId, string showId, int season, int episode) { - OwnerId = ownerId, - TvShowId = showId, - SeasonNumber = season, - EpisodeNumber = episode, - WatchedAt = DateOnly.FromDateTime(DateTime.Today) - }; + var created = await repository.CreateAsync(new EpisodeModel + { + OwnerId = ownerId, + TvShowId = showId, + SeasonNumber = season, + EpisodeNumber = episode, + WatchedAt = DateOnly.FromDateTime(DateTime.Today) + }); + TrackCleanup(() => repository.DeleteAsync(created.Id!, ownerId)); + return created; + } } diff --git a/test/WebApi.IntegrationTests/Resources/ExploreResourceTest.cs b/test/WebApi.IntegrationTests/Resources/ExploreResourceTest.cs index 352785d4..14cc2b75 100644 --- a/test/WebApi.IntegrationTests/Resources/ExploreResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/ExploreResourceTest.cs @@ -33,8 +33,8 @@ public async Task Dismiss_AndUndo_AreIdempotentAndReturnNoContent() await Authenticate(); // dismissing twice is idempotent (both 204); undo also 204 - none of this touches a provider - await PostNoContentAsync("/api/explore/Movie/dismiss/999999", new { }); - await PostNoContentAsync("/api/explore/Movie/dismiss/999999", new { }); + await DismissAsync("Movie", "999999"); + await DismissAsync("Movie", "999999"); await DeleteAsync("/api/explore/Movie/dismiss/999999"); } @@ -45,9 +45,20 @@ public async Task Dismiss_KeepsEachDomainsProviderIdSpaceSeparate() // the same bare number means a TMDB movie and a RAWG game - two different titles. The unique key // carries the provider, so both inserts succeed and undoing one leaves the other in place. - await PostNoContentAsync("/api/explore/Movie/dismiss/424242", new { }); - await PostNoContentAsync("/api/explore/VideoGame/dismiss/424242", new { }); + await DismissAsync("Movie", "424242"); + await DismissAsync("VideoGame", "424242"); await DeleteAsync("/api/explore/Movie/dismiss/424242"); await DeleteAsync("/api/explore/VideoGame/dismiss/424242"); } + + /// + /// Dismissing and registering the undo together. The undo is also what each test asserts on, but a + /// dismissal recorded before an assertion fails would otherwise stay in explore_dismissal and + /// silently hide that title from the owner's real Explore feed. + /// + private async Task DismissAsync(string itemType, string externalId) + { + await PostNoContentAsync($"/api/explore/{itemType}/dismiss/{externalId}", new { }); + TrackResource($"/api/explore/{itemType}/dismiss", externalId); + } } diff --git a/test/WebApi.IntegrationTests/Resources/GearResourceTest.cs b/test/WebApi.IntegrationTests/Resources/GearResourceTest.cs index a8c8b41d..17a91a33 100644 --- a/test/WebApi.IntegrationTests/Resources/GearResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/GearResourceTest.cs @@ -38,26 +38,19 @@ public async Task GearResourceFullCycle_IsOk() o.ImageUrl = f.Internet.Url(); }) .Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); + var created = await CreateAsync($"/{ResourceEndpoint}", input); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.Title = "New shiny title"; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created); - - var finalItems = await GetAsync>($"/{ResourceEndpoint}"); - var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); - firstItem.Should().NotBeNull(); - firstItem.Title.Should().Be(updated.Title); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); } [Fact] @@ -65,18 +58,11 @@ public async Task GearResourceSearch_FiltersByTitle_IsOk() { await Authenticate(); - var title = System.Guid.NewGuid().ToString(); - var created = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); + var title = Guid.NewGuid().ToString(); + var created = await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); - try - { - var results = await GetAsync>($"/{ResourceEndpoint}?search={title}"); - results.Items.Should().ContainSingle(x => x.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var results = await GetAsync>($"/{ResourceEndpoint}?search={title}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); } [Fact] @@ -84,23 +70,14 @@ public async Task GearResourceCategoryFilter_OnlyReturnsMatchingItems_IsOk() { await Authenticate(); - var title = $"CategoryTarget-{System.Guid.NewGuid():N}"; - var electronics = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Electronics" }); - var camping = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Camping" }); - var uncategorized = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); + var title = $"CategoryTarget-{Guid.NewGuid():N}"; + var electronics = await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Electronics" }); + var camping = await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Camping" }); + var uncategorized = await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); - try - { - var results = await GetAsync>($"/{ResourceEndpoint}?Category=Electronics&search={title}"); - results.Items.Should().ContainSingle(x => x.Id == electronics.Id); - results.Items.Should().NotContain(x => x.Id == camping.Id || x.Id == uncategorized.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{electronics.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{camping.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{uncategorized.Id}"); - } + var results = await GetAsync>($"/{ResourceEndpoint}?Category=Electronics&search={title}"); + results.Items.Should().ContainSingle(x => x.Id == electronics.Id); + results.Items.Should().NotContain(x => x.Id == camping.Id || x.Id == uncategorized.Id); } [Fact] @@ -108,29 +85,19 @@ public async Task GearCategoriesEndpoint_ReturnsDistinctSortedCategories_IsOk() { await Authenticate(); - var title = $"CategoryList-{System.Guid.NewGuid():N}"; - var first = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Zetatools" }); - var second = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Anvils" }); + var title = $"CategoryList-{Guid.NewGuid():N}"; + await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Zetatools" }); + await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Anvils" }); // same category twice must appear only once, and an unset category must never appear at all - var duplicate = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Zetatools" }); - var uncategorized = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); - - try - { - var categories = await GetAsync>($"/{ResourceEndpoint}/categories"); - categories.Should().Contain(["Anvils", "Zetatools"]); - categories.Should().OnlyHaveUniqueItems(); - var anvilsIndex = categories.IndexOf("Anvils"); - var zetatoolsIndex = categories.IndexOf("Zetatools"); - anvilsIndex.Should().BeLessThan(zetatoolsIndex, "results are sorted alphabetically"); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{first.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{second.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{duplicate.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{uncategorized.Id}"); - } + await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Zetatools" }); + await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); + + var categories = await GetAsync>($"/{ResourceEndpoint}/categories"); + categories.Should().Contain(["Anvils", "Zetatools"]); + categories.Should().OnlyHaveUniqueItems(); + var anvilsIndex = categories.IndexOf("Anvils"); + var zetatoolsIndex = categories.IndexOf("Zetatools"); + anvilsIndex.Should().BeLessThan(zetatoolsIndex, "results are sorted alphabetically"); } [Fact] @@ -138,33 +105,24 @@ public async Task GearResourceBoughtSort_OrdersByMostRecentlyAcquiredCopyDescend { await Authenticate(); - var title = $"BoughtSort-{System.Guid.NewGuid():N}"; - var olderPurchase = await PostAsync($"/{ResourceEndpoint}", new GearDto + var title = $"BoughtSort-{Guid.NewGuid():N}"; + var olderPurchase = await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title, OwnedVersions = [new OwnedVersionDto { CopyType = CopyType.Physical, AcquiredAt = new DateOnly(2020, 1, 1) }] }); - var recentPurchase = await PostAsync($"/{ResourceEndpoint}", new GearDto + var recentPurchase = await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title, OwnedVersions = [new OwnedVersionDto { CopyType = CopyType.Physical, AcquiredAt = new DateOnly(2024, 6, 1) }] }); - var neverOwned = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); + var neverOwned = await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); - try - { - var results = await GetAsync>($"/{ResourceEndpoint}?sort=bought&search={title}"); - var ids = results.Items.Select(x => x.Id).ToList(); - ids.IndexOf(recentPurchase.Id).Should().BeLessThan(ids.IndexOf(olderPurchase.Id), - "the most recently acquired copy sorts first"); - ids.Should().Contain(neverOwned.Id, "an unset acquisition date still falls back to the newest-first tie-break, it isn't excluded"); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{olderPurchase.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{recentPurchase.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{neverOwned.Id}"); - } + var results = await GetAsync>($"/{ResourceEndpoint}?sort=bought&search={title}"); + var ids = results.Items.Select(x => x.Id).ToList(); + ids.IndexOf(recentPurchase.Id).Should().BeLessThan(ids.IndexOf(olderPurchase.Id), + "the most recently acquired copy sorts first"); + ids.Should().Contain(neverOwned.Id, "an unset acquisition date still falls back to the newest-first tie-break, it isn't excluded"); } [Fact] @@ -172,33 +130,24 @@ public async Task GearResourceOwnedAndFavoriteFilters_OnlyReturnMatchingItems_Is { await Authenticate(); - var title = $"OwnedTarget-{System.Guid.NewGuid():N}"; - var owned = await PostAsync($"/{ResourceEndpoint}", new GearDto + var title = $"OwnedTarget-{Guid.NewGuid():N}"; + var owned = await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title, // "owned" is derived from having at least one owned version, not a stored flag OwnedVersions = [new OwnedVersionDto { CopyType = CopyType.Physical, Price = 199.99m, ProductName = "Limited edition" }] }); - var favorite = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title, IsFavorite = true }); - var plain = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); - - try - { - var ownedResults = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); - ownedResults.Items.Should().ContainSingle(x => x.Id == owned.Id); - ownedResults.Items.Should().NotContain(x => x.Id == plain.Id); - // the version's fields must survive the full DTO -> model -> BSON round trip, including ProductName - ownedResults.Items.Single(x => x.Id == owned.Id).OwnedVersions.Should().BeEquivalentTo(owned.OwnedVersions); - - var favoriteResults = await GetAsync>($"/{ResourceEndpoint}?IsFavorite=true&search={title}"); - favoriteResults.Items.Should().ContainSingle(x => x.Id == favorite.Id); - favoriteResults.Items.Should().NotContain(x => x.Id == plain.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{owned.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{favorite.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{plain.Id}"); - } + var favorite = await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title, IsFavorite = true }); + var plain = await CreateAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); + + var ownedResults = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); + ownedResults.Items.Should().ContainSingle(x => x.Id == owned.Id); + ownedResults.Items.Should().NotContain(x => x.Id == plain.Id); + // the version's fields must survive the full DTO -> model -> BSON round trip, including ProductName + ownedResults.Items.Single(x => x.Id == owned.Id).OwnedVersions.Should().BeEquivalentTo(owned.OwnedVersions); + + var favoriteResults = await GetAsync>($"/{ResourceEndpoint}?IsFavorite=true&search={title}"); + favoriteResults.Items.Should().ContainSingle(x => x.Id == favorite.Id); + favoriteResults.Items.Should().NotContain(x => x.Id == plain.Id); } } diff --git a/test/WebApi.IntegrationTests/Resources/GenericImportResourceTest.cs b/test/WebApi.IntegrationTests/Resources/GenericImportResourceTest.cs index 6363048b..2376c884 100644 --- a/test/WebApi.IntegrationTests/Resources/GenericImportResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/GenericImportResourceTest.cs @@ -39,94 +39,77 @@ public async Task PreviewThenCommit_CreatesOneItemPerTypeFromTheTypeColumn_AndDe videoGameRow.SuggestedMediaType.Should().Be(ImportMediaType.VideoGame); videoGameRow.Platform.Should().Be(GenericImportFixtureCsvBuilder.VideoGamePlatform); - try - { - // a video game row with no platform must be rejected before anything is persisted - var invalidPlatformRequest = new GenericImportCommitRequestDto { Items = [ToCommitItem(videoGameRow, ImportMediaType.VideoGame, platform: null)] }; - await PostAsync("/api/import/generic/commit", invalidPlatformRequest, HttpStatusCode.BadRequest); - - // a row with no media type chosen must also be rejected before anything is persisted - var noTypeRequest = new GenericImportCommitRequestDto { Items = [ToCommitItem(bookRow, mediaType: null)] }; - await PostAsync("/api/import/generic/commit", noTypeRequest, HttpStatusCode.BadRequest); - - var commitRequest = new GenericImportCommitRequestDto - { - Items = - [ - ToCommitItem(bookRow, ImportMediaType.Book, author: bookRow.Author, year: 1969), - ToCommitItem(movieRow, ImportMediaType.Movie), - ToCommitItem(videoGameRow, ImportMediaType.VideoGame, platform: GenericImportFixtureCsvBuilder.VideoGamePlatform) - ] - }; - - var commitResult = await PostAsync("/api/import/generic/commit", commitRequest); - commitResult.BooksCreated.Should().Be(1); - commitResult.MoviesCreated.Should().Be(1); - commitResult.VideoGamesCreated.Should().Be(1); - commitResult.RowsImported.Should().Be(3); - commitResult.SkippedRowTitles.Should().BeEmpty(); - - var books = await GetAsync>($"/api/books?search={Uri.EscapeDataString(GenericImportFixtureCsvBuilder.BookTitle)}"); - var book = books.Items.Should().ContainSingle().Subject; - book.Year.Should().Be(1969); - book.Author.Should().Be(GenericImportFixtureCsvBuilder.BookAuthor); - book.OwnedVersions.Should().ContainSingle(); - book.OwnedVersions[0].Price.Should().Be(12.50m); - // Vendor field comes from the Vendor column; the Reference carries the Website column (independent). - book.OwnedVersions[0].Vendor.Should().Be(GenericImportFixtureCsvBuilder.Vendor); - book.OwnedVersions[0].Reference.Should().Contain(GenericImportFixtureCsvBuilder.BookOrderId); - book.OwnedVersions[0].Reference.Should().Contain(GenericImportFixtureCsvBuilder.BookWebsite); - // the condition is preserved on the owned copy's Product field rather than dropped - book.OwnedVersions[0].ProductName.Should().Be(GenericImportFixtureCsvBuilder.BookCondition); - book.Notes.Should().Be($"Title from {GenericImportFixtureCsvBuilder.Vendor}: {GenericImportFixtureCsvBuilder.BookTitle}"); - - var movies = await GetAsync>($"/api/movies?search={Uri.EscapeDataString(GenericImportFixtureCsvBuilder.MovieTitle)}"); - movies.Items.Should().ContainSingle().Which.OwnedVersions.Should().ContainSingle(); - - var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericImportFixtureCsvBuilder.VideoGameTitle)}"); - var videoGame = videoGames.Items.Should().ContainSingle().Subject; - videoGame.Platforms.Should().ContainSingle(); - videoGame.Platforms[0].Platform.Should().Be(GenericImportFixtureCsvBuilder.VideoGamePlatform); - videoGame.Platforms[0].Reference.Should().Contain(GenericImportFixtureCsvBuilder.VideoGameOrderId); - - // re-preview after commit: every just-imported order line must now be flagged as already imported - var secondPreview = await PostFileAsync>("/api/import/generic/preview", "file", csv, "orders.csv"); - secondPreview.Should().Contain(r => r.Title == GenericImportFixtureCsvBuilder.BookTitle && r.AlreadyImported); - secondPreview.Should().Contain(r => r.Title == GenericImportFixtureCsvBuilder.MovieTitle && r.AlreadyImported); - secondPreview.Should().Contain(r => r.Title == GenericImportFixtureCsvBuilder.VideoGameTitle && r.AlreadyImported); - - // committing the exact same rows again must not duplicate anything, and must reconcile as all-skipped - var secondCommitResult = await PostAsync("/api/import/generic/commit", commitRequest); - secondCommitResult.BooksCreated.Should().Be(0); - secondCommitResult.BooksSkipped.Should().Be(1); - secondCommitResult.MoviesSkipped.Should().Be(1); - secondCommitResult.VideoGamesSkipped.Should().Be(1); - secondCommitResult.RowsImported.Should().Be(0); - secondCommitResult.SkippedRowTitles.Should().HaveCount(3); - - var booksAfterReimport = await GetAsync>($"/api/books?search={Uri.EscapeDataString(GenericImportFixtureCsvBuilder.BookTitle)}"); - booksAfterReimport.Items.Should().ContainSingle().Which.OwnedVersions.Should().ContainSingle(); - } - finally - { - await CleanUpAsync($"/api/books", GenericImportFixtureCsvBuilder.BookTitle, (BookDto b) => b.Id); - await CleanUpAsync($"/api/movies", GenericImportFixtureCsvBuilder.MovieTitle, (MovieDto m) => m.Id); - await CleanUpAsync($"/api/video-games", GenericImportFixtureCsvBuilder.VideoGameTitle, (VideoGameDto g) => g.Id); - } - } + // the commit creates items whose ids this test never sees, so cleanup is keyed on the fixture's own + // synthetic titles - and registered before the commit, so a partial commit is cleaned up too + TrackResourcesMatching("/api/books", GenericImportFixtureCsvBuilder.BookTitle); + TrackResourcesMatching("/api/movies", GenericImportFixtureCsvBuilder.MovieTitle); + TrackResourcesMatching("/api/video-games", GenericImportFixtureCsvBuilder.VideoGameTitle); - private async Task CleanUpAsync(string route, string title, Func getId) - where TDto : class - { - var page = await GetAsync>($"{route}?search={Uri.EscapeDataString(title)}"); - foreach (var item in page.Items) + // a video game row with no platform must be rejected before anything is persisted + var invalidPlatformRequest = new GenericImportCommitRequestDto { Items = [ToCommitItem(videoGameRow, ImportMediaType.VideoGame, platform: null)] }; + await PostAsync("/api/import/generic/commit", invalidPlatformRequest, HttpStatusCode.BadRequest); + + // a row with no media type chosen must also be rejected before anything is persisted + var noTypeRequest = new GenericImportCommitRequestDto { Items = [ToCommitItem(bookRow, mediaType: null)] }; + await PostAsync("/api/import/generic/commit", noTypeRequest, HttpStatusCode.BadRequest); + + var commitRequest = new GenericImportCommitRequestDto { - var id = getId(item); - if (id is not null) - { - await DeleteAsync($"{route}/{id}"); - } - } + Items = + [ + ToCommitItem(bookRow, ImportMediaType.Book, author: bookRow.Author, year: 1969), + ToCommitItem(movieRow, ImportMediaType.Movie), + ToCommitItem(videoGameRow, ImportMediaType.VideoGame, platform: GenericImportFixtureCsvBuilder.VideoGamePlatform) + ] + }; + + var commitResult = await PostAsync("/api/import/generic/commit", commitRequest); + commitResult.BooksCreated.Should().Be(1); + commitResult.MoviesCreated.Should().Be(1); + commitResult.VideoGamesCreated.Should().Be(1); + commitResult.RowsImported.Should().Be(3); + commitResult.SkippedRowTitles.Should().BeEmpty(); + + var books = await GetAsync>($"/api/books?search={Uri.EscapeDataString(GenericImportFixtureCsvBuilder.BookTitle)}"); + var book = books.Items.Should().ContainSingle().Subject; + book.Year.Should().Be(1969); + book.Author.Should().Be(GenericImportFixtureCsvBuilder.BookAuthor); + book.OwnedVersions.Should().ContainSingle(); + book.OwnedVersions[0].Price.Should().Be(12.50m); + // Vendor field comes from the Vendor column; the Reference carries the Website column (independent). + book.OwnedVersions[0].Vendor.Should().Be(GenericImportFixtureCsvBuilder.Vendor); + book.OwnedVersions[0].Reference.Should().Contain(GenericImportFixtureCsvBuilder.BookOrderId); + book.OwnedVersions[0].Reference.Should().Contain(GenericImportFixtureCsvBuilder.BookWebsite); + // the condition is preserved on the owned copy's Product field rather than dropped + book.OwnedVersions[0].ProductName.Should().Be(GenericImportFixtureCsvBuilder.BookCondition); + book.Notes.Should().Be($"Title from {GenericImportFixtureCsvBuilder.Vendor}: {GenericImportFixtureCsvBuilder.BookTitle}"); + + var movies = await GetAsync>($"/api/movies?search={Uri.EscapeDataString(GenericImportFixtureCsvBuilder.MovieTitle)}"); + movies.Items.Should().ContainSingle().Which.OwnedVersions.Should().ContainSingle(); + + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericImportFixtureCsvBuilder.VideoGameTitle)}"); + var videoGame = videoGames.Items.Should().ContainSingle().Subject; + videoGame.Platforms.Should().ContainSingle(); + videoGame.Platforms[0].Platform.Should().Be(GenericImportFixtureCsvBuilder.VideoGamePlatform); + videoGame.Platforms[0].Reference.Should().Contain(GenericImportFixtureCsvBuilder.VideoGameOrderId); + + // re-preview after commit: every just-imported order line must now be flagged as already imported + var secondPreview = await PostFileAsync>("/api/import/generic/preview", "file", csv, "orders.csv"); + secondPreview.Should().Contain(r => r.Title == GenericImportFixtureCsvBuilder.BookTitle && r.AlreadyImported); + secondPreview.Should().Contain(r => r.Title == GenericImportFixtureCsvBuilder.MovieTitle && r.AlreadyImported); + secondPreview.Should().Contain(r => r.Title == GenericImportFixtureCsvBuilder.VideoGameTitle && r.AlreadyImported); + + // committing the exact same rows again must not duplicate anything, and must reconcile as all-skipped + var secondCommitResult = await PostAsync("/api/import/generic/commit", commitRequest); + secondCommitResult.BooksCreated.Should().Be(0); + secondCommitResult.BooksSkipped.Should().Be(1); + secondCommitResult.MoviesSkipped.Should().Be(1); + secondCommitResult.VideoGamesSkipped.Should().Be(1); + secondCommitResult.RowsImported.Should().Be(0); + secondCommitResult.SkippedRowTitles.Should().HaveCount(3); + + var booksAfterReimport = await GetAsync>($"/api/books?search={Uri.EscapeDataString(GenericImportFixtureCsvBuilder.BookTitle)}"); + booksAfterReimport.Items.Should().ContainSingle().Which.OwnedVersions.Should().ContainSingle(); } private static GenericImportCommitItemDto ToCommitItem(GenericImportPreviewRowDto row, ImportMediaType? mediaType, int? year = null, string? author = null, string? platform = null) => new() diff --git a/test/WebApi.IntegrationTests/Resources/GenericVideoGameImportResourceTest.cs b/test/WebApi.IntegrationTests/Resources/GenericVideoGameImportResourceTest.cs index 088f67f8..91a7cc77 100644 --- a/test/WebApi.IntegrationTests/Resources/GenericVideoGameImportResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/GenericVideoGameImportResourceTest.cs @@ -27,52 +27,45 @@ public async Task PreviewThenCommit_CreatesAVideoGame_AndSkipsADuplicateReimport gameRow.ProductName.Should().Be(GenericVideoGameImportFixtureCsvBuilder.GameProductName); gameRow.AlreadyImported.Should().BeFalse(); - try - { - // a row with no platform must be rejected before anything is persisted - var invalidItem = ToCommitItem(gameRow); - invalidItem.Platform = null; - var invalidRequest = new GenericVideoGameImportCommitRequestDto { Items = [invalidItem] }; - await PostAsync("/api/import/video-games/commit", invalidRequest, HttpStatusCode.BadRequest); - - var commitRequest = new GenericVideoGameImportCommitRequestDto { Items = [ToCommitItem(gameRow)] }; - var commitResult = await PostAsync("/api/import/video-games/commit", commitRequest); - commitResult.VideoGamesCreated.Should().Be(1); - - var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.GameTitle)}"); - var videoGame = videoGames.Items.Should().ContainSingle().Subject; - videoGame.Platforms.Should().ContainSingle(); - videoGame.Platforms[0].Platform.Should().Be(GenericVideoGameImportFixtureCsvBuilder.GamePlatform); - videoGame.Platforms[0].ProductName.Should().Be(GenericVideoGameImportFixtureCsvBuilder.GameProductName); - videoGame.Platforms[0].CopyType.Should().Be(CopyType.Digital); - videoGame.Platforms[0].Price.Should().Be(14.99m); - videoGame.Platforms[0].Reference.Should().Contain(GenericVideoGameImportFixtureCsvBuilder.GameTransactionId); - // SourceTitle is echoed from the preview row's already-cleaned Title (platform suffix already - // stripped by CleanTitle during parsing), not the raw "Game Name (PS4)" CSV cell. - videoGame.Notes.Should().Be($"Title from {GenericVideoGameImportFixtureCsvBuilder.GameVendor}: {GenericVideoGameImportFixtureCsvBuilder.GameTitle}"); - - // re-preview after commit: the just-imported transaction must now be flagged, so re-uploading a - // newer export later doesn't silently duplicate it - var secondPreview = await PostFileAsync>("/api/import/video-games/preview", "file", csv, "transactions.csv"); - secondPreview.Should().Contain(r => r.Title == GenericVideoGameImportFixtureCsvBuilder.GameTitle && r.AlreadyImported); - - // committing the exact same row again must not duplicate anything - var secondCommitResult = await PostAsync("/api/import/video-games/commit", commitRequest); - secondCommitResult.VideoGamesCreated.Should().Be(0); - secondCommitResult.VideoGamesMergedInto.Should().Be(0); - secondCommitResult.VideoGamesSkipped.Should().Be(1); - - var videoGamesAfterReimport = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.GameTitle)}"); - videoGamesAfterReimport.Items.Should().ContainSingle().Which.Platforms.Should().ContainSingle(); - } - finally - { - var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.GameTitle)}"); - foreach (var videoGame in videoGames.Items.Where(g => g.Id is not null)) - { - await DeleteAsync($"/api/video-games/{videoGame.Id}"); - } - } + // the commit creates an item whose id this test never sees, so cleanup is keyed on the fixture's own + // synthetic title - and registered before the commit, so a partial commit is cleaned up too + TrackResourcesMatching("/api/video-games", GenericVideoGameImportFixtureCsvBuilder.GameTitle); + + // a row with no platform must be rejected before anything is persisted + var invalidItem = ToCommitItem(gameRow); + invalidItem.Platform = null; + var invalidRequest = new GenericVideoGameImportCommitRequestDto { Items = [invalidItem] }; + await PostAsync("/api/import/video-games/commit", invalidRequest, HttpStatusCode.BadRequest); + + var commitRequest = new GenericVideoGameImportCommitRequestDto { Items = [ToCommitItem(gameRow)] }; + var commitResult = await PostAsync("/api/import/video-games/commit", commitRequest); + commitResult.VideoGamesCreated.Should().Be(1); + + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.GameTitle)}"); + var videoGame = videoGames.Items.Should().ContainSingle().Subject; + videoGame.Platforms.Should().ContainSingle(); + videoGame.Platforms[0].Platform.Should().Be(GenericVideoGameImportFixtureCsvBuilder.GamePlatform); + videoGame.Platforms[0].ProductName.Should().Be(GenericVideoGameImportFixtureCsvBuilder.GameProductName); + videoGame.Platforms[0].CopyType.Should().Be(CopyType.Digital); + videoGame.Platforms[0].Price.Should().Be(14.99m); + videoGame.Platforms[0].Reference.Should().Contain(GenericVideoGameImportFixtureCsvBuilder.GameTransactionId); + // SourceTitle is echoed from the preview row's already-cleaned Title (platform suffix already + // stripped by CleanTitle during parsing), not the raw "Game Name (PS4)" CSV cell. + videoGame.Notes.Should().Be($"Title from {GenericVideoGameImportFixtureCsvBuilder.GameVendor}: {GenericVideoGameImportFixtureCsvBuilder.GameTitle}"); + + // re-preview after commit: the just-imported transaction must now be flagged, so re-uploading a + // newer export later doesn't silently duplicate it + var secondPreview = await PostFileAsync>("/api/import/video-games/preview", "file", csv, "transactions.csv"); + secondPreview.Should().Contain(r => r.Title == GenericVideoGameImportFixtureCsvBuilder.GameTitle && r.AlreadyImported); + + // committing the exact same row again must not duplicate anything + var secondCommitResult = await PostAsync("/api/import/video-games/commit", commitRequest); + secondCommitResult.VideoGamesCreated.Should().Be(0); + secondCommitResult.VideoGamesMergedInto.Should().Be(0); + secondCommitResult.VideoGamesSkipped.Should().Be(1); + + var videoGamesAfterReimport = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.GameTitle)}"); + videoGamesAfterReimport.Items.Should().ContainSingle().Which.Platforms.Should().ContainSingle(); } [Fact] @@ -99,24 +92,15 @@ public async Task Commit_MergesTwoRowsSharingATitle_IntoOneVideoGameWithTwoPlatf ] }; - try - { - var commitResult = await PostAsync("/api/import/video-games/commit", request); - commitResult.VideoGamesCreated.Should().Be(1); - - var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(sharedTitle)}"); - var videoGame = videoGames.Items.Should().ContainSingle().Subject; - videoGame.Platforms.Should().HaveCount(2); - videoGame.Platforms.Select(p => p.Platform).Should().BeEquivalentTo(["PS4", "PS5"]); - } - finally - { - var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(sharedTitle)}"); - foreach (var videoGame in videoGames.Items.Where(g => g.Id is not null)) - { - await DeleteAsync($"/api/video-games/{videoGame.Id}"); - } - } + TrackResourcesMatching("/api/video-games", sharedTitle); + + var commitResult = await PostAsync("/api/import/video-games/commit", request); + commitResult.VideoGamesCreated.Should().Be(1); + + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(sharedTitle)}"); + var videoGame = videoGames.Items.Should().ContainSingle().Subject; + videoGame.Platforms.Should().HaveCount(2); + videoGame.Platforms.Select(p => p.Platform).Should().BeEquivalentTo(["PS4", "PS5"]); } [Fact] @@ -130,37 +114,28 @@ public async Task PreviewThenCommit_ImportsAllThreeLines_WhenTheyShareOneTransac var csv = GenericVideoGameImportFixtureCsvBuilder.Build(); - try - { - var preview = await PostFileAsync>("/api/import/video-games/preview", "file", csv, "transactions.csv"); - var bundleRows = preview.Where(r => r.Title == GenericVideoGameImportFixtureCsvBuilder.BundleTitle).ToList(); - bundleRows.Should().HaveCount(3); - bundleRows.Should().OnlyContain(r => !r.AlreadyImported); - - var commitRequest = new GenericVideoGameImportCommitRequestDto { Items = bundleRows.Select(ToCommitItem).ToList() }; - var commitResult = await PostAsync("/api/import/video-games/commit", commitRequest); - commitResult.VideoGamesCreated.Should().Be(1); - commitResult.VideoGamesSkipped.Should().Be(0); - - var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.BundleTitle)}"); - var videoGame = videoGames.Items.Should().ContainSingle().Subject; - videoGame.Platforms.Should().HaveCount(3); - videoGame.Platforms.Select(p => p.ProductName).Should().BeEquivalentTo( - [ - GenericVideoGameImportFixtureCsvBuilder.BundleProductA, - GenericVideoGameImportFixtureCsvBuilder.BundleProductB, - GenericVideoGameImportFixtureCsvBuilder.BundleProductC - ]); - videoGame.Platforms.Select(p => p.Reference).Distinct().Should().HaveCount(3); - } - finally - { - var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.BundleTitle)}"); - foreach (var videoGame in videoGames.Items.Where(g => g.Id is not null)) - { - await DeleteAsync($"/api/video-games/{videoGame.Id}"); - } - } + TrackResourcesMatching("/api/video-games", GenericVideoGameImportFixtureCsvBuilder.BundleTitle); + + var preview = await PostFileAsync>("/api/import/video-games/preview", "file", csv, "transactions.csv"); + var bundleRows = preview.Where(r => r.Title == GenericVideoGameImportFixtureCsvBuilder.BundleTitle).ToList(); + bundleRows.Should().HaveCount(3); + bundleRows.Should().OnlyContain(r => !r.AlreadyImported); + + var commitRequest = new GenericVideoGameImportCommitRequestDto { Items = bundleRows.Select(ToCommitItem).ToList() }; + var commitResult = await PostAsync("/api/import/video-games/commit", commitRequest); + commitResult.VideoGamesCreated.Should().Be(1); + commitResult.VideoGamesSkipped.Should().Be(0); + + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.BundleTitle)}"); + var videoGame = videoGames.Items.Should().ContainSingle().Subject; + videoGame.Platforms.Should().HaveCount(3); + videoGame.Platforms.Select(p => p.ProductName).Should().BeEquivalentTo( + [ + GenericVideoGameImportFixtureCsvBuilder.BundleProductA, + GenericVideoGameImportFixtureCsvBuilder.BundleProductB, + GenericVideoGameImportFixtureCsvBuilder.BundleProductC + ]); + videoGame.Platforms.Select(p => p.Reference).Distinct().Should().HaveCount(3); } private static GenericVideoGameImportCommitItemDto ToCommitItem(GenericVideoGameImportPreviewRowDto row) => new() diff --git a/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs index d30cbd74..ddd8c1e1 100644 --- a/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs @@ -27,22 +27,15 @@ public async Task HealthProfileResourceFullCycle_IsOk() await Authenticate(); - var created = await PostAsync($"/{ResourceEndpoint}", new HealthProfileDto { Name = $"Profile-{Guid.NewGuid():N}" }); + var created = await CreateAsync($"/{ResourceEndpoint}", new HealthProfileDto { Name = $"Profile-{Guid.NewGuid():N}" }); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.Notes = "Allergic to penicillin"; - created.ImageUrl = "https://example.com/profile.jpg"; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + created.Notes = "Allergic to penicillin"; + created.ImageUrl = "https://example.com/profile.jpg"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); } [Fact] @@ -50,62 +43,54 @@ public async Task HealthProfileMetrics_ComputeCostsLastVisitsAndUnbalanced_FromR { await Authenticate(); - var profile = await PostAsync($"/{ResourceEndpoint}", new HealthProfileDto { Name = $"Profile-{Guid.NewGuid():N}" }); + var profile = await CreateAsync($"/{ResourceEndpoint}", new HealthProfileDto { Name = $"Profile-{Guid.NewGuid():N}" }); - try + // a fully settled appointment (price = ameli + mutuelle + leftover), an unsettled one, and a + // sickness entry with no money + await CreateAsync($"/{RecordEndpoint}", new HealthRecordDto { - // a fully settled appointment (price = ameli + mutuelle + leftover), an unsettled one, and a - // sickness entry with no money - await PostAsync($"/{RecordEndpoint}", new HealthRecordDto - { - HealthProfileId = profile.Id!, - HistoryDate = new DateTime(2026, 2, 3, 9, 30, 0), - EventType = HealthEventType.Appointment, - Specialty = "généraliste", - Practitioner = "Dr Martin", - Price = 30, - PublicReimbursement = 20, - InsuranceReimbursement = 8.5, - NotCovered = 1.5 - }); - await PostAsync($"/{RecordEndpoint}", new HealthRecordDto - { - HealthProfileId = profile.Id!, - HistoryDate = new DateTime(2026, 5, 10, 14, 0, 0), - EventType = HealthEventType.Appointment, - Specialty = "dentiste", - Practitioner = "Dr Diaz", - Price = 120 - }); - await PostAsync($"/{RecordEndpoint}", new HealthRecordDto - { - HealthProfileId = profile.Id!, - HistoryDate = new DateTime(2026, 7, 1, 8, 0, 0), - EventType = HealthEventType.Sickness, - Description = "Fever" - }); - - var metrics = await GetAsync($"/{ResourceEndpoint}/{profile.Id}/metrics"); - - var year = metrics.CostHistory.Should().ContainSingle().Subject; - year.Year.Should().Be(2026); - year.TotalPaid.Should().Be(150); - year.TotalReimbursed.Should().Be(28.5); - year.OutOfPocket.Should().Be(121.5); - - metrics.LastVisits.Should().HaveCount(2); - metrics.LastVisits[0].Specialty.Should().Be("dentiste"); - - var unbalanced = metrics.UnbalancedRecords.Should().ContainSingle().Subject; - unbalanced.Label.Should().Be("Dr Diaz"); - unbalanced.Price.Should().Be(120); - unbalanced.MissingAmount.Should().Be(120); - } - finally + HealthProfileId = profile.Id!, + HistoryDate = new DateTime(2026, 2, 3, 9, 30, 0), + EventType = HealthEventType.Appointment, + Specialty = "généraliste", + Practitioner = "Dr Martin", + Price = 30, + PublicReimbursement = 20, + InsuranceReimbursement = 8.5, + NotCovered = 1.5 + }); + await CreateAsync($"/{RecordEndpoint}", new HealthRecordDto + { + HealthProfileId = profile.Id!, + HistoryDate = new DateTime(2026, 5, 10, 14, 0, 0), + EventType = HealthEventType.Appointment, + Specialty = "dentiste", + Practitioner = "Dr Diaz", + Price = 120 + }); + await CreateAsync($"/{RecordEndpoint}", new HealthRecordDto { - // deleting the profile cascades to its records (verified below), so no per-record cleanup here - await DeleteAsync($"/{ResourceEndpoint}/{profile.Id}"); - } + HealthProfileId = profile.Id!, + HistoryDate = new DateTime(2026, 7, 1, 8, 0, 0), + EventType = HealthEventType.Sickness, + Description = "Fever" + }); + + var metrics = await GetAsync($"/{ResourceEndpoint}/{profile.Id}/metrics"); + + var year = metrics.CostHistory.Should().ContainSingle().Subject; + year.Year.Should().Be(2026); + year.TotalPaid.Should().Be(150); + year.TotalReimbursed.Should().Be(28.5); + year.OutOfPocket.Should().Be(121.5); + + metrics.LastVisits.Should().HaveCount(2); + metrics.LastVisits[0].Specialty.Should().Be("dentiste"); + + var unbalanced = metrics.UnbalancedRecords.Should().ContainSingle().Subject; + unbalanced.Label.Should().Be("Dr Diaz"); + unbalanced.Price.Should().Be(120); + unbalanced.MissingAmount.Should().Be(120); } [Fact] @@ -113,8 +98,8 @@ public async Task DeletingAProfile_CascadesToItsJournal() { await Authenticate(); - var profile = await PostAsync($"/{ResourceEndpoint}", new HealthProfileDto { Name = $"Profile-{Guid.NewGuid():N}" }); - var record = await PostAsync($"/{RecordEndpoint}", new HealthRecordDto + var profile = await CreateAsync($"/{ResourceEndpoint}", new HealthProfileDto { Name = $"Profile-{Guid.NewGuid():N}" }); + var record = await CreateAsync($"/{RecordEndpoint}", new HealthRecordDto { HealthProfileId = profile.Id!, HistoryDate = DateTime.Today, diff --git a/test/WebApi.IntegrationTests/Resources/HealthRecordResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HealthRecordResourceTest.cs index 03951426..6af7adcc 100644 --- a/test/WebApi.IntegrationTests/Resources/HealthRecordResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/HealthRecordResourceTest.cs @@ -38,25 +38,18 @@ public async Task HealthRecordResourceFullCycle_IsOk_AndKeepsTheTimeOfDay() await Authenticate(); var profileId = Guid.NewGuid().ToString(); - var created = await PostAsync($"/{ResourceEndpoint}", NewEntry(profileId)); + var created = await CreateAsync($"/{ResourceEndpoint}", NewEntry(profileId)); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.PublicReimbursement = 30; - created.InsuranceReimbursement = 15.5; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + created.PublicReimbursement = 30; + created.InsuranceReimbursement = 15.5; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created); - // the appointment's time of day is real data and must survive the BSON round trip - updated.HistoryDate.Hour.Should().Be(16); - updated.HistoryDate.Minute.Should().Be(45); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + // the appointment's time of day is real data and must survive the BSON round trip + updated.HistoryDate.Hour.Should().Be(16); + updated.HistoryDate.Minute.Should().Be(45); } [Fact] @@ -69,23 +62,15 @@ public async Task HealthRecordFilter_ByProfileIdAndSearch_OnlyReturnsMatchingEnt var practitioner = $"Dr {Guid.NewGuid():N}"; var entry = NewEntry(profileId); entry.Practitioner = practitioner; - var created = await PostAsync($"/{ResourceEndpoint}", entry); - var otherCreated = await PostAsync($"/{ResourceEndpoint}", NewEntry(otherProfileId)); + var created = await CreateAsync($"/{ResourceEndpoint}", entry); + var otherCreated = await CreateAsync($"/{ResourceEndpoint}", NewEntry(otherProfileId)); - try - { - var byProfile = await GetAsync>($"/{ResourceEndpoint}?HealthProfileId={profileId}"); - byProfile.Items.Should().ContainSingle(x => x.Id == created.Id); - byProfile.Items.Should().NotContain(x => x.Id == otherCreated.Id); + var byProfile = await GetAsync>($"/{ResourceEndpoint}?HealthProfileId={profileId}"); + byProfile.Items.Should().ContainSingle(x => x.Id == created.Id); + byProfile.Items.Should().NotContain(x => x.Id == otherCreated.Id); - // search spans practitioner (and specialty/description) - "when did I last see Dr X" - var bySearch = await GetAsync>($"/{ResourceEndpoint}?HealthProfileId={profileId}&search={practitioner}"); - bySearch.Items.Should().ContainSingle(x => x.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{otherCreated.Id}"); - } + // search spans practitioner (and specialty/description) - "when did I last see Dr X" + var bySearch = await GetAsync>($"/{ResourceEndpoint}?HealthProfileId={profileId}&search={practitioner}"); + bySearch.Items.Should().ContainSingle(x => x.Id == created.Id); } } diff --git a/test/WebApi.IntegrationTests/Resources/HouseHistoryResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HouseHistoryResourceTest.cs index 8c9a9118..357610b6 100644 --- a/test/WebApi.IntegrationTests/Resources/HouseHistoryResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/HouseHistoryResourceTest.cs @@ -36,24 +36,17 @@ public async Task HouseHistoryResourceFullCycle_IsOk() var houseId = Guid.NewGuid().ToString(); var initialItems = await GetAsync>($"/{ResourceEndpoint}?HouseId={houseId}"); - var created = await PostAsync($"/{ResourceEndpoint}", NewEntry(houseId)); + var created = await CreateAsync($"/{ResourceEndpoint}", NewEntry(houseId)); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.Cost = 55.0; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created); - - var finalItems = await GetAsync>($"/{ResourceEndpoint}?HouseId={houseId}"); - finalItems.TotalCount.Should().BeGreaterThan(initialItems.TotalCount); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + created.Cost = 55.0; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}?HouseId={houseId}"); + finalItems.TotalCount.Should().BeGreaterThan(initialItems.TotalCount); } [Fact] @@ -63,20 +56,12 @@ public async Task HouseHistoryResourceFilter_ByHouseId_OnlyReturnsThatHousesEntr var houseId = Guid.NewGuid().ToString(); var otherHouseId = Guid.NewGuid().ToString(); - var created = await PostAsync($"/{ResourceEndpoint}", NewEntry(houseId)); - var otherCreated = await PostAsync($"/{ResourceEndpoint}", NewEntry(otherHouseId)); - - try - { - var results = await GetAsync>($"/{ResourceEndpoint}?HouseId={houseId}"); - results.Items.Should().ContainSingle(x => x.Id == created.Id); - results.Items.Should().NotContain(x => x.Id == otherCreated.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{otherCreated.Id}"); - } + var created = await CreateAsync($"/{ResourceEndpoint}", NewEntry(houseId)); + var otherCreated = await CreateAsync($"/{ResourceEndpoint}", NewEntry(otherHouseId)); + + var results = await GetAsync>($"/{ResourceEndpoint}?HouseId={houseId}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); + results.Items.Should().NotContain(x => x.Id == otherCreated.Id); } [Fact] @@ -88,16 +73,9 @@ public async Task HouseHistoryResourceFilter_ByHouseIdAndSearch_DoesNotThrow_IsO var description = Guid.NewGuid().ToString(); var entry = NewEntry(houseId); entry.Description = description; - var created = await PostAsync($"/{ResourceEndpoint}", entry); - - try - { - var results = await GetAsync>($"/{ResourceEndpoint}?HouseId={houseId}&search={description}"); - results.Items.Should().ContainSingle(x => x.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var created = await CreateAsync($"/{ResourceEndpoint}", entry); + + var results = await GetAsync>($"/{ResourceEndpoint}?HouseId={houseId}&search={description}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); } } diff --git a/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs index f8a931f7..6f0d190c 100644 --- a/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -31,31 +32,24 @@ public async Task HouseResourceFullCycle_IsOk() o.Name = f.Random.AlphaNumeric(14); o.City = f.Address.City(); o.PropertyType = f.PickRandom(); - o.MovedInAt = System.DateOnly.FromDateTime(f.Date.Past()); - o.MovedOutAt = System.DateOnly.FromDateTime(f.Date.Recent()); + o.MovedInAt = DateOnly.FromDateTime(f.Date.Past()); + o.MovedOutAt = DateOnly.FromDateTime(f.Date.Recent()); o.ImageUrl = f.Internet.Url(); }) .Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); + var created = await CreateAsync($"/{ResourceEndpoint}", input); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.Name = "New shiny name"; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created); - - var finalItems = await GetAsync>($"/{ResourceEndpoint}"); - var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); - firstItem.Should().NotBeNull(); - firstItem.Name.Should().Be(updated.Name); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + created.Name = "New shiny name"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Name.Should().Be(updated.Name); } [Fact] @@ -63,18 +57,11 @@ public async Task HouseResourceSearch_FiltersByName_IsOk() { await Authenticate(); - var name = System.Guid.NewGuid().ToString(); - var created = await PostAsync($"/{ResourceEndpoint}", new HouseDto { Name = name }); + var name = Guid.NewGuid().ToString(); + var created = await CreateAsync($"/{ResourceEndpoint}", new HouseDto { Name = name }); - try - { - var results = await GetAsync>($"/{ResourceEndpoint}?search={name}"); - results.Items.Should().ContainSingle(x => x.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var results = await GetAsync>($"/{ResourceEndpoint}?search={name}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); } [Fact] @@ -90,17 +77,10 @@ public async Task HouseResourceMetrics_ReturnsEmptyMetrics_ForAHouseWithNoHistor { await Authenticate(); - var created = await PostAsync($"/{ResourceEndpoint}", new HouseDto { Name = System.Guid.NewGuid().ToString() }); + var created = await CreateAsync($"/{ResourceEndpoint}", new HouseDto { Name = Guid.NewGuid().ToString() }); - try - { - var metrics = await GetAsync($"/{ResourceEndpoint}/{created.Id}/metrics"); - metrics.CostHistory.Should().BeEmpty(); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var metrics = await GetAsync($"/{ResourceEndpoint}/{created.Id}/metrics"); + metrics.CostHistory.Should().BeEmpty(); } /// @@ -113,11 +93,13 @@ public async Task HouseResourceDelete_CascadesToItsHistory_IsOk() { await Authenticate(); - var house = await PostAsync($"/{ResourceEndpoint}", new HouseDto { Name = System.Guid.NewGuid().ToString() }); - var entry = await PostAsync("/api/house-history", new HouseHistoryDto + // both are registered even though the delete below is the point of the test: if the cascade is ever + // broken, the orphaned history entry is exactly what would otherwise be left behind. + var house = await CreateAsync($"/{ResourceEndpoint}", new HouseDto { Name = Guid.NewGuid().ToString() }); + var entry = await CreateAsync("/api/house-history", new HouseHistoryDto { HouseId = house.Id!, - HistoryDate = System.DateOnly.FromDateTime(System.DateTime.Today), + HistoryDate = DateOnly.FromDateTime(DateTime.Today), EventType = HouseEventType.Maintenance }); diff --git a/test/WebApi.IntegrationTests/Resources/LeaseRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/LeaseRepositoryTest.cs index 11e6282e..19a24455 100644 --- a/test/WebApi.IntegrationTests/Resources/LeaseRepositoryTest.cs +++ b/test/WebApi.IntegrationTests/Resources/LeaseRepositoryTest.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using AwesomeAssertions; using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; using Keeptrack.WebApi.IntegrationTests.Hosting; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -13,15 +14,20 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// server's own _id uniqueness under a filtered upsert, which only a real database can prove (a mock /// would just restate the implementation). Each test uses its own lease name, so parallel test runs /// can't contend with each other. +/// +/// deliberately has no release/delete method (a lease is released by +/// expiring, which is what makes it safe when a holder dies), so the acquired documents are removed +/// straight from the collection instead - otherwise every run permanently adds a row to lease. +/// /// -public class LeaseRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +public class LeaseRepositoryTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) { [Fact] public async Task TryAcquire_WinsOnce_AndBlocksASecondHolderWhileLive() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); - var lease = $"test-lease-{Guid.NewGuid():N}"; + var lease = NewLeaseName(); (await repository.TryAcquireAsync(lease, "holder-a", TimeSpan.FromMinutes(5))).Should().BeTrue(); (await repository.TryAcquireAsync(lease, "holder-b", TimeSpan.FromMinutes(5))).Should().BeFalse(); @@ -30,9 +36,9 @@ public async Task TryAcquire_WinsOnce_AndBlocksASecondHolderWhileLive() [Fact] public async Task TryAcquire_RenewsForTheCurrentHolder() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); - var lease = $"test-lease-{Guid.NewGuid():N}"; + var lease = NewLeaseName(); (await repository.TryAcquireAsync(lease, "holder-a", TimeSpan.FromMinutes(5))).Should().BeTrue(); (await repository.TryAcquireAsync(lease, "holder-a", TimeSpan.FromMinutes(5))).Should().BeTrue(); @@ -41,13 +47,24 @@ public async Task TryAcquire_RenewsForTheCurrentHolder() [Fact] public async Task TryAcquire_SucceedsForANewHolder_OnceTheLeaseHasExpired() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); - var lease = $"test-lease-{Guid.NewGuid():N}"; + var lease = NewLeaseName(); // a negative duration writes an already-expired lease - no sleeping in the test (await repository.TryAcquireAsync(lease, "holder-a", TimeSpan.FromSeconds(-1))).Should().BeTrue(); (await repository.TryAcquireAsync(lease, "holder-b", TimeSpan.FromMinutes(5))).Should().BeTrue(); } + + /// + /// The lease name is the document's _id, so registering it up front cleans up whichever of the + /// acquisitions below actually created the document. + /// + private string NewLeaseName() + { + var lease = $"test-lease-{Guid.NewGuid():N}"; + TrackDocument("lease", lease); + return lease; + } } diff --git a/test/WebApi.IntegrationTests/Resources/ListSortingRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/ListSortingRepositoryTest.cs index 76cad37f..296aec45 100644 --- a/test/WebApi.IntegrationTests/Resources/ListSortingRepositoryTest.cs +++ b/test/WebApi.IntegrationTests/Resources/ListSortingRepositoryTest.cs @@ -19,82 +19,65 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// Book covers both overridable sort fields (title and rating). Each test run uses its own random /// owner id, so parallel runs and other tests' data can't interfere with the ordering assertions. /// -public class ListSortingRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +public class ListSortingRepositoryTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) { [Fact] public async Task FindAllAsync_SortsByDefault_TitleAndRating() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var ownerId = $"sort-test-{Guid.NewGuid():N}"; // creation order deliberately differs from every sorted order; "apple" (lowercase) between // "Banana" and "Cherry" proves the title collation, the null rating proves nulls sort last - var created = new[] - { - await repository.CreateAsync(NewBook(ownerId, "Banana", rating: 2f)), - await repository.CreateAsync(NewBook(ownerId, "apple", rating: null)), - await repository.CreateAsync(NewBook(ownerId, "Cherry", rating: 4.5f)), - }; + await CreateBookAsync(repository, NewBook(ownerId, "Banana", rating: 2f)); + await CreateBookAsync(repository, NewBook(ownerId, "apple", rating: null)); + await CreateBookAsync(repository, NewBook(ownerId, "Cherry", rating: 4.5f)); - try - { - var byDefault = await repository.FindAllAsync(ownerId, 1, 10, null, NewBook(ownerId, "")); - byDefault.Items.Select(b => b.Title).Should().Equal(["Cherry", "apple", "Banana"], - "the default order is newest first"); + var byDefault = await repository.FindAllAsync(ownerId, 1, 10, null, NewBook(ownerId, "")); + byDefault.Items.Select(b => b.Title).Should().Equal(["Cherry", "apple", "Banana"], + "the default order is newest first"); - var byTitle = await repository.FindAllAsync(ownerId, 1, 10, null, NewBook(ownerId, ""), ListSort.Title); - byTitle.Items.Select(b => b.Title).Should().Equal(["apple", "Banana", "Cherry"], - "the title sort is case-insensitive, not byte order (which would put every uppercase title first)"); + var byTitle = await repository.FindAllAsync(ownerId, 1, 10, null, NewBook(ownerId, ""), ListSort.Title); + byTitle.Items.Select(b => b.Title).Should().Equal(["apple", "Banana", "Cherry"], + "the title sort is case-insensitive, not byte order (which would put every uppercase title first)"); - var byRating = await repository.FindAllAsync(ownerId, 1, 10, null, NewBook(ownerId, ""), ListSort.Rating); - byRating.Items.Select(b => b.Title).Should().Equal(["Cherry", "Banana", "apple"], - "the rating sort is best-first with unrated items last"); + var byRating = await repository.FindAllAsync(ownerId, 1, 10, null, NewBook(ownerId, ""), ListSort.Rating); + byRating.Items.Select(b => b.Title).Should().Equal(["Cherry", "Banana", "apple"], + "the rating sort is best-first with unrated items last"); - var byUnknownKey = await repository.FindAllAsync(ownerId, 1, 10, null, NewBook(ownerId, ""), "nonsense"); - byUnknownKey.Items.Select(b => b.Title).Should().Equal(["Cherry", "apple", "Banana"], - "an unknown sort key falls back to the newest-first default rather than erroring"); - } - finally - { - foreach (var book in created) - { - await repository.DeleteAsync(book.Id!, ownerId); - } - } + var byUnknownKey = await repository.FindAllAsync(ownerId, 1, 10, null, NewBook(ownerId, ""), "nonsense"); + byUnknownKey.Items.Select(b => b.Title).Should().Equal(["Cherry", "apple", "Banana"], + "an unknown sort key falls back to the newest-first default rather than erroring"); } [Fact] public async Task FindAllAsync_KeepsTheSortStable_AcrossPages() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var ownerId = $"sort-test-{Guid.NewGuid():N}"; - var created = new BookModel[5]; - for (var i = 0; i < created.Length; i++) + for (var i = 0; i < 5; i++) { - created[i] = await repository.CreateAsync(NewBook(ownerId, $"Book {i:D2}")); + await CreateBookAsync(repository, NewBook(ownerId, $"Book {i:D2}")); } - try - { - // paging through a sorted list must partition it exactly: no duplicates, no drops - the - // guarantee an unsorted skip/limit read never had - var page1 = await repository.FindAllAsync(ownerId, 1, 2, null, NewBook(ownerId, ""), ListSort.Title); - var page2 = await repository.FindAllAsync(ownerId, 2, 2, null, NewBook(ownerId, ""), ListSort.Title); - var page3 = await repository.FindAllAsync(ownerId, 3, 2, null, NewBook(ownerId, ""), ListSort.Title); + // paging through a sorted list must partition it exactly: no duplicates, no drops - the + // guarantee an unsorted skip/limit read never had + var page1 = await repository.FindAllAsync(ownerId, 1, 2, null, NewBook(ownerId, ""), ListSort.Title); + var page2 = await repository.FindAllAsync(ownerId, 2, 2, null, NewBook(ownerId, ""), ListSort.Title); + var page3 = await repository.FindAllAsync(ownerId, 3, 2, null, NewBook(ownerId, ""), ListSort.Title); - page1.Items.Concat(page2.Items).Concat(page3.Items).Select(b => b.Title) - .Should().Equal("Book 00", "Book 01", "Book 02", "Book 03", "Book 04"); - } - finally - { - foreach (var book in created) - { - await repository.DeleteAsync(book.Id!, ownerId); - } - } + page1.Items.Concat(page2.Items).Concat(page3.Items).Select(b => b.Title) + .Should().Equal("Book 00", "Book 01", "Book 02", "Book 03", "Book 04"); + } + + private async Task CreateBookAsync(IBookRepository repository, BookModel book) + { + var created = await repository.CreateAsync(book); + TrackCleanup(() => repository.DeleteAsync(created.Id!, book.OwnerId)); + return created; } private static BookModel NewBook(string ownerId, string title, float? rating = null) => new() diff --git a/test/WebApi.IntegrationTests/Resources/MovieReferenceRatingRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/MovieReferenceRatingRepositoryTest.cs index 36ca2e21..a68d7ae3 100644 --- a/test/WebApi.IntegrationTests/Resources/MovieReferenceRatingRepositoryTest.cs +++ b/test/WebApi.IntegrationTests/Resources/MovieReferenceRatingRepositoryTest.cs @@ -19,101 +19,78 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// on refresh), and that the reference document's Ratings dictionary round-trips through BSON. /// Each test uses its own random owner id so parallel runs can't interfere. /// -public class MovieReferenceRatingRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +public class MovieReferenceRatingRepositoryTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) { [Fact] public async Task FindAllAsync_SortsByReferenceRating_BestFirstWithUnratedLast() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var ownerId = $"refrating-sort-{Guid.NewGuid():N}"; - var created = new[] - { - await repository.CreateAsync(NewMovie(ownerId, "Middle", referenceRating: 6.5)), - await repository.CreateAsync(NewMovie(ownerId, "Unrated", referenceRating: null)), - await repository.CreateAsync(NewMovie(ownerId, "Best", referenceRating: 9.1)), - }; + await CreateMovieAsync(repository, NewMovie(ownerId, "Middle", referenceRating: 6.5)); + await CreateMovieAsync(repository, NewMovie(ownerId, "Unrated", referenceRating: null)); + await CreateMovieAsync(repository, NewMovie(ownerId, "Best", referenceRating: 9.1)); - try - { - var byReferenceRating = await repository.FindAllAsync(ownerId, 1, 10, null, NewMovie(ownerId, ""), ListSort.ReferenceRating); - byReferenceRating.Items.Select(m => m.Title).Should().Equal(["Best", "Middle", "Unrated"], - "the reference-rating sort is best-first with items that have no linked rating last"); - } - finally - { - foreach (var movie in created) - { - await repository.DeleteAsync(movie.Id!, ownerId); - } - } + var byReferenceRating = await repository.FindAllAsync(ownerId, 1, 10, null, NewMovie(ownerId, ""), ListSort.ReferenceRating); + byReferenceRating.Items.Select(m => m.Title).Should().Equal(["Best", "Middle", "Unrated"], + "the reference-rating sort is best-first with items that have no linked rating last"); } [Fact] public async Task SetReferenceLinkAsync_StampsTheDenormalizedRating_OnlyOnUnlinkedMatches() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var ownerId = $"refrating-link-{Guid.NewGuid():N}"; + // SetReferenceLinkAsync matches by (title, year) across every owner by design, so a fixed title + // would also rewrite anyone else's movie of that name in the shared test database - the one place a + // random owner id isn't enough to isolate this test. + var title = $"The Matrix {Guid.NewGuid():N}"; - var unlinked = await repository.CreateAsync(NewMovie(ownerId, "The Matrix", year: 1999)); - var alreadyLinked = await repository.CreateAsync(NewMovie(ownerId, "The Matrix", year: 1999, referenceId: "pre-existing")); + var unlinked = await CreateMovieAsync(repository, NewMovie(ownerId, title, year: 1999)); + var alreadyLinked = await CreateMovieAsync(repository, NewMovie(ownerId, title, year: 1999, referenceId: "pre-existing")); - try - { - await repository.SetReferenceLinkAsync("The Matrix", 1999, "reference-1", "The Matrix", 1999, 8.2, 10); - - var reloadedUnlinked = await repository.FindOneAsync(unlinked.Id!, ownerId); - reloadedUnlinked!.ReferenceId.Should().Be("reference-1"); - reloadedUnlinked.ReferenceRating.Should().Be(8.2); - reloadedUnlinked.ReferenceRatingScale.Should().Be(10); - - // an already-linked document is left untouched by the link propagation (UnresolvedFilter) - var reloadedLinked = await repository.FindOneAsync(alreadyLinked.Id!, ownerId); - reloadedLinked!.ReferenceId.Should().Be("pre-existing"); - reloadedLinked.ReferenceRating.Should().BeNull(); - } - finally - { - await repository.DeleteAsync(unlinked.Id!, ownerId); - await repository.DeleteAsync(alreadyLinked.Id!, ownerId); - } + await repository.SetReferenceLinkAsync(title, 1999, "reference-1", title, 1999, 8.2, 10); + + var reloadedUnlinked = await repository.FindOneAsync(unlinked.Id!, ownerId); + reloadedUnlinked!.ReferenceId.Should().Be("reference-1"); + reloadedUnlinked.ReferenceRating.Should().Be(8.2); + reloadedUnlinked.ReferenceRatingScale.Should().Be(10); + + // an already-linked document is left untouched by the link propagation (UnresolvedFilter) + var reloadedLinked = await repository.FindOneAsync(alreadyLinked.Id!, ownerId); + reloadedLinked!.ReferenceId.Should().Be("pre-existing"); + reloadedLinked.ReferenceRating.Should().BeNull(); } [Fact] public async Task SetReferenceRatingAsync_RepropagatesToEveryAlreadyLinkedMovie() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var ownerId = $"refrating-refresh-{Guid.NewGuid():N}"; + // unique per run: SetReferenceRatingAsync propagates by reference id across every owner, so a fixed + // literal would also hit leftovers from an earlier run and break the exact modified-count assertion. + var referenceId = $"reference-{Guid.NewGuid():N}"; - var linkedA = await repository.CreateAsync(NewMovie(ownerId, "Alien", referenceId: "reference-7", referenceRating: 7.0)); - var linkedB = await repository.CreateAsync(NewMovie(ownerId, "Alien", referenceId: "reference-7", referenceRating: 7.0)); - var other = await repository.CreateAsync(NewMovie(ownerId, "Other", referenceId: "reference-other", referenceRating: 5.0)); + var linkedA = await CreateMovieAsync(repository, NewMovie(ownerId, "Alien", referenceId: referenceId, referenceRating: 7.0)); + var linkedB = await CreateMovieAsync(repository, NewMovie(ownerId, "Alien", referenceId: referenceId, referenceRating: 7.0)); + var other = await CreateMovieAsync(repository, NewMovie(ownerId, "Other", referenceId: $"reference-other-{Guid.NewGuid():N}", referenceRating: 5.0)); - try - { - var modified = await repository.SetReferenceRatingAsync("reference-7", 8.4, 10); - modified.Should().Be(2); - - (await repository.FindOneAsync(linkedA.Id!, ownerId))!.ReferenceRating.Should().Be(8.4); - (await repository.FindOneAsync(linkedB.Id!, ownerId))!.ReferenceRating.Should().Be(8.4); - // a movie linked to a different reference is untouched - (await repository.FindOneAsync(other.Id!, ownerId))!.ReferenceRating.Should().Be(5.0); - } - finally - { - await repository.DeleteAsync(linkedA.Id!, ownerId); - await repository.DeleteAsync(linkedB.Id!, ownerId); - await repository.DeleteAsync(other.Id!, ownerId); - } + var modified = await repository.SetReferenceRatingAsync(referenceId, 8.4, 10); + modified.Should().Be(2); + + (await repository.FindOneAsync(linkedA.Id!, ownerId))!.ReferenceRating.Should().Be(8.4); + (await repository.FindOneAsync(linkedB.Id!, ownerId))!.ReferenceRating.Should().Be(8.4); + // a movie linked to a different reference is untouched + (await repository.FindOneAsync(other.Id!, ownerId))!.ReferenceRating.Should().Be(5.0); } [Fact] public async Task MovieReferenceRatings_RoundTripThroughBson() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var referenceRepository = scope.ServiceProvider.GetRequiredService(); var saved = await referenceRepository.UpsertAsync(new MovieReferenceModel @@ -121,22 +98,23 @@ public async Task MovieReferenceRatings_RoundTripThroughBson() Title = $"Round Trip {Guid.NewGuid():N}", TitleNormalized = "placeholder", Year = 2001, - ExternalIds = new Dictionary { ["tmdb"] = $"rt-{Guid.NewGuid():N}" }, + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() }, Ratings = new Dictionary { ["tmdb"] = new() { Value = 7.8, Scale = 10, Count = 4321 } } }); + TrackCleanup(() => referenceRepository.DeleteAsync(saved.Id!)); - try - { - var reloaded = await referenceRepository.FindByIdAsync(saved.Id!); - reloaded!.Ratings.Should().ContainKey("tmdb"); - reloaded.Ratings["tmdb"].Value.Should().Be(7.8); - reloaded.Ratings["tmdb"].Scale.Should().Be(10); - reloaded.Ratings["tmdb"].Count.Should().Be(4321); - } - finally - { - await referenceRepository.DeleteAsync(saved.Id!); - } + var reloaded = await referenceRepository.FindByIdAsync(saved.Id!); + reloaded!.Ratings.Should().ContainKey("tmdb"); + reloaded.Ratings["tmdb"].Value.Should().Be(7.8); + reloaded.Ratings["tmdb"].Scale.Should().Be(10); + reloaded.Ratings["tmdb"].Count.Should().Be(4321); + } + + private async Task CreateMovieAsync(IMovieRepository repository, MovieModel movie) + { + var created = await repository.CreateAsync(movie); + TrackCleanup(() => repository.DeleteAsync(created.Id!, movie.OwnerId)); + return created; } private static MovieModel NewMovie(string ownerId, string title, int? year = null, string? referenceId = null, double? referenceRating = null) => new() diff --git a/test/WebApi.IntegrationTests/Resources/MovieResourceTest.cs b/test/WebApi.IntegrationTests/Resources/MovieResourceTest.cs index 759b5f2d..a20f43ab 100644 --- a/test/WebApi.IntegrationTests/Resources/MovieResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/MovieResourceTest.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -26,26 +26,19 @@ public async Task MovieResourceLocalhostFullCycle_IsOk() var input = new Faker() .Rules((f, o) => { o.Title = f.Random.AlphaNumeric(14); }) .Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); + var created = await CreateAsync($"/{ResourceEndpoint}", input); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.Title = "New shiny title"; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created); - - var finalItems = await GetAsync>($"/{ResourceEndpoint}"); - var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); - firstItem.Should().NotBeNull(); - firstItem.Title.Should().Be(updated.Title); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); } [Fact] @@ -55,20 +48,13 @@ public async Task MovieResourceSearch_FiltersToMatchingTitle_IsOk() var uniqueTitle = $"UniqueSearchTarget-{Guid.NewGuid():N}"; var input = new Faker().Rules((f, o) => { o.Title = uniqueTitle; }).Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); - - try - { - var matching = await GetAsync>($"/{ResourceEndpoint}?search={uniqueTitle}"); - matching.Items.Should().Contain(m => m.Id == created.Id); - - var nonMatching = await GetAsync>($"/{ResourceEndpoint}?search={Guid.NewGuid():N}"); - nonMatching.Items.Should().NotContain(m => m.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var created = await CreateAsync($"/{ResourceEndpoint}", input); + + var matching = await GetAsync>($"/{ResourceEndpoint}?search={uniqueTitle}"); + matching.Items.Should().Contain(m => m.Id == created.Id); + + var nonMatching = await GetAsync>($"/{ResourceEndpoint}?search={Guid.NewGuid():N}"); + nonMatching.Items.Should().NotContain(m => m.Id == created.Id); } [Fact] @@ -86,25 +72,18 @@ public async Task MovieResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItems o.IsWishlisted = true; }) .Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); - - try - { - var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={uniqueTitle}"); - owned.Items.Should().ContainSingle(m => m.Id == created.Id); - - // the version's fields must survive the full DTO -> model -> BSON round trip (incl. the decimal price) - var fetchedVersions = owned.Items.Single(m => m.Id == created.Id).OwnedVersions; - fetchedVersions.Should().BeEquivalentTo(input.OwnedVersions); - - // this is the WishlistController filter-probe, not a list-page UI filter (removed) - still real API behavior - var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={uniqueTitle}"); - wishlisted.Items.Should().ContainSingle(m => m.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var created = await CreateAsync($"/{ResourceEndpoint}", input); + + var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={uniqueTitle}"); + owned.Items.Should().ContainSingle(m => m.Id == created.Id); + + // the version's fields must survive the full DTO -> model -> BSON round trip (incl. the decimal price) + var fetchedVersions = owned.Items.Single(m => m.Id == created.Id).OwnedVersions; + fetchedVersions.Should().BeEquivalentTo(input.OwnedVersions); + + // this is the WishlistController filter-probe, not a list-page UI filter (removed) - still real API behavior + var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={uniqueTitle}"); + wishlisted.Items.Should().ContainSingle(m => m.Id == created.Id); } [Fact] @@ -114,16 +93,9 @@ public async Task MovieResourceOwnedFilter_ExcludesItemsWithoutOwnedVersions_IsO var uniqueTitle = $"NotOwnedTarget-{Guid.NewGuid():N}"; var input = new Faker().Rules((f, o) => { o.Title = uniqueTitle; }).Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); - - try - { - var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={uniqueTitle}"); - owned.Items.Should().NotContain(m => m.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var created = await CreateAsync($"/{ResourceEndpoint}", input); + + var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={uniqueTitle}"); + owned.Items.Should().NotContain(m => m.Id == created.Id); } } diff --git a/test/WebApi.IntegrationTests/Resources/PersonReferenceRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/PersonReferenceRepositoryTest.cs index cf88603b..673ca815 100644 --- a/test/WebApi.IntegrationTests/Resources/PersonReferenceRepositoryTest.cs +++ b/test/WebApi.IntegrationTests/Resources/PersonReferenceRepositoryTest.cs @@ -4,6 +4,7 @@ using AwesomeAssertions; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; using Keeptrack.WebApi.IntegrationTests.Hosting; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -16,57 +17,47 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// see the comment in PersonReferenceRepository) is exactly the kind of new, hand-written Mongo /// query that has hidden real bugs in this codebase before. /// -public class PersonReferenceRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +public class PersonReferenceRepositoryTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) { [Fact] public async Task UpsertAsync_ThenFindByExternalIdAsync_RoundTripsAndDeduplicatesByProviderAndId() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); - var tmdbId = Guid.NewGuid().ToString(); + var tmdbId = TestExternalId.New(); var created = await repository.UpsertAsync(new PersonReferenceModel { Name = "Test Actor", ExternalIds = new Dictionary { ["tmdb"] = tmdbId } }); + TrackDocument("person_reference", created.Id); - try - { - var found = await repository.FindByExternalIdAsync("tmdb", tmdbId); - found.Should().NotBeNull(); - found!.Id.Should().Be(created.Id); - found.Name.Should().Be("Test Actor"); - - // upserting again with the same id (simulating a second show crediting the same actor) must - // update the existing document, not create a second one - var updated = await repository.UpsertAsync(new PersonReferenceModel - { - Id = created.Id, - Name = "Test Actor (updated)", - ExternalIds = new Dictionary { ["tmdb"] = tmdbId } - }); + var found = await repository.FindByExternalIdAsync("tmdb", tmdbId); + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); + found.Name.Should().Be("Test Actor"); - updated.Id.Should().Be(created.Id); - (await repository.FindByExternalIdAsync("tmdb", tmdbId))!.Name.Should().Be("Test Actor (updated)"); - } - finally + // upserting again with the same id (simulating a second show crediting the same actor) must + // update the existing document, not create a second one + var updated = await repository.UpsertAsync(new PersonReferenceModel { - var collection = scope.ServiceProvider.GetRequiredService() - .GetCollection("person_reference"); - await collection.DeleteOneAsync( - MongoDB.Driver.Builders.Filter.Eq(x => x.Id, created.Id), - TestContext.Current.CancellationToken); - } + Id = created.Id, + Name = "Test Actor (updated)", + ExternalIds = new Dictionary { ["tmdb"] = tmdbId } + }); + + updated.Id.Should().Be(created.Id); + (await repository.FindByExternalIdAsync("tmdb", tmdbId))!.Name.Should().Be("Test Actor (updated)"); } [Fact] public async Task FindByExternalIdAsync_ReturnsNull_WhenNoPersonHasThatExternalId() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); - var found = await repository.FindByExternalIdAsync("tmdb", Guid.NewGuid().ToString()); + var found = await repository.FindByExternalIdAsync("tmdb", TestExternalId.New()); found.Should().BeNull(); } diff --git a/test/WebApi.IntegrationTests/Resources/PlaylistResourceTest.cs b/test/WebApi.IntegrationTests/Resources/PlaylistResourceTest.cs index 0e079c22..b9de943e 100644 --- a/test/WebApi.IntegrationTests/Resources/PlaylistResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/PlaylistResourceTest.cs @@ -30,26 +30,19 @@ public async Task PlaylistResourceFullCycle_IsOk() var input = new Faker() .Rules((f, o) => { o.Title = f.Random.AlphaNumeric(14); }) .Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); + var created = await CreateAsync($"/{ResourceEndpoint}", input); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.Title = "New shiny title"; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created); + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); - var finalItems = await GetAsync>($"/{ResourceEndpoint}"); - var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); - firstItem.Should().NotBeNull(); - firstItem.Title.Should().Be(updated.Title); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); } [Fact] @@ -57,26 +50,19 @@ public async Task PlaylistResourceUpdate_PersistsSongIdsInOrder_IsOk() { await Authenticate(); - var created = await PostAsync($"/{ResourceEndpoint}", new PlaylistDto { Title = "Order Test Playlist" }); + var created = await CreateAsync($"/{ResourceEndpoint}", new PlaylistDto { Title = "Order Test Playlist" }); - try - { - created.SongIds = ["song-c", "song-a", "song-b"]; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + created.SongIds = ["song-c", "song-a", "song-b"]; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.SongIds.Should().ContainInOrder("song-c", "song-a", "song-b"); + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.SongIds.Should().ContainInOrder("song-c", "song-a", "song-b"); - created.SongIds = ["song-a", "song-b"]; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + created.SongIds = ["song-a", "song-b"]; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - var afterRemoval = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - afterRemoval.SongIds.Should().ContainInOrder("song-a", "song-b"); - afterRemoval.SongIds.Should().HaveCount(2); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var afterRemoval = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + afterRemoval.SongIds.Should().ContainInOrder("song-a", "song-b"); + afterRemoval.SongIds.Should().HaveCount(2); } } diff --git a/test/WebApi.IntegrationTests/Resources/ReferenceDataAdminResourceTest.cs b/test/WebApi.IntegrationTests/Resources/ReferenceDataAdminResourceTest.cs index a05c7b26..d1b325e4 100644 --- a/test/WebApi.IntegrationTests/Resources/ReferenceDataAdminResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/ReferenceDataAdminResourceTest.cs @@ -4,6 +4,7 @@ using System.Net; using System.Threading.Tasks; using AwesomeAssertions; +using Keeptrack.Infrastructure.MongoDb.Entities; using Keeptrack.WebApi.Contracts.Dto; using Keeptrack.WebApi.IntegrationTests.Hosting; using Xunit; @@ -65,6 +66,8 @@ public async Task SyncNow_StartsAJob_AndStatusIsQueryable() var job = await PostAsync("/api/reference-data/sync-now", null, HttpStatusCode.Accepted); job.Should().NotBeNull(); job!.JobId.Should().NotBeEmpty(); + // the job row would otherwise sit in the admin panel's recent-jobs list until the TTL index expires it + TrackDocument("background_job", job.JobId.ToString()); var status = await GetAsync($"/api/reference-data/sync-now/{job.JobId}"); status.Stage.Should().NotBe(ReferenceSyncStage.Failed, status.ErrorMessage); @@ -86,6 +89,7 @@ public async Task SyncNow_PollingReachesACompletedResult() var job = await PostAsync("/api/reference-data/sync-now", null, HttpStatusCode.Accepted); job.Should().NotBeNull(); job!.JobId.Should().NotBeEmpty(); + TrackDocument("background_job", job.JobId.ToString()); var deadline = DateTime.UtcNow + PollTimeout; ReferenceSyncJobStatusDto status; diff --git a/test/WebApi.IntegrationTests/Resources/ReferenceDataExportImportTest.cs b/test/WebApi.IntegrationTests/Resources/ReferenceDataExportImportTest.cs index 2b183090..d34ed9a7 100644 --- a/test/WebApi.IntegrationTests/Resources/ReferenceDataExportImportTest.cs +++ b/test/WebApi.IntegrationTests/Resources/ReferenceDataExportImportTest.cs @@ -8,7 +8,6 @@ using Keeptrack.Infrastructure.MongoDb.Entities; using Keeptrack.WebApi.IntegrationTests.Hosting; using Microsoft.Extensions.DependencyInjection; -using MongoDB.Driver; using Xunit; namespace Keeptrack.WebApi.IntegrationTests.Resources; @@ -18,12 +17,12 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// real MongoDB, and confirms re-upserting an already-exported document (the zip import path) is a true /// no-op the second time - the whole point of "idempotent" for POST /api/reference-data/import. /// -public class ReferenceDataExportImportTest(KestrelWebAppFactory factory) : IClassFixture> +public class ReferenceDataExportImportTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) { [Fact] public async Task TvShowReferenceRepository_FindAllAsync_IncludesEveryDocument() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Export Test Show {Guid.NewGuid()}"; @@ -32,62 +31,51 @@ public async Task TvShowReferenceRepository_FindAllAsync_IncludesEveryDocument() Title = title, TitleNormalized = title.ToLowerInvariant(), Year = 2020, - ExternalIds = new Dictionary { ["tmdb"] = "1" } + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() } }); + TrackDocument("tvshow_reference", created.Id); - try - { - var all = await repository.FindAllAsync(); + var all = await repository.FindAllAsync(); - all.Should().Contain(m => m.Id == created.Id && m.Title == title); - } - finally - { - await DeleteAsync(scope, "tvshow_reference", created.Id!); - } + all.Should().Contain(m => m.Id == created.Id && m.Title == title); } [Fact] public async Task TvShowReferenceRepository_ReimportingTheSameExportedDocument_IsANoOp() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Reimport Test Show {Guid.NewGuid()}"; + var externalId = TestExternalId.New(); var created = await repository.UpsertAsync(new TvShowReferenceModel { Title = title, TitleNormalized = title.ToLowerInvariant(), Year = 2020, - ExternalIds = new Dictionary { ["tmdb"] = "1" } + ExternalIds = new Dictionary { ["tmdb"] = externalId } }); + TrackDocument("tvshow_reference", created.Id); - try - { - // simulates re-running an import of a previously exported document: same id, same content - await repository.UpsertAsync(new TvShowReferenceModel - { - Id = created.Id, - Title = title, - TitleNormalized = title.ToLowerInvariant(), - Year = 2020, - ExternalIds = new Dictionary { ["tmdb"] = "1" } - }); - - var all = await repository.FindAllAsync(); - - all.Count(m => m.Id == created.Id).Should().Be(1); - } - finally + // simulates re-running an import of a previously exported document: same id, same content + await repository.UpsertAsync(new TvShowReferenceModel { - await DeleteAsync(scope, "tvshow_reference", created.Id!); - } + Id = created.Id, + Title = title, + TitleNormalized = title.ToLowerInvariant(), + Year = 2020, + ExternalIds = new Dictionary { ["tmdb"] = externalId } + }); + + var all = await repository.FindAllAsync(); + + all.Count(m => m.Id == created.Id).Should().Be(1); } [Fact] public async Task MovieReferenceRepository_FindAllAsync_IncludesEveryDocument() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Export Test Movie {Guid.NewGuid()}"; @@ -96,50 +84,37 @@ public async Task MovieReferenceRepository_FindAllAsync_IncludesEveryDocument() Title = title, TitleNormalized = title.ToLowerInvariant(), Year = 2020, - ExternalIds = new Dictionary { ["tmdb"] = "1" } + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() } }); + TrackDocument("movie_reference", created.Id); - try - { - var all = await repository.FindAllAsync(); + var all = await repository.FindAllAsync(); - all.Should().Contain(m => m.Id == created.Id && m.Title == title); - } - finally - { - await DeleteAsync(scope, "movie_reference", created.Id!); - } + all.Should().Contain(m => m.Id == created.Id && m.Title == title); } [Fact] public async Task PersonReferenceRepository_FindAllAsync_IncludesEveryDocument() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); - var tmdbId = Guid.NewGuid().ToString(); var created = await repository.UpsertAsync(new PersonReferenceModel { Name = "Export Test Actor", - ExternalIds = new Dictionary { ["tmdb"] = tmdbId } + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() } }); + TrackDocument("person_reference", created.Id); - try - { - var all = await repository.FindAllAsync(); + var all = await repository.FindAllAsync(); - all.Should().Contain(p => p.Id == created.Id && p.Name == "Export Test Actor"); - } - finally - { - await DeleteAsync(scope, "person_reference", created.Id!); - } + all.Should().Contain(p => p.Id == created.Id && p.Name == "Export Test Actor"); } [Fact] public async Task BookReferenceRepository_FindAllAsync_IncludesEveryDocument() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Export Test Book {Guid.NewGuid()}"; @@ -148,25 +123,19 @@ public async Task BookReferenceRepository_FindAllAsync_IncludesEveryDocument() Title = title, TitleNormalized = title.ToLowerInvariant(), Year = 2020, - ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" } + ExternalIds = new Dictionary { ["openlibrary"] = TestExternalId.New() } }); + TrackDocument("book_reference", created.Id); - try - { - var all = await repository.FindAllAsync(); + var all = await repository.FindAllAsync(); - all.Should().Contain(m => m.Id == created.Id && m.Title == title); - } - finally - { - await DeleteAsync(scope, "book_reference", created.Id!); - } + all.Should().Contain(m => m.Id == created.Id && m.Title == title); } [Fact] public async Task VideoGameReferenceRepository_FindAllAsync_IncludesEveryDocument() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Export Test Game {Guid.NewGuid()}"; @@ -175,25 +144,19 @@ public async Task VideoGameReferenceRepository_FindAllAsync_IncludesEveryDocumen Title = title, TitleNormalized = title.ToLowerInvariant(), Year = 2020, - ExternalIds = new Dictionary { ["rawg"] = "1" } + ExternalIds = new Dictionary { ["rawg"] = TestExternalId.New() } }); + TrackDocument("videogame_reference", created.Id); - try - { - var all = await repository.FindAllAsync(); + var all = await repository.FindAllAsync(); - all.Should().Contain(m => m.Id == created.Id && m.Title == title); - } - finally - { - await DeleteAsync(scope, "videogame_reference", created.Id!); - } + all.Should().Contain(m => m.Id == created.Id && m.Title == title); } [Fact] public async Task AlbumReferenceRepository_FindAllAsync_IncludesEveryDocument() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Export Test Album {Guid.NewGuid()}"; @@ -202,24 +165,12 @@ public async Task AlbumReferenceRepository_FindAllAsync_IncludesEveryDocument() Title = title, TitleNormalized = title.ToLowerInvariant(), Year = 2020, - ExternalIds = new Dictionary { ["discogs"] = "1" } + ExternalIds = new Dictionary { ["discogs"] = TestExternalId.New() } }); + TrackDocument("album_reference", created.Id); - try - { - var all = await repository.FindAllAsync(); + var all = await repository.FindAllAsync(); - all.Should().Contain(m => m.Id == created.Id && m.Title == title); - } - finally - { - await DeleteAsync(scope, "album_reference", created.Id!); - } - } - - private static async Task DeleteAsync(IServiceScope scope, string collectionName, string id) where TEntity : class - { - var collection = scope.ServiceProvider.GetRequiredService().GetCollection(collectionName); - await collection.DeleteOneAsync(Builders.Filter.Eq("_id", id), TestContext.Current.CancellationToken); + all.Should().Contain(m => m.Id == created.Id && m.Title == title); } } diff --git a/test/WebApi.IntegrationTests/Resources/RefreshReferenceResourceTest.cs b/test/WebApi.IntegrationTests/Resources/RefreshReferenceResourceTest.cs index 9fd0954b..32f00350 100644 --- a/test/WebApi.IntegrationTests/Resources/RefreshReferenceResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/RefreshReferenceResourceTest.cs @@ -10,7 +10,6 @@ using Keeptrack.WebApi.Contracts.Dto; using Keeptrack.WebApi.IntegrationTests.Hosting; using Microsoft.Extensions.DependencyInjection; -using MongoDB.Driver; using Xunit; namespace Keeptrack.WebApi.IntegrationTests.Resources; @@ -36,27 +35,20 @@ public async Task RefreshReference_LinksTvShow_WhenAnExistingReferenceMatchesByT Title = "Canonical Title", TitleNormalized = "canonical title", Year = year, - ExternalIds = new Dictionary { ["tmdb"] = "1" }, + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() }, // the reference is only found by the show's own (title, year) via its aliases - a real reference // resolved from this show would carry exactly this alias (see MatchedAliases / TryLinkExisting...) MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year }] }); + TrackDocument("tvshow_reference", reference.Id); await Authenticate(); - var created = await PostAsync("/api/tv-shows", new TvShowDto { Title = title, Year = year }); + var created = await CreateAsync("/api/tv-shows", new TvShowDto { Title = title, Year = year }); - try - { - var refreshed = await PostAsync($"/api/tv-shows/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + var refreshed = await PostAsync($"/api/tv-shows/{created.Id}/refresh-reference", null, HttpStatusCode.OK); - refreshed!.ReferenceId.Should().Be(reference.Id); - refreshed.Title.Should().Be("Canonical Title"); - } - finally - { - await DeleteAsync($"/api/tv-shows/{created.Id}"); - await DeleteReferenceAsync(scope, "tvshow_reference", reference.Id!); - } + refreshed!.ReferenceId.Should().Be(reference.Id); + refreshed.Title.Should().Be("Canonical Title"); } [Fact] @@ -64,18 +56,11 @@ public async Task RefreshReference_LeavesTvShowUnresolved_WhenNoMatchingReferenc { await Authenticate(); var title = $"Refresh Reference No Match {Guid.NewGuid()}"; - var created = await PostAsync("/api/tv-shows", new TvShowDto { Title = title, Year = 2019 }); + var created = await CreateAsync("/api/tv-shows", new TvShowDto { Title = title, Year = 2019 }); - try - { - var refreshed = await PostAsync($"/api/tv-shows/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + var refreshed = await PostAsync($"/api/tv-shows/{created.Id}/refresh-reference", null, HttpStatusCode.OK); - refreshed!.ReferenceId.Should().BeNullOrEmpty(); - } - finally - { - await DeleteAsync($"/api/tv-shows/{created.Id}"); - } + refreshed!.ReferenceId.Should().BeNullOrEmpty(); } [Fact] @@ -91,25 +76,18 @@ public async Task RefreshReference_LinksMovie_WhenAnExistingReferenceMatchesByTi Title = "Canonical Movie Title", TitleNormalized = "canonical movie title", Year = year, - ExternalIds = new Dictionary { ["tmdb"] = "1" }, + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year }] }); + TrackDocument("movie_reference", reference.Id); await Authenticate(); - var created = await PostAsync("/api/movies", new MovieDto { Title = title, Year = year }); + var created = await CreateAsync("/api/movies", new MovieDto { Title = title, Year = year }); - try - { - var refreshed = await PostAsync($"/api/movies/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + var refreshed = await PostAsync($"/api/movies/{created.Id}/refresh-reference", null, HttpStatusCode.OK); - refreshed!.ReferenceId.Should().Be(reference.Id); - refreshed.Title.Should().Be("Canonical Movie Title"); - } - finally - { - await DeleteAsync($"/api/movies/{created.Id}"); - await DeleteReferenceAsync(scope, "movie_reference", reference.Id!); - } + refreshed!.ReferenceId.Should().Be(reference.Id); + refreshed.Title.Should().Be("Canonical Movie Title"); } [Fact] @@ -125,26 +103,19 @@ public async Task RefreshReference_LinksBook_WhenAnExistingReferenceMatchesByTit Title = "Canonical Book Title", TitleNormalized = "canonical book title", Year = year, - ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" }, + ExternalIds = new Dictionary { ["openlibrary"] = TestExternalId.New() }, // book/album aliases also carry the normalized creator - the lookup matches title+year+creator MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year, Creator = TitleNormalizer.Normalize("Some Author") }] }); + TrackDocument("book_reference", reference.Id); await Authenticate(); - var created = await PostAsync("/api/books", new BookDto { Title = title, Author = "Some Author", Year = year }); + var created = await CreateAsync("/api/books", new BookDto { Title = title, Author = "Some Author", Year = year }); - try - { - var refreshed = await PostAsync($"/api/books/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + var refreshed = await PostAsync($"/api/books/{created.Id}/refresh-reference", null, HttpStatusCode.OK); - refreshed!.ReferenceId.Should().Be(reference.Id); - refreshed.Title.Should().Be("Canonical Book Title"); - } - finally - { - await DeleteAsync($"/api/books/{created.Id}"); - await DeleteReferenceAsync(scope, "book_reference", reference.Id!); - } + refreshed!.ReferenceId.Should().Be(reference.Id); + refreshed.Title.Should().Be("Canonical Book Title"); } [Fact] @@ -160,25 +131,18 @@ public async Task RefreshReference_LinksVideoGame_WhenAnExistingReferenceMatches Title = "Canonical Game Title", TitleNormalized = "canonical game title", Year = year, - ExternalIds = new Dictionary { ["rawg"] = "1" }, + ExternalIds = new Dictionary { ["rawg"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year }] }); + TrackDocument("videogame_reference", reference.Id); await Authenticate(); - var created = await PostAsync("/api/video-games", new VideoGameDto { Title = title, Year = year }); + var created = await CreateAsync("/api/video-games", new VideoGameDto { Title = title, Year = year }); - try - { - var refreshed = await PostAsync($"/api/video-games/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + var refreshed = await PostAsync($"/api/video-games/{created.Id}/refresh-reference", null, HttpStatusCode.OK); - refreshed!.ReferenceId.Should().Be(reference.Id); - refreshed.Title.Should().Be("Canonical Game Title"); - } - finally - { - await DeleteAsync($"/api/video-games/{created.Id}"); - await DeleteReferenceAsync(scope, "videogame_reference", reference.Id!); - } + refreshed!.ReferenceId.Should().Be(reference.Id); + refreshed.Title.Should().Be("Canonical Game Title"); } [Fact] @@ -194,30 +158,17 @@ public async Task RefreshReference_LinksAlbum_WhenAnExistingReferenceMatchesByTi Title = "Canonical Album Title", TitleNormalized = "canonical album title", Year = year, - ExternalIds = new Dictionary { ["discogs"] = "1" }, + ExternalIds = new Dictionary { ["discogs"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year, Creator = TitleNormalizer.Normalize("Some Artist") }] }); + TrackDocument("album_reference", reference.Id); await Authenticate(); - var created = await PostAsync("/api/albums", new AlbumDto { Title = title, Artist = "Some Artist", Year = year }); + var created = await CreateAsync("/api/albums", new AlbumDto { Title = title, Artist = "Some Artist", Year = year }); - try - { - var refreshed = await PostAsync($"/api/albums/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + var refreshed = await PostAsync($"/api/albums/{created.Id}/refresh-reference", null, HttpStatusCode.OK); - refreshed!.ReferenceId.Should().Be(reference.Id); - refreshed.Title.Should().Be("Canonical Album Title"); - } - finally - { - await DeleteAsync($"/api/albums/{created.Id}"); - await DeleteReferenceAsync(scope, "album_reference", reference.Id!); - } - } - - private static async Task DeleteReferenceAsync(IServiceScope scope, string collectionName, string id) where TEntity : class - { - var collection = scope.ServiceProvider.GetRequiredService().GetCollection(collectionName); - await collection.DeleteOneAsync(Builders.Filter.Eq("_id", id), TestContext.Current.CancellationToken); + refreshed!.ReferenceId.Should().Be(reference.Id); + refreshed.Title.Should().Be("Canonical Album Title"); } } diff --git a/test/WebApi.IntegrationTests/Resources/ResourceTestBase.cs b/test/WebApi.IntegrationTests/Resources/ResourceTestBase.cs index 9094182a..f964ba55 100644 --- a/test/WebApi.IntegrationTests/Resources/ResourceTestBase.cs +++ b/test/WebApi.IntegrationTests/Resources/ResourceTestBase.cs @@ -6,37 +6,90 @@ using System.Text.Json; using System.Threading.Tasks; using AwesomeAssertions; +using Keeptrack.Common.System; using Keeptrack.Testing.Shared.Firebase; using Keeptrack.WebApi.IntegrationTests.Hosting; -using Xunit; namespace Keeptrack.WebApi.IntegrationTests.Resources; public abstract class ResourceTestBase(KestrelWebAppFactory factory) - : IClassFixture>, IAsyncLifetime + : DatabaseTestBase(factory) { private const string MediaTypeJson = "application/json"; + private HttpClient _httpClient = null!; + + public override ValueTask InitializeAsync() + { + _httpClient = new HttpClient { BaseAddress = new Uri(Factory.ServerAddress) }; + return base.InitializeAsync(); + } + + public override async ValueTask DisposeAsync() + { + try + { + // the registered cleanups are HTTP calls on this very client, so it has to outlive them + await base.DisposeAsync(); + } + finally + { + _httpClient?.Dispose(); + } + } + /// - /// Exposes the factory to subclasses that also need a DI scope (e.g. to seed data directly via a - /// repository) alongside the HTTP helpers below - avoids a second, redundant capture of the same - /// constructor parameter as its own field. + /// Registers a resource created over the API for deletion when the test ends. Prefer + /// , which does this for you; use this directly for the endpoints whose + /// create response isn't the resource itself (an import commit, a share grant read back from a list). + /// + /// The delete is status-agnostic on purpose: plenty of tests delete their own subject as part of what + /// they assert (revoking a share, the cascade-delete cases), and a cleanup that insisted on 204 would + /// turn that correct behavior into a failure. + /// /// - protected KestrelWebAppFactory Factory => factory; + protected void TrackResource(string resourceEndpoint, string? id) + { + if (string.IsNullOrEmpty(id)) return; - private HttpClient _httpClient = null!; + TrackCleanup(async () => + { + using var response = await _httpClient.DeleteAsync($"{resourceEndpoint.TrimEnd('/')}/{id}"); + }); + } - public ValueTask InitializeAsync() + /// + /// Registers a "delete whatever this endpoint lists for this search term" cleanup. + /// + /// This is the import endpoints' shape: a commit creates items the test never learns the ids of, so the + /// only handle on them is the (unique, synthetic) title the fixture used. Registered before the commit + /// rather than after, so a partially-successful commit is still cleaned up. Every list endpoint shares + /// the one PagedResult shape, so this single helper serves all of them. + /// + /// + protected void TrackResourcesMatching(string resourceEndpoint, string searchTerm) + where TDto : IHasId { - _httpClient = new HttpClient { BaseAddress = new Uri(factory.ServerAddress) }; - return ValueTask.CompletedTask; + TrackCleanup(async () => + { + var page = await GetAsync>($"{resourceEndpoint}?search={Uri.EscapeDataString(searchTerm)}"); + foreach (var item in page.Items) + { + TrackResource(resourceEndpoint, item.Id); + } + }); } - public ValueTask DisposeAsync() + /// + /// Posts a new resource and registers it for deletion in one step, so there is no window in which a + /// created item isn't yet tracked. This is the shape almost every test wants. + /// + protected async Task CreateAsync(string resourceEndpoint, T body, HttpStatusCode httpStatusCode = HttpStatusCode.Created) + where T : IHasId { - _httpClient?.Dispose(); - GC.SuppressFinalize(this); - return ValueTask.CompletedTask; + var created = await PostAsync(resourceEndpoint, body, httpStatusCode); + TrackResource(resourceEndpoint, created.Id); + return created; } protected async Task GetAsync(string url, HttpStatusCode httpStatusCode = HttpStatusCode.OK) @@ -128,6 +181,13 @@ protected async Task DeleteAsync(string url, HttpStatusCode httpStatusCode = Htt await response.Content.ReadAsStringAsync(); } + /// + /// The signed-in caller's Firebase uid - the same value the API stamps as OwnerId on everything + /// this test creates (see ControllerBaseExtensions.GetUserId). Available after + /// , for the few cleanups that can only identify a document by its owner. + /// + protected string AuthenticatedUserId { get; private set; } = ""; + protected async Task Authenticate() { var token = await AccountRepository.AuthenticateAsync( @@ -135,5 +195,19 @@ protected async Task Authenticate() _httpClient.DefaultRequestHeaders.Clear(); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + AuthenticatedUserId = ReadUserIdClaim(token!); + } + + /// + /// Reads the user_id claim straight out of the token's payload segment. No signature check: the + /// API already validates the token on every call, and this only needs the same identity the server will + /// derive, to scope a cleanup by owner. + /// + private static string ReadUserIdClaim(string token) + { + var payload = token.Split('.')[1]; + var padded = payload.Replace('-', '+').Replace('_', '/').PadRight((payload.Length + 3) / 4 * 4, '='); + using var document = JsonDocument.Parse(Convert.FromBase64String(padded)); + return document.RootElement.GetProperty("user_id").GetString()!; } } diff --git a/test/WebApi.IntegrationTests/Resources/ShareResourceTest.cs b/test/WebApi.IntegrationTests/Resources/ShareResourceTest.cs index 9dcd2a50..0dac6c44 100644 --- a/test/WebApi.IntegrationTests/Resources/ShareResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/ShareResourceTest.cs @@ -33,8 +33,8 @@ public async Task MediaShare_IsReadableWithFilters_AndAddIsDedupSafe_ThenRevocab var ownEmail = FirebaseConfiguration.Username; var tag = Guid.NewGuid().ToString("N"); - var favourite = await PostAsync("/api/movies", new MovieDto { Title = $"ShareFav-{tag}", Year = 1999, Rating = 5, IsFavorite = true }); - var plain = await PostAsync("/api/movies", new MovieDto { Title = $"SharePlain-{tag}", Year = 2001, Rating = 2 }); + var favourite = await CreateAsync("/api/movies", new MovieDto { Title = $"ShareFav-{tag}", Year = 1999, Rating = 5, IsFavorite = true }); + var plain = await CreateAsync("/api/movies", new MovieDto { Title = $"SharePlain-{tag}", Year = 2001, Rating = 2 }); var share = await PostAsync("/api/shares", new CreateShareRequestDto { @@ -42,42 +42,34 @@ public async Task MediaShare_IsReadableWithFilters_AndAddIsDedupSafe_ThenRevocab IncludedCategories = [ShareCategory.Movies], Label = "Myself" }); - - try - { - // owner sees their own grant; recipient (self) sees the shared collection - (await GetAsync>("/api/shares")).Should().Contain(s => s.Id == share.Id && s.Label == "Myself"); - (await GetAsync>("/api/shared-with-me")) - .Should().Contain(s => s.ShareId == share.Id && s.IncludedCategories.Contains(ShareCategory.Movies)); - - // read the shared movies, searching to isolate this test's items; both are already-in-collection (self-share) - var page = await GetAsync>($"/api/shared-with-me/{share.Id}/movies?search={tag}&sort=rating"); - page.Items.Should().Contain(m => m.Id == favourite.Id).And.Contain(m => m.Id == plain.Id); - page.AlreadyInCollectionIds.Should().Contain(favourite.Id!).And.Contain(plain.Id!); - - // the favourites filter narrows to the sharer's favourite only - var favPage = await GetAsync>($"/api/shared-with-me/{share.Id}/movies?search={tag}&IsFavorite=true"); - favPage.Items.Should().Contain(m => m.Id == favourite.Id).And.NotContain(m => m.Id == plain.Id); - - // adding an item the recipient already owns is dedup-safe: no duplicate, returns the existing item - var copy = await PostAsync>($"/api/shared-with-me/{share.Id}/movies/{favourite.Id}/copy", new { }, HttpStatusCode.OK); - copy.AlreadyInCollection.Should().BeTrue(); - copy.Item.Id.Should().Be(favourite.Id); - - // a category not in scope is an indistinguishable 404 - await GetAsync($"/api/shared-with-me/{share.Id}/tv-shows", HttpStatusCode.NotFound); - - // revoking removes access - await DeleteAsync($"/api/shares/{share.Id}"); - await GetAsync($"/api/shared-with-me/{share.Id}/movies", HttpStatusCode.NotFound); - (await GetAsync>("/api/shared-with-me")).Should().NotContain(s => s.ShareId == share.Id); - } - finally - { - await DeleteAsync($"/api/movies/{favourite.Id}"); - await DeleteAsync($"/api/movies/{plain.Id}"); - await DeleteAsync($"/api/shares/{share.Id}"); - } + TrackResource("/api/shares", share.Id); + + // owner sees their own grant; recipient (self) sees the shared collection + (await GetAsync>("/api/shares")).Should().Contain(s => s.Id == share.Id && s.Label == "Myself"); + (await GetAsync>("/api/shared-with-me")) + .Should().Contain(s => s.ShareId == share.Id && s.IncludedCategories.Contains(ShareCategory.Movies)); + + // read the shared movies, searching to isolate this test's items; both are already-in-collection (self-share) + var page = await GetAsync>($"/api/shared-with-me/{share.Id}/movies?search={tag}&sort=rating"); + page.Items.Should().Contain(m => m.Id == favourite.Id).And.Contain(m => m.Id == plain.Id); + page.AlreadyInCollectionIds.Should().Contain(favourite.Id!).And.Contain(plain.Id!); + + // the favourites filter narrows to the sharer's favourite only + var favPage = await GetAsync>($"/api/shared-with-me/{share.Id}/movies?search={tag}&IsFavorite=true"); + favPage.Items.Should().Contain(m => m.Id == favourite.Id).And.NotContain(m => m.Id == plain.Id); + + // adding an item the recipient already owns is dedup-safe: no duplicate, returns the existing item + var copy = await PostAsync>($"/api/shared-with-me/{share.Id}/movies/{favourite.Id}/copy", new { }, HttpStatusCode.OK); + copy.AlreadyInCollection.Should().BeTrue(); + copy.Item.Id.Should().Be(favourite.Id); + + // a category not in scope is an indistinguishable 404 + await GetAsync($"/api/shared-with-me/{share.Id}/tv-shows", HttpStatusCode.NotFound); + + // revoking removes access + await DeleteAsync($"/api/shares/{share.Id}"); + await GetAsync($"/api/shared-with-me/{share.Id}/movies", HttpStatusCode.NotFound); + (await GetAsync>("/api/shared-with-me")).Should().NotContain(s => s.ShareId == share.Id); } [Fact] @@ -87,8 +79,8 @@ public async Task PersonalShare_IsReadableAsListAndReadOnlyDetail_ButNeverCopyab var ownEmail = FirebaseConfiguration.Username; var tag = Guid.NewGuid().ToString("N"); - var car = await PostAsync("/api/cars", new CarDto { Name = $"ShareCar-{tag}", EnergyType = CarEnergyType.Combustion }); - var entry = await PostAsync("/api/car-history", new CarHistoryDto + var car = await CreateAsync("/api/cars", new CarDto { Name = $"ShareCar-{tag}", EnergyType = CarEnergyType.Combustion }); + var entry = await CreateAsync("/api/car-history", new CarHistoryDto { CarId = car.Id!, HistoryDate = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), @@ -102,30 +94,23 @@ public async Task PersonalShare_IsReadableAsListAndReadOnlyDetail_ButNeverCopyab RecipientEmail = ownEmail, IncludedCategories = [ShareCategory.Cars] }); + TrackResource("/api/shares", share.Id); - try - { - // the shared car appears in the recipient's read-only list - (await GetAsync>($"/api/shared-with-me/{share.Id}/cars")) - .Should().Contain(c => c.Id == car.Id && c.Name == $"ShareCar-{tag}"); - - // the read-only detail returns the parent, its full history and computed metrics - var detail = await GetAsync>($"/api/shared-with-me/{share.Id}/cars/{car.Id}"); - detail.Parent.Id.Should().Be(car.Id); - detail.Children.Should().Contain(h => h.Id == entry.Id); - detail.Metrics.Should().NotBeNull(); - - // a personal category not in this grant is an indistinguishable 404 - await GetAsync($"/api/shared-with-me/{share.Id}/houses", HttpStatusCode.NotFound); - - // personal data is never copyable - there is deliberately no copy route for it - await PostNoContentAsync($"/api/shared-with-me/{share.Id}/cars/{car.Id}/copy", new { }, HttpStatusCode.NotFound); - } - finally - { - await DeleteAsync($"/api/shares/{share.Id}"); - await DeleteAsync($"/api/cars/{car.Id}"); - } + // the shared car appears in the recipient's read-only list + (await GetAsync>($"/api/shared-with-me/{share.Id}/cars")) + .Should().Contain(c => c.Id == car.Id && c.Name == $"ShareCar-{tag}"); + + // the read-only detail returns the parent, its full history and computed metrics + var detail = await GetAsync>($"/api/shared-with-me/{share.Id}/cars/{car.Id}"); + detail.Parent.Id.Should().Be(car.Id); + detail.Children.Should().Contain(h => h.Id == entry.Id); + detail.Metrics.Should().NotBeNull(); + + // a personal category not in this grant is an indistinguishable 404 + await GetAsync($"/api/shared-with-me/{share.Id}/houses", HttpStatusCode.NotFound); + + // personal data is never copyable - there is deliberately no copy route for it + await PostNoContentAsync($"/api/shared-with-me/{share.Id}/cars/{car.Id}/copy", new { }, HttpStatusCode.NotFound); } [Fact] @@ -135,42 +120,34 @@ public async Task CollectionShare_IsReadableAsAFilterableList_ButNeverCopyable() var ownEmail = FirebaseConfiguration.Username; var tag = Guid.NewGuid().ToString("N"); - var favourite = await PostAsync("/api/collectibles", new CollectibleDto { Title = $"ShareColFav-{tag}", Brand = "Lego", Year = 2015, IsFavorite = true }); - var plain = await PostAsync("/api/collectibles", new CollectibleDto { Title = $"ShareColPlain-{tag}", Year = 2018 }); + var favourite = await CreateAsync("/api/collectibles", new CollectibleDto { Title = $"ShareColFav-{tag}", Brand = "Lego", Year = 2015, IsFavorite = true }); + var plain = await CreateAsync("/api/collectibles", new CollectibleDto { Title = $"ShareColPlain-{tag}", Year = 2018 }); var share = await PostAsync("/api/shares", new CreateShareRequestDto { RecipientEmail = ownEmail, IncludedCategories = [ShareCategory.Collectibles] }); + TrackResource("/api/shares", share.Id); - try - { - (await GetAsync>("/api/shared-with-me")) - .Should().Contain(s => s.ShareId == share.Id && s.IncludedCategories.Contains(ShareCategory.Collectibles)); - - // the shared collectibles read as a normal paged list, searchable like the owner's own list - var page = await GetAsync>($"/api/shared-with-me/{share.Id}/collectibles?search={tag}"); - page.Items.Should().Contain(c => c.Id == favourite.Id).And.Contain(c => c.Id == plain.Id); - // a view-only category never advertises copy-ability - page.AlreadyInCollectionIds.Should().BeEmpty(); - - // the favourites filter narrows to the favourite only - var favPage = await GetAsync>($"/api/shared-with-me/{share.Id}/collectibles?search={tag}&IsFavorite=true"); - favPage.Items.Should().Contain(c => c.Id == favourite.Id).And.NotContain(c => c.Id == plain.Id); - - // a category not in scope is an indistinguishable 404 - await GetAsync($"/api/shared-with-me/{share.Id}/gear", HttpStatusCode.NotFound); - - // collections are never copyable - there is deliberately no copy route for them - await PostNoContentAsync($"/api/shared-with-me/{share.Id}/collectibles/{favourite.Id}/copy", new { }, HttpStatusCode.NotFound); - } - finally - { - await DeleteAsync($"/api/collectibles/{favourite.Id}"); - await DeleteAsync($"/api/collectibles/{plain.Id}"); - await DeleteAsync($"/api/shares/{share.Id}"); - } + (await GetAsync>("/api/shared-with-me")) + .Should().Contain(s => s.ShareId == share.Id && s.IncludedCategories.Contains(ShareCategory.Collectibles)); + + // the shared collectibles read as a normal paged list, searchable like the owner's own list + var page = await GetAsync>($"/api/shared-with-me/{share.Id}/collectibles?search={tag}"); + page.Items.Should().Contain(c => c.Id == favourite.Id).And.Contain(c => c.Id == plain.Id); + // a view-only category never advertises copy-ability + page.AlreadyInCollectionIds.Should().BeEmpty(); + + // the favourites filter narrows to the favourite only + var favPage = await GetAsync>($"/api/shared-with-me/{share.Id}/collectibles?search={tag}&IsFavorite=true"); + favPage.Items.Should().Contain(c => c.Id == favourite.Id).And.NotContain(c => c.Id == plain.Id); + + // a category not in scope is an indistinguishable 404 + await GetAsync($"/api/shared-with-me/{share.Id}/gear", HttpStatusCode.NotFound); + + // collections are never copyable - there is deliberately no copy route for them + await PostNoContentAsync($"/api/shared-with-me/{share.Id}/collectibles/{favourite.Id}/copy", new { }, HttpStatusCode.NotFound); } [Fact] @@ -183,15 +160,9 @@ public async Task ShareToADifferentEmail_IsNotVisibleToOthers() RecipientEmail = $"not-me-{Guid.NewGuid():N}@example.com", IncludedCategories = [ShareCategory.Movies] }); + TrackResource("/api/shares", share.Id); - try - { - (await GetAsync>("/api/shared-with-me")).Should().NotContain(s => s.ShareId == share.Id); - await GetAsync($"/api/shared-with-me/{share.Id}/movies", HttpStatusCode.NotFound); - } - finally - { - await DeleteAsync($"/api/shares/{share.Id}"); - } + (await GetAsync>("/api/shared-with-me")).Should().NotContain(s => s.ShareId == share.Id); + await GetAsync($"/api/shared-with-me/{share.Id}/movies", HttpStatusCode.NotFound); } } diff --git a/test/WebApi.IntegrationTests/Resources/SongResourceTest.cs b/test/WebApi.IntegrationTests/Resources/SongResourceTest.cs index 34b3cbf0..5b1c97a3 100644 --- a/test/WebApi.IntegrationTests/Resources/SongResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/SongResourceTest.cs @@ -30,26 +30,19 @@ public async Task SongResourceFullCycle_IsOk() var input = new Faker() .Rules((f, o) => { o.Title = f.Random.AlphaNumeric(14); o.Artist = f.Random.AlphaNumeric(8); }) .Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); + var created = await CreateAsync($"/{ResourceEndpoint}", input); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.Title = "New shiny title"; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created); + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); - var finalItems = await GetAsync>($"/{ResourceEndpoint}"); - var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); - firstItem.Should().NotBeNull(); - firstItem.Title.Should().Be(updated.Title); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); } [Fact] @@ -57,17 +50,10 @@ public async Task SongResourceCreate_PersistsAlbumLink_IsOk() { await Authenticate(); - var created = await PostAsync($"/{ResourceEndpoint}", new SongDto { Title = "Time Is Running Out", Artist = "Muse", AlbumId = "some-album-id" }); + var created = await CreateAsync($"/{ResourceEndpoint}", new SongDto { Title = "Time Is Running Out", Artist = "Muse", AlbumId = "some-album-id" }); - try - { - var fetched = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - fetched.AlbumId.Should().Be("some-album-id"); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var fetched = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + fetched.AlbumId.Should().Be("some-album-id"); } /// @@ -81,20 +67,12 @@ public async Task SongResourceSearch_FiltersToMatchingAlbumIdAndTrackPosition_Is await Authenticate(); const string albumId = "shared-album-id"; - var trackOne = await PostAsync($"/{ResourceEndpoint}", new SongDto { Title = "Apocalypse Please", AlbumId = albumId, TrackPosition = "2" }); - var trackTwo = await PostAsync($"/{ResourceEndpoint}", new SongDto { Title = "Time Is Running Out", AlbumId = albumId, TrackPosition = "3" }); + var trackOne = await CreateAsync($"/{ResourceEndpoint}", new SongDto { Title = "Apocalypse Please", AlbumId = albumId, TrackPosition = "2" }); + var trackTwo = await CreateAsync($"/{ResourceEndpoint}", new SongDto { Title = "Time Is Running Out", AlbumId = albumId, TrackPosition = "3" }); - try - { - var results = await GetAsync>($"/{ResourceEndpoint}?AlbumId={albumId}&TrackPosition=3"); + var results = await GetAsync>($"/{ResourceEndpoint}?AlbumId={albumId}&TrackPosition=3"); - results.Items.Should().ContainSingle(x => x.Id == trackTwo.Id); - results.Items.Should().NotContain(x => x.Id == trackOne.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{trackOne.Id}"); - await DeleteAsync($"/{ResourceEndpoint}/{trackTwo.Id}"); - } + results.Items.Should().ContainSingle(x => x.Id == trackTwo.Id); + results.Items.Should().NotContain(x => x.Id == trackOne.Id); } } diff --git a/test/WebApi.IntegrationTests/Resources/StatsResourceTest.cs b/test/WebApi.IntegrationTests/Resources/StatsResourceTest.cs index 3eeba5e1..4459406c 100644 --- a/test/WebApi.IntegrationTests/Resources/StatsResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/StatsResourceTest.cs @@ -28,25 +28,17 @@ public async Task Stats_CountTheCallersItems() { await Authenticate(); - var book = await PostAsync("/api/books", new Faker() + await CreateAsync("/api/books", new Faker() .Rules((f, o) => { o.Title = f.Random.AlphaNumeric(14); o.Author = f.Random.AlphaNumeric(8); }) .Generate()); - var movie = await PostAsync("/api/movies", new Faker() + await CreateAsync("/api/movies", new Faker() .Rules((f, o) => { o.Title = f.Random.AlphaNumeric(14); }) .Generate()); - try - { - var stats = await GetAsync($"/{ResourceEndpoint}"); + var stats = await GetAsync($"/{ResourceEndpoint}"); - // the shared test tenant may hold other tests' in-flight items, so lower bounds only - stats.Books.Should().BeGreaterThanOrEqualTo(1); - stats.Movies.Should().BeGreaterThanOrEqualTo(1); - } - finally - { - await DeleteAsync($"/api/books/{book.Id}"); - await DeleteAsync($"/api/movies/{movie.Id}"); - } + // the shared test tenant may hold other tests' in-flight items, so lower bounds only + stats.Books.Should().BeGreaterThanOrEqualTo(1); + stats.Movies.Should().BeGreaterThanOrEqualTo(1); } } diff --git a/test/WebApi.IntegrationTests/Resources/SystemStatusResourceTest.cs b/test/WebApi.IntegrationTests/Resources/SystemStatusResourceTest.cs index 77153659..9624f614 100644 --- a/test/WebApi.IntegrationTests/Resources/SystemStatusResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/SystemStatusResourceTest.cs @@ -1,6 +1,7 @@ using System.Net; using System.Threading.Tasks; using AwesomeAssertions; +using Keeptrack.Infrastructure.MongoDb.Entities; using Keeptrack.WebApi.Contracts.Dto; using Keeptrack.WebApi.IntegrationTests.Hosting; using Xunit; @@ -46,6 +47,8 @@ public async Task SystemStatus_ListsAJobStartedThroughTheApi() // starting a sync job (its store is shared with imports) must surface in the recent-jobs list var job = await PostAsync("/api/reference-data/sync-now", null, HttpStatusCode.Accepted); job.Should().NotBeNull(); + // the job row would otherwise sit in the admin panel's recent-jobs list until the TTL index expires it + TrackDocument("background_job", job!.JobId.ToString()); var status = await GetAsync($"/{ResourceEndpoint}"); diff --git a/test/WebApi.IntegrationTests/Resources/TestExternalId.cs b/test/WebApi.IntegrationTests/Resources/TestExternalId.cs new file mode 100644 index 00000000..41275693 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/TestExternalId.cs @@ -0,0 +1,21 @@ +using System; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// A provider id for a synthetic reference document, unique to the call. +/// +/// Reference fixtures used to hardcode "1" (or "OL1W"), which is now a correctness problem +/// rather than a cosmetic one: scripts/mongodb-create-index.js makes external_ids.{provider} +/// unique where present, and xunit runs test classes in parallel, so two classes both inserting a +/// tvshow_reference with tmdb: "1" race into a duplicate-key error. A shared constant also meant a +/// single leaked document permanently blocked every later run. +/// +/// +/// The value only has to be opaque and unique - nothing in these tests calls a real provider with it. +/// +/// +internal static class TestExternalId +{ + public static string New() => $"test-{Guid.NewGuid():N}"; +} diff --git a/test/WebApi.IntegrationTests/Resources/TvShowReferenceLinkingTest.cs b/test/WebApi.IntegrationTests/Resources/TvShowReferenceLinkingTest.cs index b3565bfb..d3f70773 100644 --- a/test/WebApi.IntegrationTests/Resources/TvShowReferenceLinkingTest.cs +++ b/test/WebApi.IntegrationTests/Resources/TvShowReferenceLinkingTest.cs @@ -19,22 +19,22 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// container rather than HTTP, since the cross-tenant propagation this proves has nothing to do with /// the calling user's own identity/role. /// -public class TvShowReferenceLinkingTest(KestrelWebAppFactory factory) : IClassFixture> +public class TvShowReferenceLinkingTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) { [Fact] public async Task SetReferenceLinkAsync_UpdatesEveryMatchingTenantsShow_ButNotOthers() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Reference Linking Test Show {Guid.NewGuid()}"; var canonicalTitle = $"Canonical {title}"; const int year = 2021; - var tenantAShow = await repository.CreateAsync(new TvShowModel { OwnerId = "reference-link-tenant-a", Title = title, Year = year }); + var tenantAShow = await CreateShowAsync(repository, new TvShowModel { OwnerId = "reference-link-tenant-a", Title = title, Year = year }); // different casing, different tenant: the match is case-insensitive and crosses tenants by design - var tenantBShow = await repository.CreateAsync(new TvShowModel { OwnerId = "reference-link-tenant-b", Title = title.ToUpperInvariant(), Year = year }); - var differentYearShow = await repository.CreateAsync(new TvShowModel { OwnerId = "reference-link-tenant-a", Title = title, Year = year + 1 }); - var alreadyLinkedShow = await repository.CreateAsync(new TvShowModel + var tenantBShow = await CreateShowAsync(repository, new TvShowModel { OwnerId = "reference-link-tenant-b", Title = title.ToUpperInvariant(), Year = year }); + var differentYearShow = await CreateShowAsync(repository, new TvShowModel { OwnerId = "reference-link-tenant-a", Title = title, Year = year + 1 }); + var alreadyLinkedShow = await CreateShowAsync(repository, new TvShowModel { OwnerId = "reference-link-tenant-c", Title = title, @@ -42,44 +42,34 @@ public async Task SetReferenceLinkAsync_UpdatesEveryMatchingTenantsShow_ButNotOt ReferenceId = "pre-existing-link" }); - try - { - var modifiedCount = await repository.SetReferenceLinkAsync(title, year, "reference-123", canonicalTitle); + var modifiedCount = await repository.SetReferenceLinkAsync(title, year, "reference-123", canonicalTitle); - modifiedCount.Should().Be(2); - var tenantAResult = (await repository.FindOneAsync(tenantAShow.Id!, "reference-link-tenant-a"))!; - tenantAResult.ReferenceId.Should().Be("reference-123"); - // the tenant's own title is replaced with the reference's canonical name, not just the id - tenantAResult.Title.Should().Be(canonicalTitle); - (await repository.FindOneAsync(tenantBShow.Id!, "reference-link-tenant-b"))!.ReferenceId.Should().Be("reference-123"); - // An unset ReferenceId can round-trip as either "" (documents written before the - // AutoMapper -> Mapperly migration) or null (new writes) - BeNullOrEmpty is the correct - // "still unresolved" check that covers both generations. - (await repository.FindOneAsync(differentYearShow.Id!, "reference-link-tenant-a"))!.ReferenceId.Should().BeNullOrEmpty(); - // a show that already has a link is never clobbered by a later automatic/admin resolution - var alreadyLinkedResult = (await repository.FindOneAsync(alreadyLinkedShow.Id!, "reference-link-tenant-c"))!; - alreadyLinkedResult.ReferenceId.Should().Be("pre-existing-link"); - alreadyLinkedResult.Title.Should().Be(title); - } - finally - { - await repository.DeleteAsync(tenantAShow.Id!, "reference-link-tenant-a"); - await repository.DeleteAsync(tenantBShow.Id!, "reference-link-tenant-b"); - await repository.DeleteAsync(differentYearShow.Id!, "reference-link-tenant-a"); - await repository.DeleteAsync(alreadyLinkedShow.Id!, "reference-link-tenant-c"); - } + modifiedCount.Should().Be(2); + var tenantAResult = (await repository.FindOneAsync(tenantAShow.Id!, "reference-link-tenant-a"))!; + tenantAResult.ReferenceId.Should().Be("reference-123"); + // the tenant's own title is replaced with the reference's canonical name, not just the id + tenantAResult.Title.Should().Be(canonicalTitle); + (await repository.FindOneAsync(tenantBShow.Id!, "reference-link-tenant-b"))!.ReferenceId.Should().Be("reference-123"); + // An unset ReferenceId can round-trip as either "" (documents written before the + // AutoMapper -> Mapperly migration) or null (new writes) - BeNullOrEmpty is the correct + // "still unresolved" check that covers both generations. + (await repository.FindOneAsync(differentYearShow.Id!, "reference-link-tenant-a"))!.ReferenceId.Should().BeNullOrEmpty(); + // a show that already has a link is never clobbered by a later automatic/admin resolution + var alreadyLinkedResult = (await repository.FindOneAsync(alreadyLinkedShow.Id!, "reference-link-tenant-c"))!; + alreadyLinkedResult.ReferenceId.Should().Be("pre-existing-link"); + alreadyLinkedResult.Title.Should().Be(title); } [Fact] public async Task FindDistinctUnresolvedTitleYearsAsync_ReturnsDistinctUnlinkedTitleYearPairs() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Unresolved Test Show {Guid.NewGuid()}"; - var showA = await repository.CreateAsync(new TvShowModel { OwnerId = "unresolved-tenant-a", Title = title, Year = 2022 }); - var showB = await repository.CreateAsync(new TvShowModel { OwnerId = "unresolved-tenant-b", Title = title, Year = 2022 }); - var linkedShow = await repository.CreateAsync(new TvShowModel + await CreateShowAsync(repository, new TvShowModel { OwnerId = "unresolved-tenant-a", Title = title, Year = 2022 }); + await CreateShowAsync(repository, new TvShowModel { OwnerId = "unresolved-tenant-b", Title = title, Year = 2022 }); + await CreateShowAsync(repository, new TvShowModel { OwnerId = "unresolved-tenant-c", Title = title, @@ -87,18 +77,16 @@ public async Task FindDistinctUnresolvedTitleYearsAsync_ReturnsDistinctUnlinkedT ReferenceId = "already-linked" }); - try - { - var unresolved = await repository.FindDistinctUnresolvedTitleYearsAsync(); + var unresolved = await repository.FindDistinctUnresolvedTitleYearsAsync(); - // two unlinked shows sharing (title, year) collapse into one queue entry; the already-linked one doesn't appear - unresolved.Should().ContainSingle(p => p.Title == title && p.Year == 2022); - } - finally - { - await repository.DeleteAsync(showA.Id!, "unresolved-tenant-a"); - await repository.DeleteAsync(showB.Id!, "unresolved-tenant-b"); - await repository.DeleteAsync(linkedShow.Id!, "unresolved-tenant-c"); - } + // two unlinked shows sharing (title, year) collapse into one queue entry; the already-linked one doesn't appear + unresolved.Should().ContainSingle(p => p.Title == title && p.Year == 2022); + } + + private async Task CreateShowAsync(ITvShowRepository repository, TvShowModel show) + { + var created = await repository.CreateAsync(show); + TrackCleanup(() => repository.DeleteAsync(created.Id!, show.OwnerId)); + return created; } } diff --git a/test/WebApi.IntegrationTests/Resources/TvShowReferenceRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/TvShowReferenceRepositoryTest.cs index cc8e4d4e..e40d6a26 100644 --- a/test/WebApi.IntegrationTests/Resources/TvShowReferenceRepositoryTest.cs +++ b/test/WebApi.IntegrationTests/Resources/TvShowReferenceRepositoryTest.cs @@ -20,37 +20,30 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// Mongo filter that has hidden real bugs before (see docs/code-quality-findings.md), so it's verified /// against a real database, not mocks. /// -public class TvShowReferenceRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +public class TvShowReferenceRepositoryTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) { [Fact] public async Task FindByTitleYearAsync_MatchesAnAlternateTitle_NotJustTheCanonicalOne() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var alternateTitle = $"Alternate Title {Guid.NewGuid()}"; const int year = 2005; - var created = await repository.UpsertAsync(new TvShowReferenceModel + var created = await CreateReferenceAsync(repository, new TvShowReferenceModel { Title = "Canonical Title", TitleNormalized = "canonical title", Year = year, - ExternalIds = new Dictionary { ["tmdb"] = "1" }, + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = year }] }); - try - { - // case-insensitive: normalization lower-cases before comparing - var found = await repository.FindByTitleYearAsync(alternateTitle.ToUpperInvariant(), year); + // case-insensitive: normalization lower-cases before comparing + var found = await repository.FindByTitleYearAsync(alternateTitle.ToUpperInvariant(), year); - found.Should().NotBeNull(); - found!.Id.Should().Be(created.Id); - } - finally - { - await DeleteAsync(scope, created.Id!); - } + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); } [Fact] @@ -59,129 +52,106 @@ public async Task FindByTitleYearAsync_MatchesAnAliasWhoseConfirmedYearDiffersFr // regression: a single top-level Year scalar AND-ed against the title-array-contains filter would // reject a tenant whose recorded year genuinely differs from whichever year happens to be this // document's own canonical one - year now travels with its specific title variant instead. - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var alternateTitle = $"Alternate Title {Guid.NewGuid()}"; - var created = await repository.UpsertAsync(new TvShowReferenceModel + var created = await CreateReferenceAsync(repository, new TvShowReferenceModel { Title = "Canonical Title", TitleNormalized = "canonical title", Year = 2005, - ExternalIds = new Dictionary { ["tmdb"] = "1" }, + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2004 }] }); - try - { - var found = await repository.FindByTitleYearAsync(alternateTitle, 2004); + var found = await repository.FindByTitleYearAsync(alternateTitle, 2004); - found.Should().NotBeNull(); - found!.Id.Should().Be(created.Id); - } - finally - { - await DeleteAsync(scope, created.Id!); - } + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); } [Fact] public async Task FindByTitleAsync_MatchesAnAlternateTitle_IgnoringYear() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var alternateTitle = $"Alternate Title {Guid.NewGuid()}"; - var created = await repository.UpsertAsync(new TvShowReferenceModel + var created = await CreateReferenceAsync(repository, new TvShowReferenceModel { Title = "Canonical Title", TitleNormalized = "canonical title", Year = 2005, - ExternalIds = new Dictionary { ["tmdb"] = "1" }, + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2005 }] }); - try - { - var found = await repository.FindByTitleAsync(alternateTitle); + var found = await repository.FindByTitleAsync(alternateTitle); - found.Should().NotBeNull(); - found!.Id.Should().Be(created.Id); - } - finally - { - await DeleteAsync(scope, created.Id!); - } + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); } [Fact] public async Task UpsertAsync_PersistsANullCreator_AsAnActualBsonNullNotAnEmptyString() { - // Regression: DataStorageMappingProfile's ReferenceMatchModel -> ReferenceMatch map opts Creator out - // of the profile-wide AllowNullDestinationValues = false (Program.cs), specifically so a null Creator - // (TV show/movie/video game have no creator dimension) round-trips as a real null, not "". Getting - // this wrong once let a null Creator silently become "" on save, which broke MergeMatchedAliases' - // in-memory dedup comparison and duplicated an alias on every re-resolve/re-refresh (confirmed - // against a real video game reference, RAWG's "God of War", that had accumulated an exact duplicate - // this way - see scripts/dedupe-matched-aliases.js). Only a real MongoDB round-trip can catch this; - // a mocked repository never exercises the actual mapper/BSON serialization behavior. - using var scope = factory.Services.CreateScope(); + // Regression: a null Creator (TV show/movie/video game have no creator dimension) has to round-trip + // as a real null, not "". Getting this wrong once let a null Creator silently become "" on save, + // which broke MergeMatchedAliases' in-memory dedup comparison and duplicated an alias on every + // re-resolve/re-refresh (confirmed against a real video game reference, RAWG's "God of War", that had + // accumulated an exact duplicate this way - see scripts/dedupe-matched-aliases.js). Mapperly (the + // current mapper) preserves nulls by default, so this now guards a structural property rather than a + // configuration one - and only a real MongoDB round-trip can check it at all, since a mocked + // repository never exercises the actual mapper/BSON serialization behavior. + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Null Creator Title {Guid.NewGuid()}"; - var created = await repository.UpsertAsync(new TvShowReferenceModel + var created = await CreateReferenceAsync(repository, new TvShowReferenceModel { Title = title, TitleNormalized = title.ToLowerInvariant(), Year = 2010, - ExternalIds = new Dictionary { ["tmdb"] = "1" }, + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = title.ToLowerInvariant(), Year = 2010, Creator = null }] }); - try - { - var collection = scope.ServiceProvider.GetRequiredService().GetCollection("tvshow_reference"); - var stored = await collection.Find(x => x.Id == created.Id).FirstOrDefaultAsync(TestContext.Current.CancellationToken); + var collection = scope.ServiceProvider.GetRequiredService().GetCollection("tvshow_reference"); + var stored = await collection.Find(x => x.Id == created.Id).FirstOrDefaultAsync(TestContext.Current.CancellationToken); - stored.MatchedAliases.Should().ContainSingle(m => m.Creator == null); - } - finally - { - await DeleteAsync(scope, created.Id!); - } + stored.MatchedAliases.Should().ContainSingle(m => m.Creator == null); } [Fact] public async Task UpsertAsync_AlwaysIncludesTheCanonicalTitleAndYearInMatchedAliases_EvenIfTheCallerForgot() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Canonical Only Title {Guid.NewGuid()}"; - var created = await repository.UpsertAsync(new TvShowReferenceModel + var created = await CreateReferenceAsync(repository, new TvShowReferenceModel { Title = title, TitleNormalized = title.ToLowerInvariant(), Year = 2010, - ExternalIds = new Dictionary { ["tmdb"] = "1" } + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() } }); - try - { - var found = await repository.FindByTitleAsync(title); + var found = await repository.FindByTitleAsync(title); - found.Should().NotBeNull(); - found!.Id.Should().Be(created.Id); - } - finally - { - await DeleteAsync(scope, created.Id!); - } + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); } - private static async Task DeleteAsync(IServiceScope scope, string id) + /// + /// Upserts a reference and registers it for deletion in the same step, so no test body can create one + /// without it being cleaned up. + /// + private async Task CreateReferenceAsync(ITvShowReferenceRepository repository, TvShowReferenceModel model) { - var collection = scope.ServiceProvider.GetRequiredService().GetCollection("tvshow_reference"); - await collection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, id), TestContext.Current.CancellationToken); + var created = await repository.UpsertAsync(model); + TrackDocument("tvshow_reference", created.Id); + return created; } } diff --git a/test/WebApi.IntegrationTests/Resources/TvShowResourceTest.cs b/test/WebApi.IntegrationTests/Resources/TvShowResourceTest.cs index 753371d8..037b6cb5 100644 --- a/test/WebApi.IntegrationTests/Resources/TvShowResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/TvShowResourceTest.cs @@ -18,7 +18,7 @@ public async Task TvShowResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItem await Authenticate(); var title = System.Guid.NewGuid().ToString(); - var created = await PostAsync($"/{ResourceEndpoint}", new TvShowDto + var created = await CreateAsync($"/{ResourceEndpoint}", new TvShowDto { Title = title, // "owned" is derived from having at least one owned version, not a stored flag @@ -26,18 +26,11 @@ public async Task TvShowResourceOwnedAndWishlistedFilters_OnlyReturnMatchingItem IsWishlisted = true }); - try - { - var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); - owned.Items.Should().ContainSingle(s => s.Id == created.Id); + var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); + owned.Items.Should().ContainSingle(s => s.Id == created.Id); - // this is the WishlistController filter-probe, not a list-page UI filter (removed) - still real API behavior - var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={title}"); - wishlisted.Items.Should().ContainSingle(s => s.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + // this is the WishlistController filter-probe, not a list-page UI filter (removed) - still real API behavior + var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={title}"); + wishlisted.Items.Should().ContainSingle(s => s.Id == created.Id); } } diff --git a/test/WebApi.IntegrationTests/Resources/TvTimeImportResourceTest.cs b/test/WebApi.IntegrationTests/Resources/TvTimeImportResourceTest.cs index 71c8b0ff..e46ec481 100644 --- a/test/WebApi.IntegrationTests/Resources/TvTimeImportResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/TvTimeImportResourceTest.cs @@ -20,6 +20,14 @@ public async Task ImportTvTime_UpsertsShowsEpisodesAndMovies_AndIsIdempotent() var zip = TvTimeFixtureZipBuilder.Build(); + // the import creates items whose ids this test never sees, so cleanup is keyed on the fixture's own + // synthetic titles - and registered before the import, so a partial import is cleaned up too. Shows + // need the extra hop through their episodes: an episode is a separate top-level document keyed by + // show id, not something a title search can reach. + TrackShowsAndTheirEpisodes(TvTimeFixtureZipBuilder.ShowTitle); + TrackShowsAndTheirEpisodes(TvTimeFixtureZipBuilder.OrphanShowTitle); + TrackResourcesMatching("/api/movies", TvTimeFixtureZipBuilder.MovieTitle); + var firstJob = await PostFileAsync("/api/import/tv-time", "file", zip, "gdpr-data.zip", HttpStatusCode.Accepted); var firstResult = await PollForResultAsync(firstJob.JobId); // ShowTitle (from followed_tv_show.csv) + OrphanShowTitle (has watch history but is absent from @@ -32,82 +40,82 @@ public async Task ImportTvTime_UpsertsShowsEpisodesAndMovies_AndIsIdempotent() // user_tv_show_data.csv reports 5 episodes seen, but only 4 got a watch date from the sources above firstResult.Warnings.Should().Contain(w => w.Contains(TvTimeFixtureZipBuilder.ShowTitle) && w.Contains("4 of 5")); - try - { - var shows = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.ShowTitle)}"); - var show = shows.Items.Should().ContainSingle().Subject; - show.Rating.Should().Be(4.5f); - show.IsFavorite.Should().BeTrue(); - show.Notes.Should().Contain("Great show"); - - var episodes = await GetAsync>($"/api/episodes?TvShowId={show.Id}"); - episodes.Items.Should().HaveCount(4); - // episode notes are date-prefixed by FormatComments (same as show notes above), e.g. "2020-01-02: Great pilot" - episodes.Items.Should().Contain(e => e.SeasonNumber == 1 && e.EpisodeNumber == 1 && e.Notes != null && e.Notes.Contains("Great pilot")); - episodes.Items.Should().Contain(e => e.SeasonNumber == 1 && e.EpisodeNumber == 3); - episodes.Items.Should().Contain(e => e.SeasonNumber == 2 && e.EpisodeNumber == 1); - - // the real-world bug this guards against: a show with genuine watch history but no - // followed_tv_show.csv row must still be created, with its own rating applied by id, and - // its episode imported - not silently skipped with a "wasn't found" warning. - var orphanShows = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.OrphanShowTitle)}"); - var orphanShow = orphanShows.Items.Should().ContainSingle().Subject; - orphanShow.Rating.Should().Be(3.5f); - - var orphanEpisodes = await GetAsync>($"/api/episodes?TvShowId={orphanShow.Id}"); - orphanEpisodes.Items.Should().ContainSingle(e => e.SeasonNumber == 1 && e.EpisodeNumber == 1); - - var movies = await GetAsync>($"/api/movies?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.MovieTitle)}"); - var movie = movies.Items.Should().ContainSingle().Subject; - movie.IsFavorite.Should().BeTrue(); - // the only source of a movie's watched date is a "watch"/"movie" row in tracking-prod-records.csv - - // confirmed against a real export, where the rating/emotion vote files never carry one - movie.FirstSeenAt.Should().Be(new DateOnly(2020, 1, 7)); - - // re-importing the same export must recognize everything by its stable TV Time id and skip it, - // never duplicate - var secondJob = await PostFileAsync("/api/import/tv-time", "file", zip, "gdpr-data.zip", HttpStatusCode.Accepted); - var secondResult = await PollForResultAsync(secondJob.JobId); - secondResult.ShowsCreated.Should().Be(0); - secondResult.ShowsSkipped.Should().Be(2); - secondResult.EpisodesCreated.Should().Be(0); - secondResult.EpisodesSkipped.Should().Be(5); - secondResult.MoviesCreated.Should().Be(0); - secondResult.MoviesSkipped.Should().Be(1); - - var showsAfterReimport = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.ShowTitle)}"); - showsAfterReimport.Items.Should().ContainSingle(); - - var orphanShowsAfterReimport = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.OrphanShowTitle)}"); - orphanShowsAfterReimport.Items.Should().ContainSingle(); - } - finally + var shows = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.ShowTitle)}"); + var show = shows.Items.Should().ContainSingle().Subject; + show.Rating.Should().Be(4.5f); + show.IsFavorite.Should().BeTrue(); + show.Notes.Should().Contain("Great show"); + + var episodes = await GetAsync>($"/api/episodes?TvShowId={show.Id}"); + episodes.Items.Should().HaveCount(4); + // episode notes are date-prefixed by FormatComments (same as show notes above), e.g. "2020-01-02: Great pilot" + episodes.Items.Should().Contain(e => e.SeasonNumber == 1 && e.EpisodeNumber == 1 && e.Notes != null && e.Notes.Contains("Great pilot")); + episodes.Items.Should().Contain(e => e.SeasonNumber == 1 && e.EpisodeNumber == 3); + episodes.Items.Should().Contain(e => e.SeasonNumber == 2 && e.EpisodeNumber == 1); + + // the real-world bug this guards against: a show with genuine watch history but no + // followed_tv_show.csv row must still be created, with its own rating applied by id, and + // its episode imported - not silently skipped with a "wasn't found" warning. + var orphanShows = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.OrphanShowTitle)}"); + var orphanShow = orphanShows.Items.Should().ContainSingle().Subject; + orphanShow.Rating.Should().Be(3.5f); + + var orphanEpisodes = await GetAsync>($"/api/episodes?TvShowId={orphanShow.Id}"); + orphanEpisodes.Items.Should().ContainSingle(e => e.SeasonNumber == 1 && e.EpisodeNumber == 1); + + var movies = await GetAsync>($"/api/movies?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.MovieTitle)}"); + var movie = movies.Items.Should().ContainSingle().Subject; + movie.IsFavorite.Should().BeTrue(); + // the only source of a movie's watched date is a "watch"/"movie" row in tracking-prod-records.csv - + // confirmed against a real export, where the rating/emotion vote files never carry one + movie.FirstSeenAt.Should().Be(new DateOnly(2020, 1, 7)); + + // re-importing the same export must recognize everything by its stable TV Time id and skip it, + // never duplicate + var secondJob = await PostFileAsync("/api/import/tv-time", "file", zip, "gdpr-data.zip", HttpStatusCode.Accepted); + var secondResult = await PollForResultAsync(secondJob.JobId); + secondResult.ShowsCreated.Should().Be(0); + secondResult.ShowsSkipped.Should().Be(2); + secondResult.EpisodesCreated.Should().Be(0); + secondResult.EpisodesSkipped.Should().Be(5); + secondResult.MoviesCreated.Should().Be(0); + secondResult.MoviesSkipped.Should().Be(1); + + var showsAfterReimport = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.ShowTitle)}"); + showsAfterReimport.Items.Should().ContainSingle(); + + var orphanShowsAfterReimport = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.OrphanShowTitle)}"); + orphanShowsAfterReimport.Items.Should().ContainSingle(); + } + + /// + /// Registers every show matching a title, plus each show's episodes, for deletion. Episodes live in + /// their own collection keyed by show id (see CLAUDE.md's "Child entities"), so they have to be + /// enumerated per show rather than found by the same title search. + /// + private void TrackShowsAndTheirEpisodes(string title) + { + TrackCleanup(async () => { - foreach (var title in new[] { TvTimeFixtureZipBuilder.ShowTitle, TvTimeFixtureZipBuilder.OrphanShowTitle }) + var shows = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(title)}"); + foreach (var show in shows.Items) { - var shows = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(title)}"); - foreach (var show in shows.Items) + var episodes = await GetAsync>($"/api/episodes?TvShowId={show.Id}"); + foreach (var episode in episodes.Items) { - var episodes = await GetAsync>($"/api/episodes?TvShowId={show.Id}"); - foreach (var episode in episodes.Items.Where(e => e.Id is not null)) - { - await DeleteAsync($"/api/episodes/{episode.Id}"); - } - - await DeleteAsync($"/api/tv-shows/{show.Id}"); + TrackResource("/api/episodes", episode.Id); } - } - var movies = await GetAsync>($"/api/movies?search={Uri.EscapeDataString(TvTimeFixtureZipBuilder.MovieTitle)}"); - foreach (var movie in movies.Items.Where(m => m.Id is not null)) - { - await DeleteAsync($"/api/movies/{movie.Id}"); + TrackResource("/api/tv-shows", show.Id); } - } + }); } private async Task PollForResultAsync(Guid jobId) { + // the job row outlives the import itself (TTL-expired after a week), so it's cleaned up too + TrackDocument("background_job", jobId.ToString()); + for (var attempt = 0; attempt < 100; attempt++) { var status = await GetAsync($"/api/import/tv-time/{jobId}"); diff --git a/test/WebApi.IntegrationTests/Resources/UnlinkReferenceResourceTest.cs b/test/WebApi.IntegrationTests/Resources/UnlinkReferenceResourceTest.cs index 7da16b4d..4943dbd2 100644 --- a/test/WebApi.IntegrationTests/Resources/UnlinkReferenceResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/UnlinkReferenceResourceTest.cs @@ -6,6 +6,7 @@ using Keeptrack.Common.System; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; using Keeptrack.WebApi.Contracts.Dto; using Keeptrack.WebApi.IntegrationTests.Hosting; using Microsoft.Extensions.DependencyInjection; @@ -21,6 +22,11 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// satisfies the endpoint's AdminOnly policy - the policy attribute itself is covered by a /// reflection unit test instead (an HTTP 403 test would need a second, non-admin Firebase account this /// suite doesn't have configured). +/// +/// Each reference document is registered for deletion even though the endpoint under test is supposed to +/// delete it: the registration is what covers the run where the endpoint *doesn't*, which is the +/// regression these tests exist to catch. Deleting an already-deleted document is a no-op. +/// /// public class UnlinkReferenceResourceTest(KestrelWebAppFactory factory) : ResourceTestBase(factory) @@ -38,25 +44,19 @@ public async Task UnlinkReference_ClearsTvShowLink_AndDeletesTheReferenceDocumen Title = "Canonical Title", TitleNormalized = "canonical title", Year = year, - ExternalIds = new Dictionary { ["tmdb"] = "1" }, + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year }] }); + TrackDocument("tvshow_reference", reference.Id); await Authenticate(); - var created = await PostAsync("/api/tv-shows", new TvShowDto { Title = title, Year = year }); + var created = await CreateAsync("/api/tv-shows", new TvShowDto { Title = title, Year = year }); await PostAsync($"/api/tv-shows/{created.Id}/refresh-reference", null, HttpStatusCode.OK); - try - { - var unlinked = await PostAsync($"/api/tv-shows/{created.Id}/unlink-reference", null, HttpStatusCode.OK); + var unlinked = await PostAsync($"/api/tv-shows/{created.Id}/unlink-reference", null, HttpStatusCode.OK); - unlinked!.ReferenceId.Should().BeNullOrEmpty(); - (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); - } - finally - { - await DeleteAsync($"/api/tv-shows/{created.Id}"); - } + unlinked!.ReferenceId.Should().BeNullOrEmpty(); + (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); } [Fact] @@ -72,25 +72,19 @@ public async Task UnlinkReference_ClearsMovieLink_AndDeletesTheReferenceDocument Title = "Canonical Movie Title", TitleNormalized = "canonical movie title", Year = year, - ExternalIds = new Dictionary { ["tmdb"] = "1" }, + ExternalIds = new Dictionary { ["tmdb"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year }] }); + TrackDocument("movie_reference", reference.Id); await Authenticate(); - var created = await PostAsync("/api/movies", new MovieDto { Title = title, Year = year }); + var created = await CreateAsync("/api/movies", new MovieDto { Title = title, Year = year }); await PostAsync($"/api/movies/{created.Id}/refresh-reference", null, HttpStatusCode.OK); - try - { - var unlinked = await PostAsync($"/api/movies/{created.Id}/unlink-reference", null, HttpStatusCode.OK); + var unlinked = await PostAsync($"/api/movies/{created.Id}/unlink-reference", null, HttpStatusCode.OK); - unlinked!.ReferenceId.Should().BeNullOrEmpty(); - (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); - } - finally - { - await DeleteAsync($"/api/movies/{created.Id}"); - } + unlinked!.ReferenceId.Should().BeNullOrEmpty(); + (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); } [Fact] @@ -106,25 +100,19 @@ public async Task UnlinkReference_ClearsBookLink_AndDeletesTheReferenceDocument( Title = "Canonical Book Title", TitleNormalized = "canonical book title", Year = year, - ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" }, + ExternalIds = new Dictionary { ["openlibrary"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year, Creator = TitleNormalizer.Normalize("Some Author") }] }); + TrackDocument("book_reference", reference.Id); await Authenticate(); - var created = await PostAsync("/api/books", new BookDto { Title = title, Author = "Some Author", Year = year }); + var created = await CreateAsync("/api/books", new BookDto { Title = title, Author = "Some Author", Year = year }); await PostAsync($"/api/books/{created.Id}/refresh-reference", null, HttpStatusCode.OK); - try - { - var unlinked = await PostAsync($"/api/books/{created.Id}/unlink-reference", null, HttpStatusCode.OK); + var unlinked = await PostAsync($"/api/books/{created.Id}/unlink-reference", null, HttpStatusCode.OK); - unlinked!.ReferenceId.Should().BeNullOrEmpty(); - (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); - } - finally - { - await DeleteAsync($"/api/books/{created.Id}"); - } + unlinked!.ReferenceId.Should().BeNullOrEmpty(); + (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); } [Fact] @@ -140,25 +128,19 @@ public async Task UnlinkReference_ClearsVideoGameLink_AndDeletesTheReferenceDocu Title = "Canonical Game Title", TitleNormalized = "canonical game title", Year = year, - ExternalIds = new Dictionary { ["rawg"] = "1" }, + ExternalIds = new Dictionary { ["rawg"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year }] }); + TrackDocument("videogame_reference", reference.Id); await Authenticate(); - var created = await PostAsync("/api/video-games", new VideoGameDto { Title = title, Year = year }); + var created = await CreateAsync("/api/video-games", new VideoGameDto { Title = title, Year = year }); await PostAsync($"/api/video-games/{created.Id}/refresh-reference", null, HttpStatusCode.OK); - try - { - var unlinked = await PostAsync($"/api/video-games/{created.Id}/unlink-reference", null, HttpStatusCode.OK); + var unlinked = await PostAsync($"/api/video-games/{created.Id}/unlink-reference", null, HttpStatusCode.OK); - unlinked!.ReferenceId.Should().BeNullOrEmpty(); - (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); - } - finally - { - await DeleteAsync($"/api/video-games/{created.Id}"); - } + unlinked!.ReferenceId.Should().BeNullOrEmpty(); + (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); } [Fact] @@ -174,24 +156,18 @@ public async Task UnlinkReference_ClearsAlbumLink_AndDeletesTheReferenceDocument Title = "Canonical Album Title", TitleNormalized = "canonical album title", Year = year, - ExternalIds = new Dictionary { ["discogs"] = "1" }, + ExternalIds = new Dictionary { ["discogs"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year, Creator = TitleNormalizer.Normalize("Some Artist") }] }); + TrackDocument("album_reference", reference.Id); await Authenticate(); - var created = await PostAsync("/api/albums", new AlbumDto { Title = title, Artist = "Some Artist", Year = year }); + var created = await CreateAsync("/api/albums", new AlbumDto { Title = title, Artist = "Some Artist", Year = year }); await PostAsync($"/api/albums/{created.Id}/refresh-reference", null, HttpStatusCode.OK); - try - { - var unlinked = await PostAsync($"/api/albums/{created.Id}/unlink-reference", null, HttpStatusCode.OK); + var unlinked = await PostAsync($"/api/albums/{created.Id}/unlink-reference", null, HttpStatusCode.OK); - unlinked!.ReferenceId.Should().BeNullOrEmpty(); - (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); - } - finally - { - await DeleteAsync($"/api/albums/{created.Id}"); - } + unlinked!.ReferenceId.Should().BeNullOrEmpty(); + (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); } } diff --git a/test/WebApi.IntegrationTests/Resources/UserPreferencesResourceTest.cs b/test/WebApi.IntegrationTests/Resources/UserPreferencesResourceTest.cs index 414b602e..175ef626 100644 --- a/test/WebApi.IntegrationTests/Resources/UserPreferencesResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/UserPreferencesResourceTest.cs @@ -3,6 +3,8 @@ using AwesomeAssertions; using Keeptrack.WebApi.Contracts.Dto; using Keeptrack.WebApi.IntegrationTests.Hosting; +using MongoDB.Bson; +using MongoDB.Driver; using Xunit; namespace Keeptrack.WebApi.IntegrationTests.Resources; @@ -37,6 +39,10 @@ public async Task Get_ReturnsAllFalseDefaults_WhenNothingWasEverSaved() public async Task Put_ThenGet_RoundTripsTheSavedValue() { await Authenticate(); + // The document is created server-side under the caller's own owner id and has no id the test ever + // sees, so it's cleaned up by owner. Leaving it behind would also quietly undermine + // Get_ReturnsAllFalseDefaults_WhenNothingWasEverSaved above, whose whole point is the never-saved case. + TrackDocumentsWhere("user_preference", Builders.Filter.Eq("owner_id", AuthenticatedUserId)); await PutAsync($"/{ResourceEndpoint}", new UserPreferencesDto { Features = new UserPreferencesFeaturesDto { ShowChasseAuxLivresLink = true } }); var afterFirstSave = await GetAsync($"/{ResourceEndpoint}"); diff --git a/test/WebApi.IntegrationTests/Resources/VideoGameReferenceRepositoryTest.cs b/test/WebApi.IntegrationTests/Resources/VideoGameReferenceRepositoryTest.cs index c6e266d8..d98fd778 100644 --- a/test/WebApi.IntegrationTests/Resources/VideoGameReferenceRepositoryTest.cs +++ b/test/WebApi.IntegrationTests/Resources/VideoGameReferenceRepositoryTest.cs @@ -7,7 +7,6 @@ using Keeptrack.Infrastructure.MongoDb.Entities; using Keeptrack.WebApi.IntegrationTests.Hosting; using Microsoft.Extensions.DependencyInjection; -using MongoDB.Driver; using Xunit; namespace Keeptrack.WebApi.IntegrationTests.Resources; @@ -17,97 +16,77 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// against real MongoDB - same ElemMatch/MatchedAliases shape already verified for /// (see TvShowReferenceRepositoryTest), applied to video games. /// -public class VideoGameReferenceRepositoryTest(KestrelWebAppFactory factory) : IClassFixture> +public class VideoGameReferenceRepositoryTest(KestrelWebAppFactory factory) : DatabaseTestBase(factory) { [Fact] public async Task FindByTitleYearAsync_MatchesAnAliasWhoseConfirmedYearDiffersFromTheDocumentsOwnCanonicalYear() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var alternateTitle = $"Alternate Game Title {Guid.NewGuid()}"; - var created = await repository.UpsertAsync(new VideoGameReferenceModel + var created = await CreateReferenceAsync(repository, new VideoGameReferenceModel { Title = "Canonical Game Title", TitleNormalized = "canonical game title", Year = 2005, - ExternalIds = new Dictionary { ["rawg"] = "1" }, + ExternalIds = new Dictionary { ["rawg"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2004 }] }); - try - { - var found = await repository.FindByTitleYearAsync(alternateTitle, 2004); + var found = await repository.FindByTitleYearAsync(alternateTitle, 2004); - found.Should().NotBeNull(); - found!.Id.Should().Be(created.Id); - } - finally - { - await DeleteAsync(scope, created.Id!); - } + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); } [Fact] public async Task FindByTitleAsync_MatchesAnAlternateTitle_IgnoringYear() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var alternateTitle = $"Alternate Game Title {Guid.NewGuid()}"; - var created = await repository.UpsertAsync(new VideoGameReferenceModel + var created = await CreateReferenceAsync(repository, new VideoGameReferenceModel { Title = "Canonical Game Title", TitleNormalized = "canonical game title", Year = 2005, - ExternalIds = new Dictionary { ["rawg"] = "1" }, + ExternalIds = new Dictionary { ["rawg"] = TestExternalId.New() }, MatchedAliases = [new ReferenceMatchModel { Title = alternateTitle.ToLowerInvariant(), Year = 2005 }] }); - try - { - var found = await repository.FindByTitleAsync(alternateTitle); + var found = await repository.FindByTitleAsync(alternateTitle); - found.Should().NotBeNull(); - found!.Id.Should().Be(created.Id); - } - finally - { - await DeleteAsync(scope, created.Id!); - } + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); } [Fact] public async Task UpsertAsync_AlwaysIncludesTheCanonicalTitleAndYearInMatchedAliases_EvenIfTheCallerForgot() { - using var scope = factory.Services.CreateScope(); + using var scope = Factory.Services.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService(); var title = $"Canonical Only Game Title {Guid.NewGuid()}"; - var created = await repository.UpsertAsync(new VideoGameReferenceModel + var created = await CreateReferenceAsync(repository, new VideoGameReferenceModel { Title = title, TitleNormalized = title.ToLowerInvariant(), Year = 2010, - ExternalIds = new Dictionary { ["rawg"] = "1" } + ExternalIds = new Dictionary { ["rawg"] = TestExternalId.New() } }); - try - { - var found = await repository.FindByTitleAsync(title); + var found = await repository.FindByTitleAsync(title); - found.Should().NotBeNull(); - found!.Id.Should().Be(created.Id); - } - finally - { - await DeleteAsync(scope, created.Id!); - } + found.Should().NotBeNull(); + found!.Id.Should().Be(created.Id); } - private static async Task DeleteAsync(IServiceScope scope, string id) + private async Task CreateReferenceAsync(IVideoGameReferenceRepository repository, VideoGameReferenceModel model) { - var collection = scope.ServiceProvider.GetRequiredService().GetCollection("videogame_reference"); - await collection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, id), TestContext.Current.CancellationToken); + var created = await repository.UpsertAsync(model); + TrackDocument("videogame_reference", created.Id); + return created; } } diff --git a/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs b/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs index 2c02a780..8c34c866 100644 --- a/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs @@ -11,7 +11,6 @@ using Keeptrack.WebApi.Contracts.Dto; using Keeptrack.WebApi.IntegrationTests.Hosting; using Microsoft.Extensions.DependencyInjection; -using MongoDB.Driver; using Xunit; namespace Keeptrack.WebApi.IntegrationTests.Resources; @@ -43,26 +42,19 @@ public async Task VideoGameResourceFullCycle_IsOk() o.CustomImageUrl = f.Internet.Url(); }) .Generate(); - var created = await PostAsync($"/{ResourceEndpoint}", input); + var created = await CreateAsync($"/{ResourceEndpoint}", input); created.Id.Should().NotBeNullOrEmpty(); - try - { - created.Title = "New shiny title"; - await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); - - var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); - updated.Should().BeEquivalentTo(created); - - var finalItems = await GetAsync>($"/{ResourceEndpoint}"); - var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); - firstItem.Should().NotBeNull(); - firstItem.Title.Should().Be(updated.Title); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); } [Fact] @@ -70,23 +62,16 @@ public async Task VideoGameResourceSearch_FiltersToMatchingPlatform_IsOk() { await Authenticate(); - var title = System.Guid.NewGuid().ToString(); - var created = await PostAsync($"/{ResourceEndpoint}", new VideoGameDto + var title = Guid.NewGuid().ToString(); + var created = await CreateAsync($"/{ResourceEndpoint}", new VideoGameDto { Title = title, Platforms = [new VideoGamePlatformDto { Platform = "PS5", CopyType = CopyType.Physical, State = "Available" }] }); - try - { - var results = await GetAsync>($"/{ResourceEndpoint}?platform=PS5&search={title}"); + var results = await GetAsync>($"/{ResourceEndpoint}?platform=PS5&search={title}"); - results.Items.Should().ContainSingle(x => x.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + results.Items.Should().ContainSingle(x => x.Id == created.Id); } [Fact] @@ -106,30 +91,23 @@ public async Task VideoGameResourceOwnedAndWishlistedFilters_OnlyReturnMatchingI Price = 59.99m, Vendor = "Some store", Reference = "Collector's edition", AcquiredAt = new DateOnly(2024, 5, 17) } }; - var created = await PostAsync($"/{ResourceEndpoint}", new VideoGameDto + var created = await CreateAsync($"/{ResourceEndpoint}", new VideoGameDto { Title = title, Platforms = [.. platforms], IsWishlisted = true }); - try - { - var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); - owned.Items.Should().ContainSingle(x => x.Id == created.Id); - - // the platform entry's ownership fields must survive the full DTO -> model -> BSON round trip (incl. the decimal price) - var fetchedPlatforms = owned.Items.Single(x => x.Id == created.Id).Platforms; - fetchedPlatforms.Should().BeEquivalentTo(platforms); - - // this is the WishlistController filter-probe, not a list-page UI filter (removed) - still real API behavior - var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={title}"); - wishlisted.Items.Should().ContainSingle(x => x.Id == created.Id); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - } + var owned = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); + owned.Items.Should().ContainSingle(x => x.Id == created.Id); + + // the platform entry's ownership fields must survive the full DTO -> model -> BSON round trip (incl. the decimal price) + var fetchedPlatforms = owned.Items.Single(x => x.Id == created.Id).Platforms; + fetchedPlatforms.Should().BeEquivalentTo(platforms); + + // this is the WishlistController filter-probe, not a list-page UI filter (removed) - still real API behavior + var wishlisted = await GetAsync>($"/{ResourceEndpoint}?IsWishlisted=true&search={title}"); + wishlisted.Items.Should().ContainSingle(x => x.Id == created.Id); } /// @@ -148,30 +126,22 @@ public async Task VideoGameResourceList_CustomImageUrlOverridesTheLinkedReferenc { Title = "Some Reference Title", TitleNormalized = "some reference title", - ExternalIds = new Dictionary { ["rawg"] = $"rawg-{Guid.NewGuid():N}" }, + ExternalIds = new Dictionary { ["rawg"] = TestExternalId.New() }, ImageUrl = "https://example.com/reference-cover.jpg" }); + TrackDocument("videogame_reference", reference.Id); await Authenticate(); const string customImageUrl = "https://example.com/custom-cover.jpg"; - var created = await PostAsync($"/{ResourceEndpoint}", new VideoGameDto + var created = await CreateAsync($"/{ResourceEndpoint}", new VideoGameDto { Title = uniqueTitle, ReferenceId = reference.Id, CustomImageUrl = customImageUrl }); - try - { - var list = await GetAsync>($"/{ResourceEndpoint}?search={uniqueTitle}"); - var item = list.Items.Should().ContainSingle(x => x.Id == created.Id).Subject; - item.ImageUrl.Should().Be(customImageUrl); - } - finally - { - await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); - var referenceCollection = scope.ServiceProvider.GetRequiredService().GetCollection("videogame_reference"); - await referenceCollection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, reference.Id), TestContext.Current.CancellationToken); - } + var list = await GetAsync>($"/{ResourceEndpoint}?search={uniqueTitle}"); + var item = list.Items.Should().ContainSingle(x => x.Id == created.Id).Subject; + item.ImageUrl.Should().Be(customImageUrl); } } diff --git a/test/WebApi.IntegrationTests/Resources/WishlistResourceTest.cs b/test/WebApi.IntegrationTests/Resources/WishlistResourceTest.cs index 3bfb8860..8490e1a1 100644 --- a/test/WebApi.IntegrationTests/Resources/WishlistResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/WishlistResourceTest.cs @@ -8,7 +8,6 @@ using Keeptrack.WebApi.Contracts.Dto; using Keeptrack.WebApi.IntegrationTests.Hosting; using Microsoft.Extensions.DependencyInjection; -using MongoDB.Driver; using Xunit; namespace Keeptrack.WebApi.IntegrationTests.Resources; @@ -32,13 +31,14 @@ public async Task Wishlist_AppliesCustomImageUrlOverrideForBooks() { Title = "Some Reference Title", TitleNormalized = "some reference title", - ExternalIds = new Dictionary { ["googlebooks"] = $"gb-{Guid.NewGuid():N}" }, + ExternalIds = new Dictionary { ["googlebooks"] = TestExternalId.New() }, ImageUrl = "https://example.com/reference-cover.jpg" }); + TrackDocument("book_reference", reference.Id); await Authenticate(); const string customImageUrl = "https://example.com/custom-book-cover.jpg"; - var created = await PostAsync("/api/books", new BookDto + var created = await CreateAsync("/api/books", new BookDto { Title = $"WishlistCustomCoverBook-{Guid.NewGuid():N}", Author = "Some Author", @@ -47,18 +47,9 @@ public async Task Wishlist_AppliesCustomImageUrlOverrideForBooks() IsWishlisted = true }); - try - { - var wishlist = await GetAsync("/api/wishlist"); - var item = wishlist.Books.Should().ContainSingle(b => b.Id == created.Id).Subject; - item.ImageUrl.Should().Be(customImageUrl); - } - finally - { - await DeleteAsync($"/api/books/{created.Id}"); - var referenceCollection = scope.ServiceProvider.GetRequiredService().GetCollection("book_reference"); - await referenceCollection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, reference.Id), TestContext.Current.CancellationToken); - } + var wishlist = await GetAsync("/api/wishlist"); + var item = wishlist.Books.Should().ContainSingle(b => b.Id == created.Id).Subject; + item.ImageUrl.Should().Be(customImageUrl); } [Fact] @@ -71,13 +62,14 @@ public async Task Wishlist_AppliesCustomImageUrlOverrideForVideoGames() { Title = "Some Reference Title", TitleNormalized = "some reference title", - ExternalIds = new Dictionary { ["rawg"] = $"rawg-{Guid.NewGuid():N}" }, + ExternalIds = new Dictionary { ["rawg"] = TestExternalId.New() }, ImageUrl = "https://example.com/reference-cover.jpg" }); + TrackDocument("videogame_reference", reference.Id); await Authenticate(); const string customImageUrl = "https://example.com/custom-game-cover.jpg"; - var created = await PostAsync("/api/video-games", new VideoGameDto + var created = await CreateAsync("/api/video-games", new VideoGameDto { Title = $"WishlistCustomCoverGame-{Guid.NewGuid():N}", ReferenceId = reference.Id, @@ -85,17 +77,8 @@ public async Task Wishlist_AppliesCustomImageUrlOverrideForVideoGames() IsWishlisted = true }); - try - { - var wishlist = await GetAsync("/api/wishlist"); - var item = wishlist.VideoGames.Should().ContainSingle(g => g.Id == created.Id).Subject; - item.ImageUrl.Should().Be(customImageUrl); - } - finally - { - await DeleteAsync($"/api/video-games/{created.Id}"); - var referenceCollection = scope.ServiceProvider.GetRequiredService().GetCollection("videogame_reference"); - await referenceCollection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, reference.Id), TestContext.Current.CancellationToken); - } + var wishlist = await GetAsync("/api/wishlist"); + var item = wishlist.VideoGames.Should().ContainSingle(g => g.Id == created.Id).Subject; + item.ImageUrl.Should().Be(customImageUrl); } } diff --git a/test/WebApi.IntegrationTests/Resources/WishlistShareResourceTest.cs b/test/WebApi.IntegrationTests/Resources/WishlistShareResourceTest.cs index 93013fce..259802ef 100644 --- a/test/WebApi.IntegrationTests/Resources/WishlistShareResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/WishlistShareResourceTest.cs @@ -31,41 +31,35 @@ public async Task Shares_AreIndependentlyCreatableListableAndRevocable_AndReadab await Authenticate(); // a wishlisted movie that must appear in the shared view - var movie = await PostAsync("/api/movies", new MovieDto { Title = $"SharedWishlistTarget-{Guid.NewGuid():N}", IsWishlisted = true }); + var movie = await CreateAsync("/api/movies", new MovieDto { Title = $"SharedWishlistTarget-{Guid.NewGuid():N}", IsWishlisted = true }); var mumShare = await PostAsync("/api/wishlist/shares", new CreateWishlistShareRequestDto { Label = "Mum" }); + TrackResource("/api/wishlist/shares", mumShare.Id); var friendShare = await PostAsync("/api/wishlist/shares", new CreateWishlistShareRequestDto { Label = "Friend" }); + TrackResource("/api/wishlist/shares", friendShare.Id); mumShare.Token.Should().NotBeNullOrEmpty().And.NotBe(friendShare.Token); mumShare.Label.Should().Be("Mum"); // a genuinely anonymous client - no Authenticate(), no bearer header, like a share recipient using var anonymous = new HttpClient { BaseAddress = new Uri(Factory.ServerAddress) }; - try - { - var shares = await GetAsync>("/api/wishlist/shares"); - shares.Should().Contain(s => s.Id == mumShare.Id && s.Label == "Mum"); - shares.Should().Contain(s => s.Id == friendShare.Id && s.Label == "Friend"); - var shared = await anonymous.GetFromJsonAsync($"/api/wishlist/shared/{mumShare.Token}", TestContext.Current.CancellationToken); - shared!.Movies.Should().Contain(m => m.Id == movie.Id); + var shares = await GetAsync>("/api/wishlist/shares"); + shares.Should().Contain(s => s.Id == mumShare.Id && s.Label == "Mum"); + shares.Should().Contain(s => s.Id == friendShare.Id && s.Label == "Friend"); - // an unknown token is an indistinguishable 404 - (await anonymous.GetAsync($"/api/wishlist/shared/{Guid.NewGuid():N}", TestContext.Current.CancellationToken)).StatusCode.Should().Be(HttpStatusCode.NotFound); + var shared = await anonymous.GetFromJsonAsync($"/api/wishlist/shared/{mumShare.Token}", TestContext.Current.CancellationToken); + shared!.Movies.Should().Contain(m => m.Id == movie.Id); - // revoking one link kills that link only - the other keeps working - await DeleteAsync($"/api/wishlist/shares/{mumShare.Id}"); - (await anonymous.GetAsync($"/api/wishlist/shared/{mumShare.Token}", TestContext.Current.CancellationToken)).StatusCode.Should().Be(HttpStatusCode.NotFound); - (await anonymous.GetAsync($"/api/wishlist/shared/{friendShare.Token}", TestContext.Current.CancellationToken)).StatusCode.Should().Be(HttpStatusCode.OK); + // an unknown token is an indistinguishable 404 + (await anonymous.GetAsync($"/api/wishlist/shared/{Guid.NewGuid():N}", TestContext.Current.CancellationToken)).StatusCode.Should().Be(HttpStatusCode.NotFound); - var remaining = await GetAsync>("/api/wishlist/shares"); - remaining.Should().NotContain(s => s.Id == mumShare.Id); - remaining.Should().Contain(s => s.Id == friendShare.Id); - } - finally - { - await DeleteAsync($"/api/movies/{movie.Id}"); - await DeleteAsync($"/api/wishlist/shares/{mumShare.Id}"); - await DeleteAsync($"/api/wishlist/shares/{friendShare.Id}"); - } + // revoking one link kills that link only - the other keeps working + await DeleteAsync($"/api/wishlist/shares/{mumShare.Id}"); + (await anonymous.GetAsync($"/api/wishlist/shared/{mumShare.Token}", TestContext.Current.CancellationToken)).StatusCode.Should().Be(HttpStatusCode.NotFound); + (await anonymous.GetAsync($"/api/wishlist/shared/{friendShare.Token}", TestContext.Current.CancellationToken)).StatusCode.Should().Be(HttpStatusCode.OK); + + var remaining = await GetAsync>("/api/wishlist/shares"); + remaining.Should().NotContain(s => s.Id == mumShare.Id); + remaining.Should().Contain(s => s.Id == friendShare.Id); } } From c38be2239c81f710f3c3586307f3a192f9651421 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 31 Jul 2026 22:43:25 +0200 Subject: [PATCH 41/80] Update health check endpoint to healthz --- src/BlazorApp/Program.cs | 2 +- src/WebApi/AppConfiguration.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BlazorApp/Program.cs b/src/BlazorApp/Program.cs index 651de7d2..9d4ff691 100644 --- a/src/BlazorApp/Program.cs +++ b/src/BlazorApp/Program.cs @@ -68,7 +68,7 @@ app.MapGet("/shared/wishlist/{token}", (string token) => new RazorComponentResult(new { Token = token })); app.MapControllers(); -app.MapHealthChecks("/health"); +app.MapHealthChecks("/healthz"); await app.RunAsync(); diff --git a/src/WebApi/AppConfiguration.cs b/src/WebApi/AppConfiguration.cs index f29246b4..ac0671a3 100644 --- a/src/WebApi/AppConfiguration.cs +++ b/src/WebApi/AppConfiguration.cs @@ -8,7 +8,7 @@ public class AppConfiguration(IConfiguration configuration) { public static string CorsPolicyName => "CorsPolicyName"; - public static string HealthCheckEndpoint => "/health"; + public static string HealthCheckEndpoint => "/healthz"; public bool IsHttpsRedirectionEnabled => configuration.TryGetSection("Features:IsHttpsRedirectionEnabled"); From 6c1c2f58f7baedcfde69dba3cf5d3b5405d427a9 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 1 Aug 2026 13:12:15 +0200 Subject: [PATCH 42/80] Order import --- src/BlazorApp/Components/Import/AmazonImportPage.razor | 2 +- src/BlazorApp/Components/Import/GenericImportPage.razor | 2 +- .../Components/Import/GenericVideoGameImportPage.razor | 2 +- src/BlazorApp/Components/_Imports.razor | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/BlazorApp/Components/Import/AmazonImportPage.razor b/src/BlazorApp/Components/Import/AmazonImportPage.razor index c69d68ce..f9bf6e69 100644 --- a/src/BlazorApp/Components/Import/AmazonImportPage.razor +++ b/src/BlazorApp/Components/Import/AmazonImportPage.razor @@ -1,7 +1,7 @@ @page "/import/amazon" @attribute [Authorize] -@using Keeptrack.WebApi.Contracts.Dto @using Keeptrack.BlazorApp.Components.Inventory.Pages +@using Keeptrack.WebApi.Contracts.Dto

Import from Amazon

diff --git a/src/BlazorApp/Components/Import/GenericImportPage.razor b/src/BlazorApp/Components/Import/GenericImportPage.razor index 0e62c3f8..c28d04dd 100644 --- a/src/BlazorApp/Components/Import/GenericImportPage.razor +++ b/src/BlazorApp/Components/Import/GenericImportPage.razor @@ -1,7 +1,7 @@ @page "/import/generic" @attribute [Authorize] -@using Keeptrack.WebApi.Contracts.Dto @using Keeptrack.BlazorApp.Components.Inventory.Pages +@using Keeptrack.WebApi.Contracts.Dto

Import from a store (CSV)

diff --git a/src/BlazorApp/Components/Import/GenericVideoGameImportPage.razor b/src/BlazorApp/Components/Import/GenericVideoGameImportPage.razor index d13af9ca..cde778ab 100644 --- a/src/BlazorApp/Components/Import/GenericVideoGameImportPage.razor +++ b/src/BlazorApp/Components/Import/GenericVideoGameImportPage.razor @@ -1,7 +1,7 @@ @page "/import/video-games" @attribute [Authorize] -@using Keeptrack.WebApi.Contracts.Dto @using Keeptrack.BlazorApp.Components.Inventory.Pages +@using Keeptrack.WebApi.Contracts.Dto

Import video game transactions

diff --git a/src/BlazorApp/Components/_Imports.razor b/src/BlazorApp/Components/_Imports.razor index de41a06d..1fee91bf 100644 --- a/src/BlazorApp/Components/_Imports.razor +++ b/src/BlazorApp/Components/_Imports.razor @@ -7,9 +7,9 @@ @using Keeptrack.BlazorApp.Components.Inventory.Clients @using Keeptrack.BlazorApp.Components.Inventory.Meta @using Keeptrack.BlazorApp.Components.Inventory.Shared -@using Keeptrack.BlazorApp.Components.Sharing @using Keeptrack.BlazorApp.Components.Layout @using Keeptrack.BlazorApp.Components.Shared +@using Keeptrack.BlazorApp.Components.Sharing @using Keeptrack.Common.System @using Keeptrack.WebApi.Contracts.Dto @using Microsoft.AspNetCore.Authorization From 035b63ad580d77eb4b51db130cfa967a4b1ea566 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 1 Aug 2026 13:12:30 +0200 Subject: [PATCH 43/80] Cosmetic code change --- src/WebApi/ReferenceData/ExploreService.cs | 66 ++++++++++++---------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/src/WebApi/ReferenceData/ExploreService.cs b/src/WebApi/ReferenceData/ExploreService.cs index f438947c..7f747961 100644 --- a/src/WebApi/ReferenceData/ExploreService.cs +++ b/src/WebApi/ReferenceData/ExploreService.cs @@ -5,15 +5,13 @@ namespace Keeptrack.WebApi.ReferenceData; /// -/// The Explore feature: reads a provider's own best-of listing and suggests acclaimed titles the caller -/// doesn't already track and hasn't dismissed. Querying the provider - not the local reference collections, -/// which only hold titles someone already tracks - is what surfaces genuinely new things. Each domain reads -/// its own reference provider (TMDB for movies/TV, RAWG for video games) and ranks by the admin-selected -/// primary rating source for that domain, exactly like the rest of the app. The one wrinkle is movies/TV -/// under IMDb: IMDb has no catalogue/top-rated API at all, so the *list* still comes from TMDB and only the -/// displayed number is enriched per title. Video games need no such exception - RAWG sorts natively on both -/// of its own sources. Lives in WebApi/ReferenceData (not Domain) as it depends on the provider clients; -/// per-domain branching is confined to the small fetcher/lookup helpers. +/// The Explore feature: reads a provider's own best-of listing and suggests acclaimed titles the caller doesn't already track and hasn't dismissed. +/// Querying the provider - not the local reference collections, which only hold titles someone already tracks - is what surfaces genuinely new things. +/// Each domain reads its own reference provider (TMDB for movies/TV, RAWG for video games) and ranks by the admin-selected primary rating source for that domain, +/// exactly like the rest of the app. +/// The one wrinkle is movies/TV under IMDb: IMDb has no catalogue/top-rated API at all, so the *list* still comes from TMDB and only the displayed number is enriched per title. +/// Video games need no such exception - RAWG sorts natively on both of its own sources. +/// Lives in WebApi/ReferenceData (not Domain) as it depends on the provider clients; per-domain branching is confined to the small fetcher/lookup helpers. /// public class ExploreService( ITmdbClient tmdbClient, @@ -29,28 +27,27 @@ public class ExploreService( IExploreDismissalRepository dismissalRepository) { /// - /// The provider each domain discovers through, and therefore the ExternalIds key its suggestion - /// ids live in. Deliberately distinct from the *rating* source: an IMDb-ranked movie suggestion is still - /// identified by a TMDB id. + /// The provider each domain discovers through, and therefore the ExternalIds key its suggestion ids live in. + /// Deliberately distinct from the *rating* source: an IMDb-ranked movie suggestion is still identified by a TMDB id. /// private const string TmdbProviderKey = "tmdb"; private const string RawgProviderKey = "rawg"; - /// TMDB and IMDb ratings are both on a 0-10 scale; RAWG's own score is 0-5 and Metacritic's 0-100. private const double TmdbRatingScale = 10; private const double RawgRatingScale = 5; private const double MetacriticRatingScale = 100; - /// How many provider pages to pull through at most while filling a request. + /// + /// How many provider pages to pull through at most while filling a request. + /// private const int MaxProviderPages = 5; /// - /// The top- provider suggestions for , excluding titles the - /// owner already tracks or has dismissed. The rating shown and the ordering follow the admin's primary - /// source for the domain. + /// The top- provider suggestions for , excluding titles the owner already tracks or has dismissed. + /// The rating shown and the ordering follow the admin's primary source for the domain. /// public async Task> GetSuggestionsAsync(ExploreItemType type, string ownerId, int limit, CancellationToken cancellationToken = default) { @@ -76,10 +73,13 @@ public async Task> GetSuggestionsAsync(ExploreItemTyp return source == RatingSourceCatalog.Imdb ? await MapWithImdbRatingsAsync(type, chosen, cancellationToken) - : chosen.Select(ToDto).ToList(); + : [.. chosen.Select(ToDto)]; } - /// Hides a provider title from the owner's Explore list permanently (until undone). Idempotent. + /// + /// Hides a provider title from the owner's Explore list permanently (until undone). + /// Idempotent. + /// public Task DismissAsync(ExploreItemType type, string ownerId, string externalId) => dismissalRepository.AddAsync(new ExploreDismissalModel { @@ -89,9 +89,13 @@ public Task DismissAsync(ExploreItemType type, string ownerId, string externalId ExternalId = externalId }); - /// Undoes a dismissal so the title can be suggested again. - public Task UndismissAsync(ExploreItemType type, string ownerId, string externalId) => - dismissalRepository.RemoveAsync(ownerId, type, DiscoverySource(type), externalId); + /// + /// Undoes a dismissal so the title can be suggested again. + /// + public Task UndismissAsync(ExploreItemType type, string ownerId, string externalId) + { + return dismissalRepository.RemoveAsync(ownerId, type, DiscoverySource(type), externalId); + } /// /// The admin-selected primary rating source for the domain - the same setting (and the same resolver) the @@ -202,9 +206,14 @@ private static IEnumerable ExternalIdsOf( _ => throw new ArgumentOutOfRangeException(nameof(type), $"Explore is not available for {type}.") }; - private static IReadOnlyList ToCandidates(IReadOnlyList items) => - [.. items.Select(i => new ExploreCandidate( - i.TmdbId, i.Title, i.Year, i.Synopsis, i.PosterUrl, i.VoteAverage, i.VoteAverage is null ? null : TmdbRatingScale))]; + private static IReadOnlyList ToCandidates(IReadOnlyList items) + { + return + [ + .. items.Select(i => new ExploreCandidate( + i.TmdbId, i.Title, i.Year, i.Synopsis, i.PosterUrl, i.VoteAverage, i.VoteAverage is null ? null : TmdbRatingScale)) + ]; + } // RAWG reports both of its scores on every listing entry, so the one the admin selected is picked here // with no second request - and its scale travels with it (0-5 for RAWG's own, 0-100 for Metacritic's). @@ -235,9 +244,8 @@ .. items.Select(i => }; /// - /// One provider suggestion, normalized across providers so the paging/exclusion loop above is written - /// once instead of per domain. The rating carries its own scale because the domains don't share one. + /// One provider suggestion, normalized across providers so the paging/exclusion loop above is written once instead of per domain. + /// The rating carries its own scale because the domains don't share one. /// - private sealed record ExploreCandidate( - string ExternalId, string Title, int? Year, string? Synopsis, string? ImageUrl, double? Rating, double? RatingScale); + private sealed record ExploreCandidate(string ExternalId, string Title, int? Year, string? Synopsis, string? ImageUrl, double? Rating, double? RatingScale); } From 146e858523b8d3ca2e4c6e58005787b8c6ebb680 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 1 Aug 2026 13:13:50 +0200 Subject: [PATCH 44/80] Update min metacritic to 60 (instead of 70) --- src/WebApi/ReferenceData/RawgClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WebApi/ReferenceData/RawgClient.cs b/src/WebApi/ReferenceData/RawgClient.cs index 4ce3897c..99f4c81e 100644 --- a/src/WebApi/ReferenceData/RawgClient.cs +++ b/src/WebApi/ReferenceData/RawgClient.cs @@ -54,7 +54,7 @@ public async Task> GetTopRatedGamesAsync(int pag /// score (i.e. the game was reviewed by the professional press at all) is the closest server-side /// equivalent of TMDB's vote threshold, and it costs no extra call. Raise it for a stricter list. /// - private const int MinMetacritic = 70; + private const int MinMetacritic = 60; private string ApiKey => settings.ApiKey; From 3e42a63cb7ff6deaf5302cf19fb58e4aa1b8517d Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 2 Aug 2026 23:52:34 +0200 Subject: [PATCH 45/80] Format md files with neatmd --- CLAUDE.md | 134 +++++++++++++++++--------- CONTRIBUTING.md | 16 +-- docs/code-quality-findings.md | 56 +++++++---- docs/plan-quick-add.md | 17 ++-- docs/playwright-e2e-tests-plan.md | 44 ++++----- docs/prerender-flash-fix.md | 8 +- docs/reference-ratings-plan.md | 92 ++++++++++++------ docs/share-collections-plan.md | 155 ++++++++++++++---------------- docs/testing-assessment.md | 78 +++++++++------ 9 files changed, 358 insertions(+), 242 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 87eb2477..ff4a0835 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,19 +208,27 @@ and `SkippedRowTitles` lists exactly which selected rows were skipped as already together they let `GenericVideoGameImportPage.razor` show a reconciling "X of Y selected rows imported" line plus a named list of anything actually skipped, so the user can trust nothing was silently dropped instead of having to guess from the per-item counts alone. -`GenericImportController`/`GenericImportService` (`POST /api/import/generic`, `MemberOnly`) is the fully store-agnostic, column-driven third importer of this shape - the one to reach for by default now, and the one to extend rather than adding another store-specific importer. +`GenericImportController`/`GenericImportService` (`POST /api/import/generic`, `MemberOnly`) is the fully store-agnostic, column-driven third importer of this shape - +the one to reach for by default now, and the one to extend rather than adding another store-specific importer. It removes every Amazon specificity by reading each field from a canonical, case-insensitive column set (all optional except `Title`) the user reshapes any retailer export into within a spreadsheet - confirmed against a real Rakuten export. -`Vendor` is a per-row column (like the video game importer), and crucially a `Type` column, when present, sets each row's `ImportMediaType` directly (`GenericImportService.ParseMediaType` tolerates the natural spellings a user types: "TV Show", "Video Game", "Film", "Jeu"...), so a well-prepared sheet pre-selects every row's type instead of forcing the per-row picker the Amazon page needs (Amazon's export has no category column). +`Vendor` is a per-row column (like the video game importer), and crucially a `Type` column, when present, sets each row's `ImportMediaType` directly (`GenericImportService.ParseMediaType` tolerates the natural spellings a user types: +"TV Show", "Video Game", "Film", "Jeu"...), so a well-prepared sheet pre-selects every row's type instead of forcing the per-row picker the Amazon page needs (Amazon's export has no category column). A blank/unrecognized `Type` falls back to that picker rather than guessing - the same "don't guess when you don't have the info" rule as everywhere else. Column aliases cover the common real headers (`Product Name`→Title, `ASIN`/`SKU`→`ProductId`, `Total Amount`→Price, `Product Condition`→Condition). -`Vendor` (the store name) and `Website` are two **separate** columns feeding two different owned-copy fields: `Vendor`→the copy's `Vendor` field, `Website`→the copy's `Reference` (a free-text per-item label - product/order URL, seller). They are deliberately not the same input: `Vendor` is NOT aliased to `Website` (unlike Amazon's own parser, whose export calls the storefront "Website" and maps it to vendor). -`GenericImportService.FormatReference` (`"{website} order {orderId} ({productId})"`, falling back to the title when `ProductId` is blank) is the store-agnostic counterpart to Amazon's ASIN reference and the video game importer's product-name one; its per-line-item dedup precision comes from the order id + product id, so the `Website` label being non-unique is harmless. +`Vendor` (the store name) and `Website` are two **separate** columns feeding two different owned-copy fields: `Vendor`→the copy's `Vendor` field, `Website`→the copy's `Reference` (a free-text per-item label - product/order URL, seller). +They are deliberately not the same input: `Vendor` is NOT aliased to `Website` (unlike Amazon's own parser, whose export calls the storefront "Website" and maps it to vendor). +`GenericImportService.FormatReference` (`"{website} order {orderId} ({productId})"`, falling back to the title when `ProductId` is blank) is the store-agnostic counterpart to Amazon's ASIN reference and the video game importer's +product-name one; its per-line-item dedup precision comes from the order id + product id, so the `Website` label being non-unique is harmless. One deliberate behavioral difference from Amazon: the `Condition` column value is preserved on the created owned copy's `ProductName` ("Product") field rather than dropped as display-only, at the owner's request. -The per-type create/merge orchestration is **not** duplicated between the two multi-type importers: it lives once in `Domain/Services/OwnedItemImportCommitCoordinator.cs`, which both `AmazonImportController.Commit` and `GenericImportController.Commit` call with a flat `List` (a pure-Domain shape carrying the already-computed reference/provenance text) and read back per-type `OwnedItemImportCommitCounts`. -The coordinator fans the inputs out by `ImportMediaType`, supplies each of the six types' model-construction delegates, and persists the resulting `ComputeCommitPlan` - the six near-identical `if (xItems.Count > 0)` blocks that were inline in the Amazon controller moved here wholesale when the generic importer would otherwise have copied them. -`ImportMediaType` exists as **two** identically-named enums (`Domain.Models` and `WebApi.Contracts.Dto`, mapped by name, same split as every other DTO/Domain enum pair) - a controller that imports both namespaces (Amazon/Generic do, via the `Contracts.Dto` global using) must alias one to disambiguate, same as `CopyType` already needed. -Covered by `GenericImportServiceTest` (unit - the real Rakuten header proves every column alias resolves), `GenericImportResourceTest` (integration - mixed-type preview→commit, `Condition`→Product-field, and re-import dedup), and `GenericImportSmokeTest` (Playwright). +The per-type create/merge orchestration is **not** duplicated between the two multi-type importers: it lives once in `Domain/Services/OwnedItemImportCommitCoordinator.cs`, which both `AmazonImportController.Commit` and +`GenericImportController.Commit` call with a flat `List` (a pure-Domain shape carrying the already-computed reference/provenance text) and read back per-type `OwnedItemImportCommitCounts`. +The coordinator fans the inputs out by `ImportMediaType`, supplies each of the six types' model-construction delegates, and persists the resulting `ComputeCommitPlan` - the six near-identical `if (xItems.Count > 0)` blocks that were inline +in the Amazon controller moved here wholesale when the generic importer would otherwise have copied them. +`ImportMediaType` exists as **two** identically-named enums (`Domain.Models` and `WebApi.Contracts.Dto`, mapped by name, same split as every other DTO/Domain enum pair) - +a controller that imports both namespaces (Amazon/Generic do, via the `Contracts.Dto` global using) must alias one to disambiguate, same as `CopyType` already needed. +Covered by `GenericImportServiceTest` (unit - the real Rakuten header proves every column alias resolves), `GenericImportResourceTest` (integration - +mixed-type preview→commit, `Condition`→Product-field, and re-import dedup), and `GenericImportSmokeTest` (Playwright). ### Child entities (1-to-many owned by another entity) @@ -275,7 +283,8 @@ Both controllers are `MemberOnly` (health data is never part of the free preview `HealthImportService` (`POST /api/import/health`, `MemberOnly` like all imports - CarHistoryImportController was fixed to match, since imports create data through repositories and would otherwise bypass controller policies) is the CarHistoryImportService-style one-off Excel import of the personal "Journal_sante.xlsx": one sheet, every family member mixed in one "Personne" column (profiles created/matched by name, case-insensitive), a SECOND "Personne" column meaning the practitioner (so header lookup is position-aware, not a plain name dictionary), and the derived "Reste à charge" formula column deliberately NOT imported - the app recomputes the balance, -so unsettled historical rows surface with the ⚠ badge for the owner's own review. Shared cell parsing lives in `ExcelCellParser` (extracted from the car importer rather than duplicated). +so unsettled historical rows surface with the ⚠ badge for the owner's own review. +Shared cell parsing lives in `ExcelCellParser` (extracted from the car importer rather than duplicated). Verified against the real sample file end-to-end (3 profiles, 13 rows, zero warnings), and `HealthImportServiceTest` pins the file's quirks with an in-memory ClosedXML workbook. `HouseDetail.razor`'s yearly cost chart is a single-series bar chart (total cost per year) plus a plain HTML breakdown table underneath (rows = years, columns = the 6 categories + total), not a 6-color stacked bar chart. @@ -428,23 +437,35 @@ WebApi validates the bearer token's claims directly and needs no equivalent step There's no in-app way to grant the first admin; it's a one-off `setCustomUserClaims` call via the Firebase Admin SDK (see `CONTRIBUTING.md`). Global admin settings an admin changes at runtime (as opposed to deploy-time config in `appsettings`/env vars) live in one shared `app_setting` collection - a single document (`_id: "global"`), one field per setting. -`IAppSettingRepository`/`AppSettingRepository` is the purpose-built accessor (like `LeaseRepository`, it doesn't extend the owner-scoped `IDataRepository`), writing with a targeted `$set` on just the one field so unrelated settings on the same document are never clobbered. +`IAppSettingRepository`/`AppSettingRepository` is the purpose-built accessor (like `LeaseRepository`, it doesn't extend the owner-scoped `IDataRepository`), writing with a targeted `$set` on just the one field so unrelated settings +on the same document are never clobbered. Reach for this - a new field/accessor here, not a new collection - for any future runtime-changeable global setting; use `AppConfiguration`/env vars only for values that are fine to change at deploy time. -Its first use is the admin-selectable **primary rating source** (which provider score is denormalized onto a tenant item as the list/sort rating): `RatingSourceCatalog` declares each domain's selectable sources + code default (video games RAWG vs Metacritic, movies/TV TMDB vs IMDb - the two multi-source domains today, default `rawg`/`tmdb`), -`ReferenceEnrichmentService.GetPrimaryRatingSourceAsync` reads the stored override-or-default, and `ReferenceDataAdminController`'s `rating-sources` GET/PUT plus a `.../recompute` POST (a synchronous bulk `SetReferenceRatingAsync` pass, no provider calls) let an admin switch it and re-propagate to every already-linked item. -The admin card, endpoints, and recompute loop are all domain-generic (they iterate `RatingSourceCatalog.SelectableDomains`), so a domain gaining a second source only needs a catalog entry plus routing its `PrimaryRating` call sites through `GetPrimaryRatingSourceAsync` - no controller/UI change (this is exactly how movies/TV joined when IMDb landed). - -**Movies/TV get their IMDb rating from OMDb** (`IOmdbClient`/`OmdbClient`, `WebApi/ReferenceData/`), keyed by the IMDb id TMDB already exposes - IMDb itself has no public ratings API, so this is the sanctioned path (directly analogous to the book ISBN→Open Library rating fallback: TMDB plays Google Books' role of handing off the cross-provider identifier, OMDb plays Open Library's role of turning it into a rating, stored under its own `imdb` source key on the same 0-10 scale as `tmdb`). -The IMDb id is native on `/movie/{id}` (`imdb_id`, zero extra calls) but not on `/tv/{id}` - the TV details call appends it via `?append_to_response=external_ids` (still one call, no season fan-out), and it's stored in the reference's `ExternalIds["imdb"]`. -**OMDb is optional/best-effort**: unlike every other provider's settings, `OmdbSettings.ApiKey` is nullable (not `required`), and `AppConfiguration.OmdbSettings` coalesces a missing `Omdb` section to an empty instance - a deployment with no `Omdb__ApiKey` simply keeps movies/TV on their TMDB rating alone rather than failing resolution/refresh, and the integration/e2e hosts need no OMDb key. - -**Gotcha (IMDb backfill bootstrap):** the TMDB `/changes` short-circuit (`LastEnrichedAt is not null && Ratings.Count > 0`) skips the full re-fetch once a `tmdb` rating exists, so a reference enriched before IMDb existed would never backfill an `imdb` one - it has a tmdb rating (so it short-circuits) but no stored imdb id (only a full fetch writes that), a chicken-and-egg the first version hit against a real dev database (only the handful of references that happened to full-fetch got IMDb). -Fixed by `BackfillImdbRatingAsync` on the no-change path: when the imdb rating is missing it resolves the imdb id cheaply via TMDB's dedicated `/{tv,movie}/{id}/external_ids` endpoint (`ITmdbClient.GetTvShowImdbIdAsync`/`GetMovieImdbIdAsync` - one call, **no** season fan-out, deliberately not the full details re-fetch the short-circuit avoids), stores it, then does the one OMDb call. +Its first use is the admin-selectable **primary rating source** (which provider score is denormalized onto a tenant item as the list/sort rating): +`RatingSourceCatalog` declares each domain's selectable sources + code default (video games RAWG vs Metacritic, movies/TV TMDB vs IMDb - the two multi-source domains today, default `rawg`/`tmdb`), +`ReferenceEnrichmentService.GetPrimaryRatingSourceAsync` reads the stored override-or-default, and `ReferenceDataAdminController`'s `rating-sources` GET/PUT plus a `.../recompute` POST (a synchronous bulk `SetReferenceRatingAsync` pass, no +provider calls) let an admin switch it and re-propagate to every already-linked item. +The admin card, endpoints, and recompute loop are all domain-generic (they iterate `RatingSourceCatalog.SelectableDomains`), so a domain gaining a second source only needs a catalog entry plus routing its `PrimaryRating` call sites through +`GetPrimaryRatingSourceAsync` - no controller/UI change (this is exactly how movies/TV joined when IMDb landed). + +**Movies/TV get their IMDb rating from OMDb** (`IOmdbClient`/`OmdbClient`, `WebApi/ReferenceData/`), keyed by the IMDb id TMDB already exposes - +IMDb itself has no public ratings API, so this is the sanctioned path (directly analogous to the book ISBN→Open Library rating fallback: +TMDB plays Google Books' role of handing off the cross-provider identifier, OMDb plays Open Library's role of turning it into a rating, stored under its own `imdb` source key on the same 0-10 scale as `tmdb`). +The IMDb id is native on `/movie/{id}` (`imdb_id`, zero extra calls) but not on `/tv/{id}` - the TV details call appends it via `?append_to_response=external_ids` (still one call, no season fan-out), and it's stored in the reference's +`ExternalIds["imdb"]`. +**OMDb is optional/best-effort**: unlike every other provider's settings, `OmdbSettings.ApiKey` is nullable (not `required`), and `AppConfiguration.OmdbSettings` coalesces a missing `Omdb` section to an empty instance - +a deployment with no `Omdb__ApiKey` simply keeps movies/TV on their TMDB rating alone rather than failing resolution/refresh, and the integration/e2e hosts need no OMDb key. + +**Gotcha (IMDb backfill bootstrap):** the TMDB `/changes` short-circuit (`LastEnrichedAt is not null && Ratings.Count > 0`) skips the full re-fetch once a `tmdb` rating exists, so a reference enriched before IMDb existed would never +backfill an `imdb` one - it has a tmdb rating (so it short-circuits) but no stored imdb id (only a full fetch writes that), a chicken-and-egg the first version hit against a real dev database (only the handful of references that happened to +full-fetch got IMDb). +Fixed by `BackfillImdbRatingAsync` on the no-change path: when the imdb rating is missing it resolves the imdb id cheaply via TMDB's dedicated `/{tv,movie}/{id}/external_ids` endpoint +(`ITmdbClient.GetTvShowImdbIdAsync`/`GetMovieImdbIdAsync` - one call, **no** season fan-out, deliberately not the full details re-fetch the short-circuit avoids), stores it, then does the one OMDb call. Self-correcting: once the id is stored, later syncs skip the external-ids lookup; a title OMDb genuinely has no rating for just retries one cheap OMDb call per full sync rather than needing a persisted "attempted" marker. `VideoGameModel.Platform`/`State`-style "this tenant's own copy" fields are never touched by any of this - only the shared reference document and the denormalized scalar. The full design (two homes for a rating, propagation, this admin mechanism, the IMDb phase above, and the pending top-rated phase) is tracked in `docs/reference-ratings-plan.md` until the feature settles. -`ReferenceDataAdminPage.razor`'s per-domain primary-rating-source picker is a `form-select` dropdown (`SourceLabel` maps the lowercase source key to a display name - `rawg`→"RAWG", `imdb`→"IMDb", ...), not a button row - a dropdown is one uniform-width control, whereas per-source buttons render at different widths by text length ("RAWG" vs "Metacritic"). +`ReferenceDataAdminPage.razor`'s per-domain primary-rating-source picker is a `form-select` dropdown (`SourceLabel` maps the lowercase source key to a display name - `rawg`→"RAWG", `imdb`→"IMDb", ...), not a button row - a dropdown is one +uniform-width control, whereas per-source buttons render at different widths by text length ("RAWG" vs "Metacritic"). The app is meant to be publicly shareable: anyone can sign in (Google/GitHub via Firebase Auth), but a plain account with **no** `role` claim is a *free preview* tier. Free tier = movies and TV shows only, capped at `Features:FreeTierItemLimit` creations per collection (default 20, guarded in `AppConfiguration.GetFreeTierItemLimit` so a missing setting can never lock the tier out entirely); @@ -588,7 +609,8 @@ So the exclusion has to happen at read time here instead of relying on the flag **`WantToWatch` is a movie-only concept - TV shows deliberately don't have it.** A TV show reaches Watch Next through `ComputeInProgressShows` (`State == Current` plus a confirmed unseen episode), which never consulted a want-to-watch flag, and there is no "shows to watch" section for a not-yet-started show to surface in either. -The flag briefly existed on `TvShowModel`/entity/DTO (populated by the TV Time import's "for_later" status and a detail-page "Watchlist" toggle) but had no consuming feature, so it was removed across every layer along with the `tvshow_want_to_watch` index - +The flag briefly existed on `TvShowModel`/entity/DTO (populated by the TV Time import's "for_later" status and a detail-page "Watchlist" toggle) but had no consuming feature, so it was removed across every layer along with the +`tvshow_want_to_watch` index - existing documents are cleaned up by the one-off `scripts/unset-tvshow-want-to-watch.js`. Don't reintroduce it as a plain flag; if a "shows I want to start" surface is ever wanted, build it as a real Watch Next section, not a dead flag. @@ -603,26 +625,32 @@ An entirely future season simply doesn't appear in the season picker at all once Covered domains are Movie, TvShow and VideoGame; Book/Album are rejected with a 400 (an aggregate rank doesn't drive discovery there, and neither provider offers a best-of listing to read). **The discovery list comes from the provider, never from the local `*_reference` collections.** -This was the original implementation's core mistake, since fixed: a reference document only exists because *someone already tracks* that title, so querying locally can only ever re-suggest things the user (or another tenant) has - the opposite of discovery. +This was the original implementation's core mistake, since fixed: a reference document only exists because *someone already tracks* that title, so querying locally can only ever re-suggest things the user (or another tenant) has - +the opposite of discovery. Each domain reads its own reference provider's best-of listing: TMDB `/{movie,tv}/top_rated` for movies/TV, RAWG `/games?ordering=-{rating|metacritic}` for video games. **Ordering follows the admin-selected primary rating source** (`RatingSourceCatalog.Resolve`, the same setting and the same resolver the rest of the app ranks by - no Explore-specific setting). Video games need no exception: RAWG sorts natively on both of its own sources (`rawg`'s 0-5 score and `metacritic`'s 0-100), and both values are already on every listing entry, so the selected one is picked with zero extra calls. -Movies/TV under **IMDb** are the one awkward case, and only because IMDb has no catalogue/top-rated API at all (OMDb only turns a known id into a rating): the *list* still comes from TMDB's ranking and OMDb only fills in the displayed number per title. +Movies/TV under **IMDb** are the one awkward case, and only because IMDb has no catalogue/top-rated API at all (OMDb only turns a known id into a rating): +the *list* still comes from TMDB's ranking and OMDb only fills in the displayed number per title. That page is deliberately **not** re-sorted by the IMDb value - partial OMDb data (rate-limited, or no key configured) would float low/unrated titles to the top. -The admin toggle `app_setting.explore_use_tmdb` (`IAppSettingRepository.Get/SetExploreUseTmdbAsync`) forces movies/TV back onto TMDB's own vote so discovery skips those per-title lookups entirely; it's read *only* when IMDb actually won the resolve, so it can never leak into the video game domain (whose sources don't include IMDb) - `GetSuggestionsAsync_ForVideoGames_IsUnaffectedByTheForceTmdbExploreFlag` pins that. +The admin toggle `app_setting.explore_use_tmdb` (`IAppSettingRepository.Get/SetExploreUseTmdbAsync`) forces movies/TV back onto TMDB's own vote so discovery skips those per-title lookups entirely; +it's read *only* when IMDb actually won the resolve, so it can never leak into the video game domain (whose sources don't include IMDb) - `GetSuggestionsAsync_ForVideoGames_IsUnaffectedByTheForceTmdbExploreFlag` pins that. **Gotcha: RAWG has no curated top-rated endpoint the way TMDB does, and its `rating` is a plain average with no vote-count filter or sort option.** Ordering the whole ~900k-game catalogue by `-rating` would therefore rank an unknown game carrying a single 5-star vote above every classic. -`RawgClient.GetTopRatedGamesAsync` constrains the pool server-side with `metacritic={MinMetacritic},100` - requiring that the game was reviewed by the professional press at all is the closest available equivalent of the minimum vote count TMDB's own top-rated list already applies, and it costs no extra call. +`RawgClient.GetTopRatedGamesAsync` constrains the pool server-side with `metacritic={MinMetacritic},100` - requiring that the game was reviewed by the professional press at all is the closest available equivalent of the minimum vote count +TMDB's own top-rated list already applies, and it costs no extra call. `MinMetacritic` is the knob to raise if the list still reads as obscure. Don't "fix" this by filtering low-vote entries client-side instead: the paging loop stops on an empty provider page, so a filter that can empty a whole page would silently truncate the results. **The "already have it" exclusion has two halves, and both are needed.** The primary one is by provider id: the owner's linked reference ids (`IExploreSourceRepository.FindLinkedReferenceIdsAsync`) resolved to those reference documents' own `ExternalIds[provider]`. -The fallback is by normalized title (`FindDistinctTitlesAsync` + `TitleNormalizer`), because automatic resolution deliberately gives up when a title search returns several candidates - so a manually-added or imported item can easily have *no* reference link at all and would otherwise be suggested back forever. +The fallback is by normalized title (`FindDistinctTitlesAsync` + `TitleNormalizer`), because automatic resolution deliberately gives up when a title search returns several candidates - +so a manually-added or imported item can easily have *no* reference link at all and would otherwise be suggested back forever. Two genuinely different works sharing one title collapse under the fallback, which is an accepted trade (hiding one discovery card beats re-suggesting something the owner owns). -`IExploreSourceRepository` (`Domain/Repositories/`) declares both projections once; `ExploreExclusionQueries` (`Infrastructure.MongoDb/Repositories/`) implements them once for every domain - each repository contributes only the field expression, the same shape as the `SortTitleField` hook. +`IExploreSourceRepository` (`Domain/Repositories/`) declares both projections once; `ExploreExclusionQueries` (`Infrastructure.MongoDb/Repositories/`) implements them once for every domain - +each repository contributes only the field expression, the same shape as the `SortTitleField` hook. **`explore_dismissal` is keyed on the *provider's* title, not on a reference document.** A suggestion is by definition something nobody tracks yet, so there is usually no `reference_id` to point at until it's actually added - `{owner_id, item_type, external_source, external_id}` (unique) is the natural key. @@ -632,12 +660,15 @@ It's stored rather than inferred from `item_type` because a RAWG id and a TMDB i **Adding goes through Explore's own endpoint, not the ordinary `POST /api/{collection}` create.** `POST /api/explore/{type}/add/{externalId}` creates the item and then calls `Resolve{Movie,TvShow,VideoGame}Async` with the *exact* provider id the suggestion came from. -The ordinary create's background auto-resolve is a title *search* that only links when there's exactly one candidate, so acclaimed titles with several candidates (common for movies) would be created unlinked - reliably linking is the whole reason this path exists. +The ordinary create's background auto-resolve is a title *search* that only links when there's exactly one candidate, so acclaimed titles with several candidates (common for movies) would be created unlinked - +reliably linking is the whole reason this path exists. It's awaited, so the card only disappears once the item is genuinely linked. The free-tier quota is enforced here exactly as `DataCrudControllerBase.Post` does it (`FreeTierQuota.CheckAsync`). -The controller carries plain `[Authorize]`, not `MemberOnly`, because movies/TV are the free preview tier; video games are member-gated per request instead (`RequireAccessTo`, a 403), and the Blazor page hides the tab behind `` and falls back to the Movies tab if a free account lands on `?tab=VideoGames` - hiding is UX, the API is the enforcement. +The controller carries plain `[Authorize]`, not `MemberOnly`, because movies/TV are the free preview tier; video games are member-gated per request instead (`RequireAccessTo`, a 403), and the Blazor page hides the tab behind `` and falls back to the Movies tab if a free account lands on `?tab=VideoGames` - hiding is UX, the API is the enforcement. -`ExplorePage.razor` keeps the active tab in `?tab=` (back/forward and refresh preserve it), caches one loaded list per tab, and tops a tab back up whenever it drops below the page size after an add/dismiss - appending below the current cards, never reshuffling what's on screen. +`ExplorePage.razor` keeps the active tab in `?tab=` (back/forward and refresh preserve it), caches one loaded list per tab, and tops a tab back up whenever it drops below the page size after an add/dismiss - +appending below the current cards, never reshuffling what's on screen. Add and dismiss share one `ActAsync` (busy-guard, remove, top-up) so the two handlers never duplicate that logic. ### Keeping reference data fresh: periodic + on-demand TMDB sync @@ -770,7 +801,8 @@ The list page's filter buttons are a different control with different semantics The enum type itself keeps its `TvShowStatus` name - only the property that holds it moved to `State`, since `VideoGameModel.State` has no equivalent enum to rename against. Unlike the `PosterUrl`→`ImageUrl` rename below, this one needed **no** data migration: `TvShow`'s entity property kept an explicit `[BsonElement("status")]` pointing at the unchanged storage name, so existing documents (confirmed directly against the real dev database - `status: 'Finished'` reads back correctly through the renamed `State` property) deserialize with no script required. -`TvTimeImportService`/`ShowStatusCsvParser`'s `ShowStatusRecord.Status` is a same-named but *entirely unrelated* field - TV Time's own CSV column for favorite/for_later, mapped to `IsFavorite` (the "for_later" value has no counterpart for shows and is not imported), never to this enum - +`TvTimeImportService`/`ShowStatusCsvParser`'s `ShowStatusRecord.Status` is a same-named but *entirely unrelated* field - TV Time's own CSV column for favorite/for_later, mapped to `IsFavorite` (the "for_later" value has no counterpart for +shows and is not imported), never to this enum - so the import pipeline needed no changes at all for this rename; verified by tracing every consumer before renaming, not just running the test suite. `WatchNextService`/`WatchNextController`'s `Status == TvShowStatus.Current` checks were updated to `State == TvShowStatus.Current` and covered by `WatchNextServiceTest`, which still passes. @@ -897,7 +929,8 @@ Open Library's client doesn't populate it (the value lives at the edition level, The reference-level `BookReferenceModel.Language`/`BookReferenceDto.Language` and `IBookRepository.SetReferenceLinkAsync`'s `canonicalLanguage` parameter follow the exact same propagation shape `Genre`/`canonicalGenre` already established: null (not overwritten) when the provider has none. -`BookModel.Isbn` follows the same shape again, with two differences from Genre/Language. First, it's edited on `BookDetail.razor` only, never the Add form +`BookModel.Isbn` follows the same shape again, with two differences from Genre/Language. +First, it's edited on `BookDetail.razor` only, never the Add form (`Books.razor`'s add card only carries title/author/year, per this document's own "Adding a new trackable item" convention). Second, it doubles as an optional *search input*: `IBookReferenceClient.SearchBooksAsync` takes an `isbn` parameter, but only `GoogleBooksClient` actually uses it (as the sole query, `isbn:{isbn}`, superseding title/author entirely - an ISBN is an exact identifier, so combining it with a fuzzy title/author match would only reintroduce the kind of "and" narrowing risk `BnfClient`'s own author fix (above) had to work around). @@ -908,7 +941,8 @@ there was no push to generalize the parameter name here the way `creator` was, s **`ReferenceMatchModel`/`ReferenceMatch` gained an `Isbn` field (null for every domain but Book) specifically so a matched alias only ever records the identifier that actually drove that particular match** - the canonical alias (the provider's own reported ISBN, from `BookDetails.Isbn`) and the tenant-search alias (whatever ISBN, if any, was actually supplied as search input) are two separate entries, never merged, -and the search alias's `Isbn` is never backfilled from the provider's own value when no ISBN was actually used to find the match. `MergeMatchedAliases`' shared tuple shape grew a 4th element for this (`(Title, Year, Creator, Isbn)`); +and the search alias's `Isbn` is never backfilled from the provider's own value when no ISBN was actually used to find the match. +`MergeMatchedAliases`' shared tuple shape grew a 4th element for this (`(Title, Year, Creator, Isbn)`); every non-Book call site across `.TvShowsAndMovies.cs`/`.VideoGames.cs`/`.Albums.cs` passes a literal `null` for it, same as `Creator` already does for the domains with no creator dimension. ### Blazor app @@ -1020,25 +1054,33 @@ Two rules follow from that, and both have already been broken in ways that cost `Infrastructure__MongoDB__DatabaseName` selects it (`keeptrack_integrationtests` and `keeptrack_e2e` by convention; see CONTRIBUTING.md). The trap is that this is *silent* when unset: the in-process host runs as `Development`, so it falls straight back to `src/WebApi/appsettings.Development.json` - i.e. `keeptrack_dev`, the database the developer actually browses in the app. Nothing errors; the suite just creates, mutates and deletes documents in real data. -It's easy to hit by accident rather than carelessness, because the documented way to run a *filtered* subset (see the `--settings`/`--filter-method` gotcha above) is to export the runsettings' variables into the shell yourself - forget that step and the run lands on `keeptrack_dev`. +It's easy to hit by accident rather than carelessness, because the documented way to run a *filtered* subset (see the `--settings`/`--filter-method` gotcha above) is to export the runsettings' variables into the shell yourself - forget that +step and the run lands on `keeptrack_dev`. That is exactly how the dev database ended up holding 180 `test-lease-*` documents, 65 `Export Test Actor` person references and stray `E2e Smoke *` items. -`Testing.Shared/Hosting/TestDatabaseGuard.EnsureExplicitTestDatabase` now fails the run fast instead, called from `WebApi.IntegrationTests`' `KestrelWebAppFactory` constructor (so every fixture inherits it) and from `End2EndFixture` in self-hosted mode. +`Testing.Shared/Hosting/TestDatabaseGuard.EnsureExplicitTestDatabase` now fails the run fast instead, called from `WebApi.IntegrationTests`' `KestrelWebAppFactory` constructor (so every fixture inherits it) and from `End2EndFixture` in +self-hosted mode. **Every test removes what it created, on success and on failure.** This isn't tidiness: `scripts/mongodb-create-index.js` enforces natural-key and `external_ids` uniqueness, so yesterday's leftover makes today's run fail with a duplicate-key error. -Cleanup is registered at the moment of creation rather than written as a per-test `try`/`finally` - a `finally` only covers what was created before the `try` opened, and the common "create two fixtures, then open the try" shape leaked whenever the second create failed. - -- `DatabaseTestBase` (`test/WebApi.IntegrationTests/Resources/`) holds the registry every integration test inherits: `TrackCleanup(Func)` for anything (a repository `DeleteAsync`), `TrackDocument(collection, id)` for a raw document, and `TrackDocumentsWhere(collection, filter)` for an owner-scoped singleton with no id the test ever sees (`user_preference`). - `ResourceTestBase` extends it with the HTTP-level ones: `CreateAsync` (POST + register, the shape almost every test wants), `TrackResource(endpoint, id)`, and `TrackResourcesMatching(endpoint, searchTerm)` for the import endpoints, whose commit creates items the test never learns the ids of. -- `SmokeTestBase` mirrors it for Playwright: `TrackOpenItem(apiRoute)` reads the id out of the detail page's URL (the only place a UI-driven test can learn it), `CreateItemAsync` seeds through the API, `TrackItemsMatching` covers the import commits, and `TrackCleanup` handles the rest. +Cleanup is registered at the moment of creation rather than written as a per-test `try`/`finally` - a `finally` only covers what was created before the `try` opened, and the common "create two fixtures, then open the try" shape leaked +whenever the second create failed. + +- `DatabaseTestBase` (`test/WebApi.IntegrationTests/Resources/`) holds the registry every integration test inherits: `TrackCleanup(Func)` for anything (a repository `DeleteAsync`), `TrackDocument(collection, id)` for a raw document, + and `TrackDocumentsWhere(collection, filter)` for an owner-scoped singleton with no id the test ever sees (`user_preference`). + `ResourceTestBase` extends it with the HTTP-level ones: `CreateAsync` (POST + register, the shape almost every test wants), `TrackResource(endpoint, id)`, and `TrackResourcesMatching(endpoint, searchTerm)` for the import endpoints, + whose commit creates items the test never learns the ids of. +- `SmokeTestBase` mirrors it for Playwright: `TrackOpenItem(apiRoute)` reads the id out of the detail page's URL (the only place a UI-driven test can learn it), `CreateItemAsync` seeds through the API, `TrackItemsMatching` covers the import + commits, and `TrackCleanup` handles the rest. - `DisposeAsync` **drains** the registry rather than iterating a cached count, because `TrackResourcesMatching` can only discover what an import created at cleanup time and then registers each id it found. - Cleanups run under `CancellationToken.None`, never `TestContext.Current.CancellationToken` - that token is cancelled exactly when a test times out or the run is interrupted, which is precisely when leftovers are most likely. **Gotcha, and the reason this class of bug is so persistent: a delete filter that matches nothing looks exactly like a delete that worked.** -`Builders.Filter.Eq("_id", id)` with a `string` id against a document whose `_id` is an `ObjectId` matches nothing, deletes nothing, and reports success - the tests using it kept passing for months while 65 `Export Test Actor` documents piled up. +`Builders.Filter.Eq("_id", id)` with a `string` id against a document whose `_id` is an `ObjectId` matches nothing, deletes nothing, and reports success - +the tests using it kept passing for months while 65 `Export Test Actor` documents piled up. The typed `Eq(x => x.Id, id)` form works; the string-field-name form does not. `TrackDocument` sidesteps it entirely by filtering over `BsonDocument` and converting the id to an `ObjectId` when it parses (`lease`/`background_job` ids are genuine strings and simply don't, so one helper covers both). -Never assert cleanup worked by reading the test's exit status - **verify with a document-count diff across the run**, which is what proved the current state: the integration suite returns `keeptrack_integrationtests` to its exact baseline, run after run. +Never assert cleanup worked by reading the test's exit status - **verify with a document-count diff across the run**, which is what proved the current state: +the integration suite returns `keeptrack_integrationtests` to its exact baseline, run after run. **Reference documents created by linking a *real* provider title are deliberately left in place** (TMDB's "The Terminator", the cast rows behind it, a Google Books volume). They're shared canonical facts, deduplicated by provider id, so a re-run reuses the same document instead of adding another - they don't accumulate, and deleting them only forces the next run to re-fetch. @@ -1052,7 +1094,8 @@ Two test classes running in parallel both inserting `tmdb: "1"` is a duplicate-k Mapper configuration validation is a compile-time concern now (Mapperly's `RMG012`/`RMG020` diagnostics, escalated to build errors in `.editorconfig`), not a unit test - there's no equivalent of the old `AutoMapperConfigurationTest` to run here anymore. - `test/WebApi.IntegrationTests`: xunit v3 tests booted against a real Kestrel host (`KestrelWebAppFactory`) and a real MongoDB instance. - `ResourceTestBase` provides typed `GetAsync`/`PostAsync`/`PutAsync`/`DeleteAsync`/`PostFileAsync` helpers, an `Authenticate()` helper that logs in against Firebase to obtain a bearer token, and the cleanup-registration helpers described above. + `ResourceTestBase` provides typed `GetAsync`/`PostAsync`/`PutAsync`/`DeleteAsync`/`PostFileAsync` helpers, an `Authenticate()` helper that logs in against Firebase to obtain a bearer token, and the cleanup-registration helpers described + above. `Authenticate()` also exposes `AuthenticatedUserId`, the same value the API stamps as `OwnerId`, for the cleanups that can only identify a document by its owner. It's the `user_id` claim read straight out of the token payload - no signature check, since the API validates the token on every call. Resource tests (`BookResourceTest`, `MovieResourceTest`, `TvTimeImportResourceTest`) exercise a full create/read/update/delete (or upsert) cycle against the live API and clean up what they create. @@ -1068,7 +1111,8 @@ Two test classes running in parallel both inserting `tmdb: "1"` is a duplicate-k It signs in exactly once for the whole run (`POST /auth/callback` + saved Playwright storage state, reusing `Testing.Shared`'s `AccountRepository` sign-in cache). It also seeds a synthetic book reference via `POST /api/reference-data/import` so "check for reference match" never calls a real provider. That seed carries a **fixed** `ReferenceFixtureZipBuilder.ReferenceId` rather than letting the import mint a new one. - The import is only idempotent for a document that already has an id (each reference repository's `UpsertAsync` replaces by id), so without it every run inserted another copy - 22 identical "The Playwright Chronicles" documents had accumulated. + The import is only idempotent for a document that already has an id (each reference repository's `UpsertAsync` replaces by id), so without it every run inserted another copy - + 22 identical "The Playwright Chronicles" documents had accumulated. The fixture removes it again on dispose, through the hosted `IBookReferenceRepository` rather than HTTP, since no admin endpoint deletes a single reference document and inventing one just to let tests tidy up would be the wrong trade. It also deletes the run's ephemeral user's own `user_preference`/`background_job` rows (owner-scoped, no delete endpoint, and nothing else can reach them). That's guarded on the user actually being ephemeral, so a run pointed at a real account via `E2E_USERNAME` never wipes that person's saved preferences. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0511c27..b2d40ed4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,14 +22,14 @@ If you do not agree with these terms, do not submit a contribution. The application source code is in the following .NET projects: -Project name | Technology | Project type ----------------------------|------------|------------- -`BlazorApp` | ASP.NET 10 | Blazor Server web application -`Common.System` | .NET 10 | Library -`Domain` | .NET 10 | Library -`Infrastructure.MongoDb` | .NET 10 | Library -`WebApi` | ASP.NET 10 | Web application (REST API) -`WebApi.Contracts` | .NET 10 | Library +Project name | Technology | Project type +-------------------------|------------|------------- +`BlazorApp` | ASP.NET 10 | Blazor Server web application +`Common.System` | .NET 10 | Library +`Domain` | .NET 10 | Library +`Infrastructure.MongoDb` | .NET 10 | Library +`WebApi` | ASP.NET 10 | Web application (REST API) +`WebApi.Contracts` | .NET 10 | Library The application is using the following .NET packages (via NuGet): diff --git a/docs/code-quality-findings.md b/docs/code-quality-findings.md index 55f504d6..c45357d8 100644 --- a/docs/code-quality-findings.md +++ b/docs/code-quality-findings.md @@ -11,8 +11,8 @@ Update this file as items are fixed or as new reviews are performed. Verdict: false positive, don't touch the code. This rule fires when Sonar's engine believes nullable warnings are disabled at that point, making ! a no-op. - But BlazorApp.csproj has enable project-wide, and every flagged ! (e.g. context.User.Identity!.Name! in Manage.razor:8, (bool)e.Value! in several @onchange handlers) - is a genuine, meaningful suppression against a real nullable-annotated API (ClaimsPrincipal.Identity, ChangeEventArgs.Value). + But BlazorApp.csproj has enable project-wide, and every flagged ! (e.g. `context.User.Identity!.Name!` in Manage.razor:8, `(bool)e.Value!` in several @onchange handlers) is a genuine, meaningful suppression against + a real nullable-annotated API (ClaimsPrincipal.Identity, ChangeEventArgs.Value). This is a known SonarC# limitation with Razor-generated code: the source generator's nullable-context pragmas don't map cleanly back onto markup-embedded lambdas/expressions, so Sonar loses track of the enclosing #nullable enable region. @@ -27,32 +27,51 @@ Update this file as items are fixed or as new reviews are performed. Fixed on 2026-07-27, reviewed against `https://sonarcloud.io/project/issues?issueStatuses=OPEN&id=devpro_keeptrack` (28 open issues at the time, excluding 3 `S1135` "TODO" issues out of scope for this pass). -- **S2365** CRITICAL, `PlaylistDetail.razor:144` - `PlaylistSongs` was a property doing `.Select().Where().ToList()` on every access, read twice per render (`.Count` then `@foreach`). Renamed to a method, `GetPlaylistSongs()`, per convention (expensive/allocating work shouldn't look like a cheap property). No behavior change. -- **ASP0025** INFO × 2, `WebApi/Program.cs`, `BlazorApp/Program.cs` - both used the older `AddAuthorization(options => { options.AddPolicy(...); ... })` shape; switched to `AddAuthorizationBuilder().AddPolicy(...).AddPolicy(...)`, the modern .NET 8+ API. Same policies, same behavior. +- **S2365** CRITICAL, `PlaylistDetail.razor:144` - `PlaylistSongs` was a property doing `.Select().Where().ToList()` on every access, read twice per render (`.Count` then `@foreach`). + Renamed to a method, `GetPlaylistSongs()`, per convention (expensive/allocating work shouldn't look like a cheap property). + No behavior change. +- **ASP0025** INFO × 2, `WebApi/Program.cs`, `BlazorApp/Program.cs` - both used the older `AddAuthorization(options => { options.AddPolicy(...); ... })` shape; + switched to `AddAuthorizationBuilder().AddPolicy(...).AddPolicy(...)`, the modern .NET 8+ API. + Same policies, same behavior. - **CA1862** INFO × 2, `AlbumReferenceRepositoryTest.cs:103`, `BookReferenceRepositoryTest.cs:144` - test assertions did `m.Title == title.ToLowerInvariant()`; switched to `string.Equals(m.Title, title, StringComparison.OrdinalIgnoreCase)`. -- **CA1859** INFO × 3 - `AmazonOrderPreviewServiceTest.ToStream`/`GenericVideoGameImportServiceTest.ToStream` now return `MemoryStream` instead of `Stream`; `OpenLibraryClientTest.BuildClient` now returns `OpenLibraryClient` instead of `IBookReferenceClient` (checked call sites first - both members it exposes, `ProviderKey`/`GetBookDetailsAsync`, are public on the concrete class, not explicit interface implementations). -- **javascript S2486** MINOR, `ReconnectModal.razor.js:42` - `catch (err)` never used `err`, silently swallowing the exception. Added `console.error("Blazor reconnect failed:", err)`. - -Investigated but confirmed **not** actionable during the same pass (left as-is, see "Sonar issues" note below for the rest of the 28): `S8969` in `TvTimeImportService.cs:469-470` looked like the same Razor-generated-code false positive as `S8970` above, but is a **different, real** finding - removing the `!` after `Dictionary.TryGetValue`'s `out model!` reintroduces genuine `CS8601` warnings on rebuild (confirmed by actually removing it and rebuilding), because `[MaybeNullWhen(false)]` doesn't flow cleanly through the open generic `TModel` parameter here. Don't conflate the two rule ids - `S8970` (Razor markup, nullable context lost) is a false positive; `S8969` (plain `.cs`, "the compiler already knows") needs checking case-by-case, this one isn't. +- **CA1859** INFO × 3 - `AmazonOrderPreviewServiceTest.ToStream`/`GenericVideoGameImportServiceTest.ToStream` now return `MemoryStream` instead of `Stream`; + `OpenLibraryClientTest.BuildClient` now returns `OpenLibraryClient` instead of `IBookReferenceClient` (checked call sites first - + both members it exposes, `ProviderKey`/`GetBookDetailsAsync`, are public on the concrete class, not explicit interface implementations). +- **javascript S2486** MINOR, `ReconnectModal.razor.js:42` - `catch (err)` never used `err`, silently swallowing the exception. + Added `console.error("Blazor reconnect failed:", err)`. + +Investigated but confirmed **not** actionable during the same pass (left as-is, see "Sonar issues" note below for the rest of the 28): +`S8969` in `TvTimeImportService.cs:469-470` looked like the same Razor-generated-code false positive as `S8970` above, but is a **different, real** finding - +removing the `!` after `Dictionary.TryGetValue`'s `out model!` reintroduces genuine `CS8601` warnings on rebuild (confirmed by actually removing it and rebuilding), because `[MaybeNullWhen(false)]` doesn't flow cleanly through +the open generic `TModel` parameter here. +Don't conflate the two rule ids - `S8970` (Razor markup, nullable context lost) is a false positive; `S8969` (plain `.cs`, "the compiler already knows") needs checking case-by-case, this one isn't. Verification: full solution build (0 warnings/errors), full `WebApi.UnitTests` run (235/235 passed, includes the three CA1859-touched test classes). Once a local MongoDB became available, also ran directly against it (`Local.runsettings` env vars loaded into the process, real Firebase auth): `AlbumReferenceRepositoryTest`/`BookReferenceRepositoryTest` (the two `CA1862` files) - 7/7 passed; -`ReferenceDataAdminResourceTest` (real HTTP call through `[Authorize(Policy="AdminOnly")]`) plus `PlaylistResourceTest`/`BookResourceTest` (real HTTP calls through `[Authorize(Policy="MemberOnly")]`) - 9/9 passed (1 self-skipped, the opt-in `SyncNow_PollingReachesACompletedResult`) - this is what actually proves the `ASP0025` `AddAuthorizationBuilder` switch still enforces both policies correctly end-to-end, not just that the attribute is present; +`ReferenceDataAdminResourceTest` (real HTTP call through `[Authorize(Policy="AdminOnly")]`) plus `PlaylistResourceTest`/`BookResourceTest` (real HTTP calls through `[Authorize(Policy="MemberOnly")]`) - +9/9 passed (1 self-skipped, the opt-in `SyncNow_PollingReachesACompletedResult`) - this is what actually proves the `ASP0025` `AddAuthorizationBuilder` switch still enforces both policies correctly end-to-end, not just that the attribute is +present; `BlazorApp.PlaywrightTests`' `PlaylistSmokeTest.AddAndDelete_PlaylistThroughTheList` (real browser, real Blazor Server circuit) - passed, proving `GetPlaylistSongs()` renders correctly post-rename. -That Playwright test still only exercises `GetPlaylistSongs()`'s empty-list branch (no song is ever added to the playlist in that test) - the populated-list/dangling-`SongId`-skip branch has no automated coverage before or after this change; adding it would need a synthetic album+tracklist fixture (a bigger addition than the rename itself), flagged here rather than silently left uncovered. +That Playwright test still only exercises `GetPlaylistSongs()`'s empty-list branch (no song is ever added to the playlist in that test) - the populated-list/dangling-`SongId`-skip branch has no automated coverage before or after this +change; adding it would need a synthetic album+tracklist fixture (a bigger addition than the rename itself), flagged here rather than silently left uncovered. -Files: `src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor`, `src/WebApi/Program.cs`, `src/BlazorApp/Program.cs`, `test/WebApi.IntegrationTests/Resources/AlbumReferenceRepositoryTest.cs`, `test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs`, `test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs`, `test/WebApi.UnitTests/Services/GenericVideoGameImportServiceTest.cs`, `test/WebApi.UnitTests/ReferenceData/OpenLibraryClientTest.cs`, `src/BlazorApp/Components/Layout/ReconnectModal.razor.js` +Files: `src/BlazorApp/Components/Inventory/Pages/PlaylistDetail.razor`, `src/WebApi/Program.cs`, `src/BlazorApp/Program.cs`, `test/WebApi.IntegrationTests/Resources/AlbumReferenceRepositoryTest.cs`, +`test/WebApi.IntegrationTests/Resources/BookReferenceRepositoryTest.cs`, `test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs`, `test/WebApi.UnitTests/Services/GenericVideoGameImportServiceTest.cs`, +`test/WebApi.UnitTests/ReferenceData/OpenLibraryClientTest.cs`, `src/BlazorApp/Components/Layout/ReconnectModal.razor.js` ### S107 — too many parameters on `OwnedItemImportMergeService.ComputeCommitPlan`/`.MergeItem` and `AmazonImportController.CommitAsync` -Fixed on 2026-07-26 (PR #467 Sonar review). All three methods carried the same six-delegate bundle +Fixed on 2026-07-26 (PR #467 Sonar review). +All three methods carried the same six-delegate bundle (`getExistingTitle`, `getExistingReferences`, `getItemTitle`, `getItemReference`, `createNew`, `appendOwnedCopy`) as separate parameters, pushing their signatures to 8/9/10 params. This was the intentional "generic engine over delegates instead of an interface" design (still documented in CLAUDE.md), so the fix keeps that design - it just stops repeating the six delegates individually. Bundled them into one new `OwnedItemImportAdapter` record (`Domain/Models/OwnedItemImportAdapter.cs`) and threaded that single value through instead, -cutting `ComputeCommitPlan` to 3 params, `MergeItem` to 6, and `CommitAsync` to 5 - no change in behavior or genericity, all 8 call sites (6 in `AmazonImportController`, 1 in `GenericVideoGameImportController`, 2 in `OwnedItemImportMergeServiceTest`) construct the adapter inline the same way they used to pass the delegates. +cutting `ComputeCommitPlan` to 3 params, `MergeItem` to 6, and `CommitAsync` to 5 - no change in behavior or genericity, all 8 call sites (6 in `AmazonImportController`, 1 in `GenericVideoGameImportController`, 2 in +`OwnedItemImportMergeServiceTest`) construct the adapter inline the same way they used to pass the delegates. -Files: `src/Domain/Models/OwnedItemImportAdapter.cs`, `src/Domain/Services/OwnedItemImportMergeService.cs`, `src/WebApi/Controllers/AmazonImportController.cs`, `src/WebApi/Controllers/GenericVideoGameImportController.cs`, `test/WebApi.UnitTests/Services/OwnedItemImportMergeServiceTest.cs` +Files: `src/Domain/Models/OwnedItemImportAdapter.cs`, `src/Domain/Services/OwnedItemImportMergeService.cs`, `src/WebApi/Controllers/AmazonImportController.cs`, `src/WebApi/Controllers/GenericVideoGameImportController.cs`, +`test/WebApi.UnitTests/Services/OwnedItemImportMergeServiceTest.cs` ### Title-only fallback ignored a tenant-recorded year, so two same-titled but genuinely different items could be silently linked to the same reference document - or, worse, merged into one via `Resolve*Async` @@ -218,7 +237,8 @@ Files: ## Confirmed by design -These were reviewed with the project owner and are intentional. No action needed. +These were reviewed with the project owner and are intentional. +No action needed. ### Each entity searches its own fields @@ -229,7 +249,8 @@ This is intentional: each entity type exposes the search behavior that fits its ## Known gaps (not yet implemented) -These are acknowledged as incomplete rather than deliberately permanent. Track and prioritize separately. +These are acknowledged as incomplete rather than deliberately permanent. +Track and prioritize separately. ### Playwright: an inventory list row intermittently isn't visible under a full parallel run (triaged 2026-07-31 - do not re-investigate from scratch) @@ -246,7 +267,8 @@ It is not always the same test - `ListStateSmokeTest.Search_PersistsInUrl_AndSur (Book/ListState/Ownership/Reference/GoogleBooks and both import smoke tests all create books against the same tenant). - The Playwright expect timeout for these assertions is the 5s default. -**Not yet done:** finding the actual cause. The plausible candidates are list-read latency under concurrent load against the shared tenant +**Not yet done:** finding the actual cause. +The plausible candidates are list-read latency under concurrent load against the shared tenant (in which case the fix is a longer timeout on these specific assertions, not a global one) or a genuine enhanced-navigation render race. Decide between them before changing anything - raising timeouts blindly would hide the second case. diff --git a/docs/plan-quick-add.md b/docs/plan-quick-add.md index ccfa46b3..fc8d4bd7 100644 --- a/docs/plan-quick-add.md +++ b/docs/plan-quick-add.md @@ -62,7 +62,8 @@ Three extraction refactors come first — they remove exactly the duplication ** 4. `IOwnedCopyDto` + `OwnedVersionFields` - New `src/WebApi.Contracts/Dto/IOwnedCopyDto.cs`: - CopyType, Price, AcquiredAt, Vendor, Reference. Implemented by OwnedVersionDto and VideoGamePlatformDto (identical existing members; precedent: IReferenceLinkedDto; no Mapperly impact — mappers map members, not interfaces). + CopyType, Price, AcquiredAt, Vendor, Reference. + Implemented by OwnedVersionDto and VideoGamePlatformDto (identical existing members; precedent: IReferenceLinkedDto; no Mapperly impact — mappers map members, not interfaces). - New `src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor`: params required IOwnedCopyDto Copy, optional EventCallback OnChanged (default no-op for Quick Add's nothing-persists-until-Save flow). Body = Physical/Digital button pair + the four fields (invariant-culture decimal parsing included), keeping data-testid="version-*-input". @@ -79,7 +80,9 @@ Three extraction refactors come first — they remove exactly the duplication ** Movie + TV show always visible; the other six tiles AND their forms inside `` with the preview-account note in `` (hiding is UX; the API enforces). - Media forms (per-type markup local to the page, same convention as the list FormTemplates): - Movie: Title, Year, "Watched on" (FirstSeenAt, default today for the "just saw it" scenario — visible and clearable, since prefilling marks it Seen), Rating. - - TV show: Title, Year. Book: Title, Author, Year, FirstReadAt default today. Album: Title, Artist, Year (Author/Artist feed Open Library/Discogs auto-resolution). + - TV show: Title, Year. + Book: Title, Author, Year, FirstReadAt default today. + Album: Title, Artist, Year (Author/Artist feed Open Library/Discogs auto-resolution). - Video game: Title, Year, Platform `` was replaced with a button group (`VideoGames.VideoGameStates`, the shared array both the filter and the form buttons iterate over) for the same reason TvShow's own state buttons exist - -clicking a value is faster than a dropdown for a small fixed set. -`FinishedAt` is a plain, always-visible date field on `VideoGameDetail.razor`'s card (not a corner-flag toggle like `MovieDetail.razor`'s "Mark as watched"/`BookDetail.razor`'s "Mark as read"). -It was previously only editable once already set elsewhere, with no way to set it from scratch on the detail page. -Unlike Movie/Book, "finished" already has its own explicit `State` value ("Completed") to toggle, so `FinishedAt` doesn't need a second boolean-flag affordance layered on top. - -**`VideoGameDetail.razor`'s own State editor follows `TvShowDetail.razor`'s per-item State pattern exactly, not the list page's filter-button pattern.** These look superficially similar (both are button rows) but behave differently, -and the two were conflated once already. -The per-item editor buttons live in their own row directly below the page header (not inside the `kt-form-card` next to Year/Rating). -Clicking the already-active value clears it back to unset (`SetStateAsync`: `_game.State = _game.State == state ? "" : state`, mirroring `TvShowDetail.razor`'s own `SetStateAsync`). -The list page's filter buttons are a different control with different semantics (an explicit "All" option to clear, since a filter and a per-item value aren't the same kind of state). - -**`TvShowModel.Status` was renamed to `TvShowModel.State`** for naming parity with `VideoGameModel.State` -(`TvShowDto.State`, `TvShow.State` entity property, `TvShowRepository.GetFilter`'s `input.State`, `TvShows.razor`'s `_stateFilter`/`SetStateFilterAsync`/`ExtraQuery["State"]`, `TvShowDetail.razor`'s `SetStateAsync` all renamed to match). -The enum type itself keeps its `TvShowStatus` name - only the property that holds it moved to `State`, since `VideoGameModel.State` has no equivalent enum to rename against. -Unlike the `PosterUrl`→`ImageUrl` rename below, this one needed **no** data migration: `TvShow`'s entity property kept an explicit `[BsonElement("status")]` pointing at the unchanged storage name, -so existing documents (confirmed directly against the real dev database - `status: 'Finished'` reads back correctly through the renamed `State` property) deserialize with no script required. -`TvTimeImportService`/`ShowStatusCsvParser`'s `ShowStatusRecord.Status` is a same-named but *entirely unrelated* field - TV Time's own CSV column for favorite/for_later, mapped to `IsFavorite` (the "for_later" value has no counterpart for -shows and is not imported), never to this enum - -so the import pipeline needed no changes at all for this rename; verified by tracing every consumer before renaming, not just running the test suite. -`WatchNextService`/`WatchNextController`'s `Status == TvShowStatus.Current` checks were updated to `State == TvShowStatus.Current` and covered by `WatchNextServiceTest`, which still passes. - -**Gotcha:** an optional narrowing parameter on an external search must never be allowed to silently zero out results that a broader search would find - -this bit both the Open Library year filter (see above) and, separately, `IDiscogsClient.SearchAlbumsAsync`'s `artist` parameter: -a tenant's own `AlbumModel.Artist` text passed straight through as Discogs' `artist=` query field can fail to match Discogs' own exact indexing (a disambiguation suffix like `"Artist (2)"`, different capitalization/formatting), -returning zero candidates even though the title alone finds the album - confirmed with a real title ("Born Pink") that returned nothing via `AlbumDetail.razor`'s `InlineReferenceLinker` -(which always passes the tracked album's own `Artist`) but succeeded via the admin page (whose first search per selected item always passes no creator). -Both `DiscogsClient.SearchAlbumsAsync` and `OpenLibraryClient.SearchBooksAsync` now retry once without the narrowing author/artist parameter whenever the constrained search comes back empty, rather than reporting a false "not found." - -**Gotcha:** `OpenLibraryClient` searched via `search.json?title=...` (a field-scoped exact match against the work's own canonical title), which misses regional title variants entirely - -confirmed against the real API that "Harry Potter and the Sorcerer's Stone" (the US title) only matches a handful of near-empty, 1-edition work stubs this way, -because Open Library's actual canonical work for this book is titled "Harry Potter and the Philosopher's Stone" (the UK title) and carries 398 editions. -Switched to `search.json?q=...` (a general relevance-ranked query across title, alternate titles, etc.), -which correctly surfaces the well-populated canonical work first in this case while still returning the same top result as before for titles that don't have this regional-variant problem (e.g. "The Return of the King"). -This also explains why some resolved covers can look like a plain, uninteresting library rebinding rather than an illustrated dust jacket even once the *correct* work is matched (confirmed for "The Return of the King", `OL27455W`) - -Open Library's own `covers` array for a work is whatever has been scanned/contributed, not curated by "which looks best," and the first entry there is already identical to the search index's own `cover_i` in the cases checked; -there's no metadata signal (short of actual image content analysis, out of scope here) to pick a nicer-looking alternative from the same array. - -**Gotcha:** when `PosterUrl` was renamed to `ImageUrl` on `TvShowReferenceModel`/`MovieReferenceModel`, -existing `tvshow_reference`/`movie_reference` documents created before the rename kept their data under the old `poster_url` BSON field - the new entity class only ever reads `image_url`, -so every pre-existing reference document silently lost its cover image (confirmed against a real dev database: 72/87 TV show references and 343/353 movie references still had the old field name). -Fixed with a one-off migration, `scripts/migrate-poster-url-to-image-url.js` (idempotent `$rename`, safe to re-run) - run it once against any environment with reference data older than the rename. -This is the same class of risk as the earlier `music-album` → `album` collection rename (see "Reference data now covers five domains" above): -a data-shape rename in code needs an explicit, documented migration step for whatever already exists in Mongo, not just updated `[BsonElement]` attributes. - -`ReferenceMatchModel.Creator` (nullable `string`) extends the `MatchedAliases` match key beyond just (title, year) - -a title+year match alone risks silently linking a tenant's book/album to a *different* tenant's unrelated one that happens to share a common title and year -(a generic name re-published/re-released the same year is common; TV/movie titles almost never collide this hard, which is why they don't need this). -`IBookReferenceRepository`/`IAlbumReferenceRepository`'s `FindByTitleYearAsync`/`FindByTitleAsync` now take a required `author`/`artist` parameter and add a `Creator` equality condition to the same `ElemMatch` filter -(both title and creator normalized via `TitleNormalizer.Normalize`) - `Creator` is always derived from the **canonical** resolved `details.Author`/`details.Artist` (the external API's own response), -never from whatever text the tenant/admin originally typed, since that let the design avoid adding a new parameter to `ResolveBookAsync`/`ResolveAlbumAsync`, `LinkReferenceRequestDto`, or the admin linking UI. -TvShow/Movie/VideoGame's `MergeMatchedAliases` calls all pass `null` for `Creator` (no creator dimension in those domains' match key) - the shared helper's third tuple element is `null` for them, not omitted, -since `ReferenceMatchModel` stays one generic shape across every domain rather than growing a Book/Album-only subtype. -`BookReferenceRepository`/`AlbumReferenceRepository.UpsertAsync`'s defensive "always include the canonical title/year alias" safety net can't set `Creator` (the model only carries `AuthorReferenceId`/`ArtistReferenceId`, a dedup'd link, -not denormalized text) - that alias is simply unreachable via the creator-required find methods, which is harmless (the normal Resolve/Refresh path always adds a proper creator-bearing alias first) rather than a false-positive risk; -a real-MongoDB integration test (`BookReferenceRepositoryTest`/`AlbumReferenceRepositoryTest`) asserts on the stored alias directly for this specific case rather than through the creator-required lookup, -since that lookup can no longer find it by design. - -**Gotcha (historical - structurally fixed by the AutoMapper -> Mapperly migration):** `MergeMatchedAliases`' dedup check compares a freshly-computed `Creator` against an existing alias's `Creator` (`m.Creator == normalizedCreator`), -which used to silently duplicate aliases on every re-resolve/re-refresh for TV show/movie/video game (the three domains that always pass `null` for `Creator`, having no creator dimension). -The reason: `AllowNullDestinationValues = false` (a profile-wide AutoMapper default, since removed along with AutoMapper itself) substituted `""` for a null *string* member during model → entity mapping - -so a freshly-built alias with `Creator = null` got persisted as `Creator = ""`, and on the *next* resolve/refresh the freshly computed `normalizedCreator` (still literally `null`) never equalled that already-persisted `""`, -so the dedup check saw no existing match and appended an exact duplicate `{title, year, creator: ""}` entry. -Confirmed against a real video game reference (RAWG's "God of War", resolved/refreshed more than once) that had accumulated a literal duplicate alias this way. - -The fix belonged at the mapping/entity layer, not as a comparison workaround in `MergeMatchedAliases` (an `(m.Creator ?? "") == (normalizedCreator ?? "")` patch was tried first and reverted) - -at the time, `DataStorageMappingProfile`'s `ReferenceMatchModel` → `ReferenceMatch` map opted `Creator` out of the profile-wide default with `.ForMember(x => x.Creator, opt => opt.AllowNull())`, -so a null `Creator` reached Mongo as an actual null again, and `MergeMatchedAliases` stayed a plain, honest `m.Creator == normalizedCreator`. -That per-member opt-out is gone now, not just moved: Mapperly (the current mapper) preserves nulls by default, so every storage mapper's `Creator` mapping is a real null with no configuration needed at all - -this entire class of bug is structurally impossible today, not merely patched. -From there, the *already-registered*, codebase-wide `IgnoreIfNullConvention(true)` (`InfrastructureServiceCollectionExtensions.AddMongoDbInfrastructure`) does the rest for free - -it omits any null property from the stored document, which is why `Year` (also nullable on `ReferenceMatch`) was never affected by this bug in the first place and needed no equivalent per-property `[BsonIgnoreIfNull]` fix; -that attribute doesn't appear anywhere in this codebase, and shouldn't - "is this field omitted when unset" is a driver-convention-level answer here, not a per-entity one. -Was covered by `RefreshVideoGameReferenceAsync_DoesNotDuplicateAnAliasAlreadyPersistedWithANullCreator` (unit, mocked) and `TvShowReferenceRepositoryTest.UpsertAsync_PersistsANullCreator_AsAnActualBsonNullNotAnEmptyString` -(integration, real MongoDB - the only way to actually catch a serialization-level regression like this one); both still pass under Mapperly as a regression guard, even though the bug they were written for can no longer occur. -A scan of the real dev database found only the one "God of War" document actually duplicated, cleaned up with the idempotent `scripts/dedupe-matched-aliases.js` -(same "run once per environment" pattern as `migrate-poster-url-to-image-url.js`). - -`BookModel`/`AlbumModel.Genre` (a single free-text field, not a list - -it predates the reference-data feature, same as `Author`/`Artist` before `PersonReferenceModel` existed) is now propagated on link the same way `Title`/`Year`/`Author`/`Artist` already are: -`TryLinkExistingBookReferenceAsync`/`TryLinkExistingAlbumReferenceAsync` and `ResolveBookAsync`/`ResolveAlbumAsync` join the reference's `Genres` list -(`JoinGenres`, a shared helper in `ReferenceEnrichmentService.cs`) into that single field, -both on the tenant's own document and via `IBookRepository`/`IAlbumRepository.SetReferenceLinkAsync`'s new `canonicalenre` parameter -(cross-tenant propagation, same incremental-parameter pattern already used for `canonicalAuthor`/`canonicalArtist`) - -null (not overwritten) when the reference has no genres, same "don't overwrite with nothing" rule the other propagated fields already follow. -`BookDetail.razor`/`AlbumDetail.razor` previously displayed the *reference's* raw `Genres` list directly (a read-only comma-joined paragraph) but never touched the tenant's own `Genre` field at all - -that display was replaced with a plain editable `Genre` input (same shape as `Author`/`Series`/`Artist`), matching how `Author`/`Artist` already work: the reference data flows into the one tenant-owned field on link, -there's no separate "raw reference value" display once linked. - -Books are the one reference domain behind a provider-agnostic interface rather than a provider-named one: `IBookReferenceClient` (`BookSearchResult`/`BookDetails` DTOs, -an `IBookReferenceClient.ProviderKey` string, plus a `DisplayName` for admin UI text) instead of `IOpenLibraryClient`. -TV show/movie/video game/album stay hard-wired to TMDB/RAWG/Discogs directly (their DTOs and hardcoded `"tmdb"`/`"rawg"`/`"discogs"` `ExternalIds` keys are provider-named on purpose - -swapping any of those would be a bigger redesign, not a config change). - -**Book is also the one reference domain with more than one provider registered at once**: `GoogleBooksClient`/`ProviderKey` `"googlebooks"` -(the default - real synopses, cover art, language and by far the widest catalogue coverage of the three, including manga/comics), -`OpenLibraryClient`/`"openlibrary"` (free/keyless, kept as a fallback), and `BnfClient`/`"bnf"` (BnF's SRU Catalogue général, free/keyless, also kept as a fallback). -Google Books became the default after both Open Library and BnF were found lacking in practice for this app's purposes: -BnF in particular returns long library-cataloguing-style titles, no cover art at all, and little to no real synopsis, and doesn't meaningfully cover manga/comics - -it can still occasionally surface a French title the other two lack, which is why it's kept registered rather than removed, just never the default. -`Program.cs` registers every implemented book provider unconditionally (typed `AddHttpClient` bridged to the shared interface via `AddTransient(sp => sp.GetRequiredService())` - `AddTransient`, -not `AddSingleton`, so `IHttpClientFactory`'s handler rotation isn't defeated by a long-lived captured client), rather than the old switch that picked exactly one. -Registration order in `Program.cs` doubles as the admin UI's provider-picker display order (`BookReferenceClientRegistry.All` preserves it) - Google Books is registered first for exactly that reason. -`BookReferenceClientRegistry` (`WebApi/ReferenceData/`) is the one place that resolves a provider key (or falls back to `ReferenceData:BookProvider`'s deployment default, -matched case-insensitively so an old PascalCase `"OpenLibrary"` setting still resolves against the new lowercase `ProviderKey` convention) to a concrete client - -`ReferenceEnrichmentService`/`ReferenceDataAdminController` both depend on this registry instead of a single injected `IBookReferenceClient`. -An admin picks the provider per search/link action (`GET /api/reference-data/book-providers` lists what's registered; `LinkReferenceRequestDto.Provider`/the `search` endpoint's `provider` query param carry the choice through) - -this is deliberately a per-request admin choice, not just the old deployment-wide config switch, so every provider stays usable side by side. -The admin UI's provider buttons are selection-only (`_selectedProvider = key`, no implicit re-search) - they used to also immediately re-run the search when one was already displayed, -which duplicated the explicit "Search"/"↻ Search again" button's job and confused which action did what; now there is exactly one way to trigger a search. -`RefreshBookReferenceAsync` checks every *registered* provider's key against `BookReferenceModel.ExternalIds`, not just the configured default's - it used to only check the default's key, -so a reference linked through any other provider would have silently stopped refreshing forever once this became possible. -BnF's ordinary catalogue records carry no cover-art field at all (only a digitized Gallica item would, via a separate API `BnfClient` doesn't call), unlike Google Books/Open Library/RAWG/Discogs - -a book linked via BnF simply has no cover, which is expected, not a bug. -`BnfClient` parses SRU/XML (Dublin Core embedded in each `srw:record`), the one non-JSON provider client in the codebase; its `ExternalId` is the record's bare ARK (from `srw:recordIdentifier`, -re-queried via the `bib.persistentid` CQL criterion for `GetBookDetailsAsync`), -and its `dc:creator` values ("LastName, FirstName (dates). Role") are cleaned up into a plain "FirstName LastName" shape to match every other provider's author format. -**Gotcha, confirmed against the real API:** BnF's own `"and (bib.author ...)"` CQL combination is not a strict intersection - -querying title "La Peste" and author "Victor Hugo" (who never wrote that book) returned several genuine Victor Hugo anthologies instead of zero, none of them actually titled "La Peste" -(title "La Peste" + the correct author "Albert Camus" does correctly narrow to 69 genuine matches, so the server-side clause isn't useless, just not trustworthy on its own). -`BnfClient.SearchBooksCoreAsync` therefore re-checks every candidate's parsed author client-side (`AuthorMatches`, a normalized word-presence check) and discards any that don't actually match, rather than trusting BnF's own filtering - -without this, mismatched candidates silently leaked through, which read to a user as "the author isn't considered" even though it nominally was, server-side. -`GoogleBooksClient` (JSON, like every provider except BnF) uses `intitle:`/`inauthor:` query qualifiers; `volumeInfo.description` is documented as HTML-formatted ("b", "i", "br" tags). -`CleanDescription` keeps that formatting rather than flattening it to plain text (an admin found the flattened version disappointing) - -it decodes HTML entities first, then replaces every ``/``/``/``/`
` tag with a bare, attribute-free reconstruction of itself and removes every other tag entirely, -discarding any attributes even on the three allowed ones. -This fixed allowlist-and-reconstruct approach (not a general sanitizer) is what makes it safe for `BookDetail.razor` to render `Reference.Synopsis` as `MarkupString` (Blazor's raw-HTML escape hatch, otherwise never used in this app) -instead of the plain-text interpolation every other synopsis display still uses - nothing but those three bare tags can ever survive the filter, so there's no attribute-based injection vector (a stray `onclick`, say) to worry about, -and entities are decoded *before* stripping specifically so an entity-encoded tag can't slip through the filter and only turn into a live tag afterward. -Never render a `MarkupString` from text that hasn't gone through this same filter. -**Gotcha, confirmed against a real description ("The Hobbit"):** paragraph breaks aren't always literal `
` tags - some descriptions use plain `\n`/`\r\n` characters instead, which HTML silently collapses to whitespace, -so a description with only bold/italic markup and no actual `
` tags rendered as one massive undivided paragraph even though the bold/italic themselves displayed correctly. -`CleanDescription` converts real newline characters to `
` before the tag-allowlist pass runs (not as a separate step after), -so a newline-based break goes through the exact same reconstruction as any other `
` rather than needing a second, parallel code path. -It also upgrades `volumeInfo.imageLinks.thumbnail` from `http://` to `https://` (a widely-documented characteristic of Google's own API responses) so the cover doesn't trip mixed-content blocking on this app's HTTPS pages. -`ReferenceEnrichmentService.Books.cs` never hardcodes a provider name; every `ExternalIds`/person-reference lookup keys off the resolved client's `ProviderKey` instead, -so a further implementation only needs its own class (`GoogleBooksClient`/`OpenLibraryClient`/`BnfClient`-shaped: base address, optional settings/API-key class, `ProviderKey`, `DisplayName`) plus one registration block in `Program.cs` - -no changes to the enrichment service, admin controller, registry, or the admin UI's provider picker (it lists whatever's registered). - -`BookModel.Language` (free text, same shape as `Genre`) is auto-filled on link/refresh from providers that report one - Google Books' `volumeInfo.language` (a clean ISO 639-1 code) and BnF's Dublin Core `dc:language` -(a MARC-style code, e.g. "fre" - shown as-is, not translated to a friendly name) both populate `BookDetails.Language`; -Open Library's client doesn't populate it (the value lives at the edition level, not the work level the current client fetches), left as a possible future enhancement rather than guessed at. -The reference-level `BookReferenceModel.Language`/`BookReferenceDto.Language` and `IBookRepository.SetReferenceLinkAsync`'s `canonicalLanguage` parameter follow the exact same propagation shape `Genre`/`canonicalGenre` already established: -null (not overwritten) when the provider has none. - -`BookModel.Isbn` follows the same shape again, with two differences from Genre/Language. -First, it's edited on `BookDetail.razor` only, never the Add form -(`Books.razor`'s add card only carries title/author/year, per this document's own "Adding a new trackable item" convention). Second, it doubles as an optional *search input*: -`IBookReferenceClient.SearchBooksAsync` takes an `isbn` parameter, but only `GoogleBooksClient` actually uses it (as the sole query, `isbn:{isbn}`, superseding title/author entirely - -an ISBN is an exact identifier, so combining it with a fuzzy title/author match would only reintroduce the kind of "and" narrowing risk `BnfClient`'s own author fix (above) had to work around). -Open Library/BnF accept the parameter (interface compliance) but ignore it as a search input; both `GoogleBooksClient` (via `volumeInfo.industryIdentifiers`, preferring ISBN_13 over ISBN_10 when a volume reports both) and `BnfClient` -(via a `dc:identifier` value prefixed "ISBN ", confirmed against the real API) still populate `BookDetails.Isbn` for autofill on link/refresh - reporting one already-known and searching by one are different things. -The admin search UI (`ReferenceDataAdminPage.razor`'s ISBN field, and `InlineReferenceLinker`'s `Isbn` parameter bound from `BookDetail.razor`'s own field) is Book-only, same convention as the Author/Artist `creator` field - -there was no push to generalize the parameter name here the way `creator` was, since only one domain needs it so far. - -**`ReferenceMatchModel`/`ReferenceMatch` gained an `Isbn` field (null for every domain but Book) specifically so a matched alias only ever records the identifier that actually drove that particular match** - -the canonical alias (the provider's own reported ISBN, from `BookDetails.Isbn`) and the tenant-search alias (whatever ISBN, if any, was actually supplied as search input) are two separate entries, never merged, -and the search alias's `Isbn` is never backfilled from the provider's own value when no ISBN was actually used to find the match. -`MergeMatchedAliases`' shared tuple shape grew a 4th element for this (`(Title, Year, Creator, Isbn)`); -every non-Book call site across `.TvShowsAndMovies.cs`/`.VideoGames.cs`/`.Albums.cs` passes a literal `null` for it, same as `Creator` already does for the domains with no creator dimension. - -### Blazor app - -`InventoryPageBase` (`BlazorApp/Components/Inventory/InventoryPageBase.cs`) centralizes list/paging/search/inline-edit state and calls into `InventoryApiClientBase`, -which wraps the typed `HttpClient` calls to the Web API (its `GetAsync` takes an optional extra-query-parameters dictionary, used by features that filter on more than search/page/pageSize). -Each concrete page (`Books.razor.cs`, `Movies.razor.cs`, ...) only supplies its `Api` instance and `CloneItem`. -A page that needs its own filter beyond search (e.g. `TvShows.razor.cs`'s state filter) overrides the base's `protected virtual ExtraQuery` property instead of reimplementing paging/search. - -List state (search, page, and each page's own filters) lives in the list URL's query string (`?search=&page=` plus lowercase per-filter parameters), read back via `[SupplyParameterFromQuery]`. -Search/filter/pagination clicks never call `LoadAsync` themselves - they navigate via the base's `ApplyQueryChanges`/`ToggleFilter`/`SetFilter` helpers, -and the reload happens once in `InventoryPageBase.OnParametersSetAsync` when the router supplies the new values. -This is what makes browser back from an item's detail page restore the exact list position (the original complaint: paging deep into movies, opening one, and coming back used to reset to an unfiltered page 1), -and it means a button click and browser back/forward share one code path instead of two. -A new list filter therefore needs three things: a `[SupplyParameterFromQuery]` property, an `ExtraQuery` entry (the API-facing key, e.g. `IsFavorite`), -and a razor button calling `ToggleFilter`/`SetFilter` with the URL parameter name (e.g. `favorite`) - don't add a mutate-a-field-then-`LoadAsync` handler, that's the pre-URL-state pattern this replaced. - -List ordering is deterministic everywhere: `MongoDbRepositoryBase.FindAllAsync` sorts every page read, defaulting to newest first via `_id` descending -(ObjectIds embed their creation timestamp, so no separate created-at field exists or is needed), with `_id` also appended as the tie-break under every other key - -an unsorted skip/limit page could duplicate or drop items across pages, so "no sort" was a paging-correctness bug, not just arbitrary-feeling UX. -`PagedRequest.Sort` carries an optional `ListSort` key (`Common.System/ListSort.cs`: `title`, `rating`) end-to-end: -`InventoryList`'s sort picker navigates a `?sort=` query parameter through the same URL-state path as search/filters ("" = the newest-first default, kept out of the URL), -`DataCrudControllerBase.Get` passes it through, and a repository opts into a key by overriding `SortTitleField`/`SortRatingField` -(an expression, never an element-name string, so the BSON mapping stays with the entity class - `Car.Name` stores as `commercial_name`, which a string-based sort would silently miss). -An unknown or unsupported key falls back to newest-first rather than erroring; `HasRatingSort` on `InventoryList` shows the Rating option only for the five media types that have a rating field. -The title sort attaches a per-query `Collation` ("en", strength 2) for case/diacritic-insensitive ordering with no normalized shadow field and no index changes - -per-owner subsets are small enough that MongoDB's in-memory sort of an owner-filtered read is negligible, so no sort indexes were added. -**Gotcha:** MongoDB rejects a collation combined with a `$text` filter; this is safe today only because every repository's `GetFilter` searches via regex `Contains` -(the base class's `builder.Text` default is effectively dead) - a future `$text`-searching repository must not also offer the title sort without gating the collation. -Covered by `ListSortingRepositoryTest` (integration, real MongoDB - the collation's ordering and descending-sort null placement are server-side semantics a mocked repository can never prove). -`InventoryList`'s search box keeps a deliberate local copy of the text (so a parent re-render racing fast typing can't revert characters) -and adopts an externally-changed `Search` parameter only when it didn't originate from its own `OnSearchChanged` report - see the sent/received tracking in its `OnParametersSet` before touching that logic. -Covered end-to-end by `ListStateSmokeTest` (Playwright), including the back-navigation-from-detail scenario. -Authentication uses Firebase (cookie auth in the Blazor app, JWT bearer validated against Firebase in the Web API); `AuthenticationTokenHandler` attaches the bearer token to outgoing API calls. - -**Scaling (multiple replicas) is an app-level design here, deliberately not an infrastructure assumption** - the app may be front with a Cloudflare tunnel, where no ingress cookie-affinity exists, so nothing may rely on sticky sessions. -Two pieces make the Blazor app replica-safe: -`DataProtection:MongoDb:ConnectionString`/`DatabaseName` (opt-in, `Program.cs`) persists the Data Protection key ring via `DataProtection/MongoDbXmlRepository` so the auth cookie and antiforgery tokens decrypt on every replica - -without it each pod keeps ephemeral keys and multi-replica cookie auth breaks; this is the only reason `BlazorApp.csproj` references `MongoDB.Driver` -(it still never references `Domain`/`Infrastructure.MongoDb` - tenant data stays behind WebApi). -`Features:IsWebSocketsOnlyEnabled` (default `true`, `App.razor`) starts the circuit via `Blazor.start` with `skipNegotiation` + WebSockets-only transport, -so a circuit's single long-lived connection naturally pins it to the pod owning its state - set it to `false` only behind a proxy that can't pass WebSockets, and stay single-replica there. -A pod dying still drops its circuits (inherent to Blazor Server - clients reconnect-then-reload); the WebApi side's replica-safety (Mongo job store, sync lease) is covered in its own sections above. - -Pages that aren't a generic CRUD list (`TvShowDetail.razor`, `WatchNext/WatchNextPage.razor`, `Import/ImportPage.razor`) don't extend `InventoryPageBase`/`InventoryList` — -they're free to build their own layout on top of the shared `kt-*` CSS classes in `app.css`. -Their API clients live next to them in a feature folder rather than in `Inventory/Clients/`. - -**Gotcha:** passing a field to a `string`-typed component `[Parameter]` needs the `@` prefix - `Title="_movie.Title"` binds the **literal text** `"_movie.Title"`, not the property's value. -Razor only auto-detects that an attribute value must be C# code when the parameter's type couldn't otherwise accept a string literal -(e.g. `Year="_movie.Year"` on an `int?` parameter works unprefixed, because a bare identifier can't type-check as one); a `string` parameter can always accept a literal, so Razor takes it at face value instead. -This compiles and renders with no error - the bug only shows up in the *data*, not the markup - -which is exactly what happened when `InlineReferenceLinker`'s `Title="_movie.Title"` sent the literal string `_movie.Title` to TMDB's search instead of the movie's actual title, returning an unrelated result. -Always write `Title="@_movie.Title"` for string parameters bound to a field/property. +Both pages share one `WishlistRow` projection. + +**Long-running work** runs as a background job, never a blocking request: buffer the input, start the work on a fresh `IServiceScopeFactory.CreateScope()` (the request scope is gone by then), return a job id, poll status. +`JobStore` (`WebApi/Jobs/`) is backed by MongoDB (`background_job`, TTL 7 days), **not** memory - with several replicas, the replica answering a poll isn't the one running the job. +Owner id is checked in the repository query on every read. +A background task must resolve its own `JobStore` from its own scope. + +### Auth, tiers and admin settings + +- Firebase auth: cookie in `BlazorApp`, JWT bearer validated against Firebase in `WebApi`; `AuthenticationTokenHandler` attaches the bearer to outgoing calls. +- Authorization is **policy**-based, not `Roles=`: `AdminOnly` = `RequireClaim("role", "admin")`, `MemberOnly` = `RequireClaim("role", "member", "admin")`, registered in both `Program.cs` files. + Firebase sends a plain `role` claim, not the `ClaimTypes.Role` URI. + `BlazorApp`'s `AuthenticationController` copies it into the cookie principal at sign-in. Granting the first admin is a one-off `setCustomUserClaims` via the Firebase Admin SDK (see `CONTRIBUTING.md`). +- **Gotcha:** `AddJwtBearer` sets `MapInboundClaims = false` deliberately - otherwise the handler renames short JWT claim names to legacy `ClaimTypes.*` URIs and `RequireClaim("role", ...)` never matches, even though the token genuinely + carries the claim. + This is what once let the Blazor side show the admin nav link while the same user's API call 403'd. Leave it alone; don't assume a new custom claim is unaffected without checking. +- **Free preview tier:** anyone can sign in; an account with no `role` claim gets movies and TV shows only, capped at `Features:FreeTierItemLimit` per collection (default 20, guarded in `AppConfiguration.GetFreeTierItemLimit`), episodes at + 100x that (`EpisodeController.FreeTierLimitFactor` - generous on purpose, only to stop a raw-API caller flooding the database). + Enforcement is API-side and two-layered: `[Authorize(Policy = "MemberOnly")]` on every restricted controller, plus the creation quota in `DataCrudControllerBase.Post` (403 with `{ error }`). + `NavMenu.razor` hiding sections is UX, never security. + `FreeTierTest` covers the quota and carries a reflection guard asserting each controller's expected policy - removing one is a failing test, not a silent giveaway. +- **Runtime-changeable global admin settings** live in one shared `app_setting` collection (single `_id: "global"` document, one field per setting) via `IAppSettingRepository`, which writes a targeted `$set` so unrelated settings are never + clobbered. + Reach for a new field here, not a new collection; use `AppConfiguration`/env vars only for deploy-time values. + +### Reference data (shared, owner-less) + +`tvshow_reference`, `movie_reference`, `book_reference`, `videogame_reference`, `album_reference` and `person_reference` hold provider metadata. +They are the one deliberate exception to "every collection has `owner_id`": public facts about a real work, stored once, pointed at by every tenant's `ReferenceId`. +Matching key is normalized title + year via `TitleNormalizer.Normalize` (shared with `TvTimeImportService` so the two never drift). + +Providers: TMDB (TV/movie), RAWG (video games), Discogs (albums), Google Books / Open Library / BnF (books), OMDb (IMDb ratings for TV/movie). + +- These repositories do **not** extend `IDataRepository`/`MongoDbRepositoryBase` (both are hard-constrained to `IHasIdAndOwnerId` + owner-scoped paged CRUD). + Write a small purpose-built repository for any new owner-less collection. +- `ReferenceEnrichmentService` is one `partial class` split by file (`.TvShowsAndMovies.cs`/`.Books.cs`/`.VideoGames.cs`/`.Albums.cs`), five methods per domain: + `TryLinkExistingReferenceAsync`/`TryAutoResolveAsync`/`ResolveAsync`/`RefreshReferenceAsync`. + Shared helpers (`MergeMatchedAliases`, `ResolvePersonReferenceIdAsync`, `JoinGenres`) stay in the core file. +- It is the single place a title+year resolves to a provider id, and it propagates the result to every tenant's matching document via `IRepository.SetReferenceLinkAsync`. + Automatic resolution fires from `Controller.OnCreatedAsync` and from `TvTimeImportService`, both on their own DI scope (never awaited inline - a bulk import must not block on a sequential chain of provider calls). + The automatic path only acts on a **single, confident** search result; zero or several candidates leaves the item for the admin queue rather than guessing. +- `SetReferenceLinkAsync` also sets `Title`, `Year` and (per domain) `Author`/`Artist`/`Genre`/`Language` from the canonical record - linking corrects what the tenant typed, it doesn't just attach an id. + Never overwrite with nothing: a field the provider has no value for is left alone. + `VideoGameModel.Platform`/`State` describe this tenant's own copy and are never overwritten. +- `MatchedAliases` (`List`, tuple `(Title, Year, Creator, Isbn)`) records every combination ever confirmed to mean this work - both the canonical values and whatever the tenant searched with - merged, never overwritten. + `UpsertAsync` guarantees the document's own title/year is present. + Queries use `Builders.Filter.ElemMatch` so title and year must match on the *same* array element (an `AnyEq`-per-field approach would match a title on one alias and a year on another). + Indexes are compound multikey over `matched_aliases.title`/`.year`. + - `Creator` (Book/Album only) exists because a title+year collision is realistic there; it is always derived from the canonical provider response, never from tenant-typed text. + TV/movie/game pass `null`. + - `Isbn` (Book only) is recorded only on the alias that actually used it: the canonical alias (provider-reported) and the tenant-search alias are separate entries, and the search alias's `Isbn` is never backfilled. +- `ResolveAsync` checks for an existing reference document **by provider id first** (`FindByExternalIdAsync`), falling back to title+year/title-only. + Title text alone can't prevent duplicates - two tenants (or an admin searching twice under different text) easily resolve the same entry through different strings. + The provider id is invariant and authoritative. +- `*_tmdb_id`/external-id indexes are `unique: true` with `partialFilterExpression: { "external_ids.": { $exists: true } }`. + The application check is what's *supposed* to prevent duplicates; the database constraint is what guarantees it. + The partial filter (not `sparse`, not a plain unique index) is required so documents missing the key don't all collide on one null. +- `TryLinkExistingReferenceAsync` is a second, cheaper path that never calls a provider: it only checks whether a matching document already exists. + It backs `POST /api//{id}/refresh-reference` - the "check for reference match" control shown **unconditionally** on every detail page to **any** authenticated user (it can only reuse a fact someone already established). + It deliberately does **not** short-circuit on an existing `ReferenceId`: `Title`/`Year` are freely editable, and replacing a bad match (two real movies sharing a title is common) is the point. + On a match it updates this tenant's own document directly, then also calls `SetReferenceLinkAsync` with the pre-edit title/year so other unresolved tenants benefit. + On **no** match for an item that *was* linked, the link is cleared (`ReferenceId = ""`), which is exactly what returns it to the admin's unresolved queue. + - **Gotcha:** the title-only fallback must run unconditionally, *including* when `Year` is null. + An earlier version skipped it unless `Year is not null`, which is backwards - `FindByTitleYearAsync(title, null)` can only match a reference whose own year is also null, so any linked item with no year unlinked itself the instant the + button was clicked. + Confirmed by a real user on a valid, already-linked title. +- **Person dedup:** `person_reference` covers actors, book authors and album artists alike ("a named individual or group identified by a provider id"), deduplicated by provider person id via `ResolvePersonReferenceIdAsync`, never by name. + References store only the id; `ReferenceDataController` hydrates names/`Cast`/`ProfileImageUrl` by joining server-side, which is why those DTO members are `[MapperIgnoreTarget]` and not plain mapped members. +- Images are **hotlinked from the provider CDN** (e.g. `https://image.tmdb.org/t/p/{size}{path}`, built once in the client and stored as a plain URL). + This is TMDB's sanctioned pattern, so there is no local storage/static-file subsystem to operate. +- **Export/import:** `GET/POST /api/reference-data/export`/`import` round-trip whole reference collections as a zip of JSON arrays, so reference data is portable across environments instead of re-earned per deployment. + Idempotency is free - `UpsertAsync` replaces by id. + `FindAllAsync()` exists solely to back the export (unpaged, acceptable because this data is small and shared). +- **Admin queue:** `ReferenceDataAdminController` (`AdminOnly`) handles manual search/link over a 5-way `ReferenceItemType`, using `ExternalId`/`Provider` (not TMDB-specific names) in its DTOs. + +**Gotcha (`null` string filters, still relevant for old data):** "does this document have no reference link yet" cannot be `Eq(x => x.ReferenceId, null)`. +Documents written under the old AutoMapper stored `""` instead of BSON null and still exist. +Copy `TvShowRepository`/`MovieRepository`'s `UnresolvedFilter()` shape (null *or* empty) for any "is this string field unset" query. It fails silently - it just matches zero documents. Only a real-MongoDB integration test catches this class +of bug. + +**Gotcha (null Find results):** every `Find*Async` that can legitimately return "nothing matched" must check `entity is null` **before** calling the mapper - Mapperly throws on a null source. +This applies to `MongoDbRepositoryBase.FindOneAsync` too, which every `GetById` 404 check depends on. +A mocked-repository unit test can never catch a regression here. + +**Data-shape renames need a migration script**, not just updated `[BsonElement]` attributes. +`PosterUrl` -> `ImageUrl` silently blanked every pre-existing cover (72/87 TV, 343/353 movie references) until `scripts/migrate-poster-url-to-image-url.js` (idempotent `$rename`). +By contrast `TvShowModel.Status` -> `State` needed none, because the entity kept `[BsonElement("status")]`. +Run-once scripts follow that same idempotent style: `dedupe-matched-aliases.js`, `unset-tvshow-want-to-watch.js`, `migrate-is-owned-to-owned-versions.js`. + +#### Per-provider findings (all confirmed against the real APIs) + +- **Open Library** never sends `year` as a server-side filter: `first_publish_year` is the *work's* original year, not a tenant's edition, so filtering by it returns zero relevant results ("Killing Floor" + 2016 reprint). + Year is still returned for display/tie-breaking. + RAWG/Discogs keep their year filters. +- **Open Library** searches via `q=` (relevance across titles/alternates), not `title=` (field-scoped exact match), which misses regional variants entirely - + the US "Harry Potter and the Sorcerer's Stone" only matched near-empty stubs while the canonical UK-titled work carries 398 editions. + Its `first_publish_date` is routinely absent from the work JSON, so `GetBookDetailsAsync` falls back to a single-document `q=key:{workKey}` re-query. + Its `covers` array is contributed, not curated, so an unattractive cover is expected, not a bug. + It exposes **no** reliable series field (the `person`/`subject_people` facet is a character name and doesn't generalize) - `BookModel.Series` is deliberately not auto-filled. +- **An optional narrowing parameter must never silently zero out results a broader search would find.** Discogs' `artist=` can fail to match its own indexing (disambiguation suffixes like `"Artist (2)"`, different formatting), returning + nothing for a title that succeeds alone ("Born Pink"). + Both `DiscogsClient` and `OpenLibraryClient` retry once without the author/artist parameter when the constrained search comes back empty, rather than reporting a false "not found". +- **BnF**'s `"and (bib.author ...)"` CQL clause is not a strict intersection - title "La Peste" + author "Victor Hugo" returned genuine Hugo anthologies instead of zero. `BnfClient.SearchBooksCoreAsync` re-checks every candidate's parsed + author client-side (`AuthorMatches`) and discards mismatches. + BnF is the one XML/SRU client (Dublin Core per `srw:record`), its `ExternalId` is the bare ARK, its `dc:creator` "LastName, FirstName (dates). Role" is normalized to "FirstName + LastName", and its ordinary records carry **no** cover art at all (expected, not a bug). +- **Google Books** is the book default (real synopses, covers, language, widest catalogue including manga). + Uses `intitle:`/`inauthor:`; an `isbn` supersedes title/author entirely as the sole query (an exact identifier must not be "and"-ed with a fuzzy match). + `CleanDescription` keeps the documented `b`/`i`/`br` HTML rather than flattening it: it decodes entities **first** (so an entity-encoded tag can't slip through and re-materialize), converts real `\n`/`\r\n` to `
` **before** the + allowlist pass (some descriptions break paragraphs with newlines only - "The Hobbit" rendered as one block), then reconstructs those three tags bare and strips everything else including attributes. + That fixed allowlist-and-reconstruct (not a general sanitizer) is what makes `BookDetail.razor`'s `MarkupString` render safe - **never render a `MarkupString` from text that hasn't been through it.** It also upgrades + `imageLinks.thumbnail` to `https://` to avoid mixed-content blocking. +- **Books are the one domain behind a provider-agnostic interface** (`IBookReferenceClient` with `ProviderKey`/`DisplayName`) and the one with several providers registered at once. + `Program.cs` registers all of them (typed `AddHttpClient` bridged via `AddTransient` - + `AddTransient`, so `IHttpClientFactory`'s handler rotation isn't defeated); registration order is the admin picker's display order. `BookReferenceClientRegistry` resolves a key (or the `ReferenceData:BookProvider` default, matched + case-insensitively) to a client. + The provider is a per-request admin choice, not a deploy-wide switch. + `RefreshBookReferenceAsync` checks **every registered** provider's key against `ExternalIds`, not just the default's - + otherwise a reference linked through another provider silently stops refreshing forever. + `ReferenceEnrichmentService.Books.cs` never hardcodes a provider name, so a new provider needs only its own class plus one registration block. + Admin provider buttons are selection-only; search is triggered by exactly one control. +- TV/movie/game/album stay hard-wired to TMDB/RAWG/Discogs (provider-named DTOs and `ExternalIds` keys on purpose - swapping one would be a redesign, not config). +- **Ratings:** `RatingSourceCatalog` declares each domain's selectable sources and code default (games RAWG vs Metacritic, movies/TV TMDB vs IMDb; defaults `rawg`/`tmdb`); + `ReferenceEnrichmentService.GetPrimaryRatingSourceAsync` reads the stored override. + The admin card, `rating-sources` GET/PUT and `.../recompute` (bulk `SetReferenceRatingAsync`, no provider calls) are all domain-generic over `RatingSourceCatalog.SelectableDomains`, so a domain gaining a second source needs only a catalog + entry. + IMDb ratings come from **OMDb** keyed by the IMDb id TMDB exposes (IMDb has no public ratings API); it's native on `/movie/{id}`, appended via `?append_to_response=external_ids` for TV. + OMDb is optional/best-effort: `OmdbSettings.ApiKey` is nullable and a missing section coalesces to empty, so a deployment without a key just keeps TMDB ratings. + Full design in `docs/reference-ratings-plan.md`. + - **Gotcha:** the `/changes` short-circuit (`LastEnrichedAt is not null && Ratings.Count > 0`) means a reference enriched before IMDb existed would never backfill one - + it has a tmdb rating so it short-circuits, but no stored imdb id because only a full fetch writes that. + `BackfillImdbRatingAsync` resolves the id cheaply on the no-change path via `/{tv,movie}/{id}/external_ids` (one call, no season fan-out), stores it, then does one OMDb call. + Self-correcting, no persisted "attempted" marker needed. + - The admin picker is a `form-select` dropdown, not a button row - buttons render at different widths by text length. + +### Keeping reference data fresh: periodic + on-demand sync + +`ReferenceSyncBackgroundService` is a plain in-process `BackgroundService` on a 24h `PeriodicTimer` (with an immediate pass at startup), deliberately **not** a Kubernetes CronJob - +a second scheduled workload is real operational overhead for a job this cheap. +Every replica runs the loop but only one syncs per cycle: each tick tries `ILeaseRepository.TryAcquireAsync("reference-sync", Environment.MachineName, 1h)`, an atomic filtered upsert whose mutual exclusion is the `lease` collection's `_id` +uniqueness (covered by the real-Mongo `LeaseRepositoryTest`). +A replica dying while holding the lease delays the next pass by at most 1h against a 24h cadence. + +`ReferenceSyncService.SyncStaleReferencesAsync(staleAfter, ...)` is the single sync algorithm, shared by the loop and the admin's `POST /api/reference-data/sync-now` (3 days for the periodic pass, `TimeSpan.Zero` for the forced one). +One failing document never aborts the run - each is caught and logged individually. + +`RefreshTvShowReferenceAsync`/`RefreshMovieReferenceAsync` lead with a cheap pre-check: TMDB's per-id `/changes?start_date=...` (one call, no season fan-out). +If nothing changed, only `LastEnrichedAt` is bumped and the full details + per-season cast calls are skipped. +A reference with no `LastEnrichedAt` always does the full fetch. +**Divergence:** RAWG/Discogs/book providers expose no `/changes` equivalent, so those domains always full-fetch once past the staleness cutoff, and their `*Updated` counts always equal their `*Checked` counts. + +**Gotcha:** the service is registered unconditionally but only works when `Features:IsReferenceSyncEnabled` (default `true`), checked fresh every tick. +`KestrelWebAppFactory` overrides it to `false` via `ConfigureAppConfiguration` (in-memory source added last, so it wins). +`UseSetting` was tried and silently doesn't work for a top-level-statement minimal-hosting `Program.cs`. +Without the override, every integration fixture fired real TMDB calls. +**Use `KestrelWebAppFactory` for any new integration fixture**, even one that doesn't need real Kestrel networking, so it inherits this for free (a bare `WebApplicationFactory` bypasses it and already bit +`AuxiliaryResourceTest`). + +### TV Time import + +`POST /api/import/tv-time` (background job, see "Long-running work"). +Findings, all confirmed against real export data: + +- `seen_episode_source.csv` alone is a drastically incomplete episode history (only written from TV Time's episode-detail screen). + `TvTimeImportService` also reads `tracking-prod-records.csv` and `-v2.csv`, merged and deduplicated per (show, season, episode), earliest date wins. +- `followed_tv_show.csv` is not a complete show list either (confirmed with "The Pitt"). + `ImportEpisodesAsync` creates shows on the fly from watch events; don't reintroduce a "skip if not already followed" check. +- Movies **do** have watch dates: `tracking-prod-records.csv` carries `entity_type == "movie"` rows with `type` watch (`FirstSeenAt`), towatch (`WantToWatch`, only when there's no watch event) and follow. + `-v2.csv` carries no movie data. + If a field looks suspiciously absent, re-check the real export before documenting it as a limitation - twice now an "unrecoverable" gap was just unparsed. +- **Idempotency is by stable id, never by title.** Enrichment rewrites `Title` to the canonical name after the first import, so title matching duplicated everything on re-import. + Every imported show/movie is stamped with `TvTimeId` (`IHasTvTimeId`, carried through entity/DTO and round-tripped on edits since `UpdateAsync` is a full replace): TV Time's show id, or the per-movie tracking `uuid`. + When the export carries no id, `ResolveTvTimeId` synthesizes a deterministic `tvtime_title:` from the **export** title (which enrichment never touches), and `BuildIdByTitle` maps titles to ids across the id-bearing files + first so title-only files resolve to the same id. +- `UpsertIndex` matches by `TvTimeId` first; a title fallback fires only for a pre-existing record with no id yet, which is adopted and back-filled once (`BackfillTvTimeIdAsync`). + A record carrying a *different* id is left alone. +- **On a match the record is left untouched** - a re-import must never clobber edits made in the app afterwards (rating, notes, favorite, corrected title/year). + Don't reintroduce an update-on-existing path. + Only new items are created; counts are deduped by reference identity. +- **Gotcha:** a CSV property present in only some of the three files' headers (e.g. `TvShowId`) needs `[Optional]` from `CsvHelper.Configuration.Attributes` on top of not being C# `required` - CsvHelper's header validation throws regardless + of nullability. + Only a realistic fixture catches this. + +### Watch Next + +`WatchNextService.ComputeInProgressShows(shows, episodes, referencesByShowId)` reports a show only if its `State` is `TvShowStatus.Current` **and** the linked reference's episode list has an entry after the last one watched, compared by +`(SeasonNumber, EpisodeNumber)` - never by title or air-date order - whose `AirDate` has already passed or is unset. +A show with no `ReferenceId` or no reference document is excluded rather than guessed at. +The controller only fetches reference documents for shows that are `Current` and linked. +The DTO reports the confirmed next episode (`InProgressShowDto.Next*`); this is real episode-guide data, not the old "+1" heuristic that shipped confirmed-wrong results. + +`FilterMoviesToWatch` excludes a movie once `FirstSeenAt` is set even if `WantToWatch` is still true - the flag can go stale, so the exclusion happens at read time. + +**`WantToWatch` is movie-only; TV shows deliberately don't have it.** The flag once existed on `TvShowModel` with no consuming feature and was removed everywhere (plus `scripts/unset-tvshow-want-to-watch.js`). +If a "shows I want to start" surface is ever wanted, build a real Watch Next section, not a dead flag. + +`TvShowDetail.razor`'s episode checklist applies the same `AirDate is null || AirDate <= today` filter before grouping into seasons, so an announced-but-unaired season simply doesn't appear. It's a full watch-through checklist once the show +has a `ReferenceId` (checking a box creates an `Episode` with `WatchedAt = today`, unchecking deletes it), falling back to the recorded-episodes-only view with a manual add form when it doesn't - +a deliberate scope boundary, since episode counts are unknowable without reference data. + +### Explore (discovery) + +`ExploreController`/`ExploreService` (`/explore`) suggests top-rated titles the caller doesn't track, with one-click add and dismiss/undo. +Movie, TvShow, VideoGame only; Book/Album 400 (no best-of listing to read). + +- **The discovery list always comes from the provider, never from local `*_reference` collections.** That was the original implementation's core mistake: + a reference document only exists because someone already tracks that title, so a local query can only re-suggest what's already owned. + Sources: TMDB `/{movie,tv}/top_rated`, RAWG `/games?ordering=-{rating|metacritic}`. +- Ordering follows the admin-selected primary rating source (`RatingSourceCatalog.Resolve`, no Explore-specific setting). + RAWG sorts natively on both its sources with zero extra calls. + Movies/TV under **IMDb** are the awkward case (IMDb has no catalogue API): the list still comes from TMDB's ranking and OMDb only fills in the displayed number, and the page is deliberately **not** re-sorted by it - + partial OMDb data would float unrated titles to the top. + `app_setting.explore_use_tmdb` forces movies/TV back onto TMDB's vote and skips the per-title lookups; it's read only when IMDb won the resolve, so it can't leak into the game domain. +- **Gotcha:** RAWG has no curated top-rated endpoint and its `rating` is a plain average with no vote-count filter, so ordering the ~900k catalogue by `-rating` ranks a single-vote unknown above every classic. + `GetTopRatedGamesAsync` constrains the pool server-side with `metacritic={MinMetacritic},100` - "reviewed by the professional press at all" is the closest equivalent of a minimum vote count and costs no extra call. + `MinMetacritic` is the knob to raise. + Don't filter client-side instead: the paging loop stops on an empty page, so a filter that can empty one would silently truncate results. +- **The "already have it" exclusion needs both halves**: by provider id (`FindLinkedReferenceIdsAsync` resolved to those documents' `ExternalIds[provider]`) and by normalized title (`FindDistinctTitlesAsync` + `TitleNormalizer`), because + automatic resolution gives up on multiple candidates, so a manually-added item may have no link at all and would be re-suggested forever. + Two different works sharing a title collapse under the fallback - an accepted trade. + `IExploreSourceRepository` declares both projections once; `ExploreExclusionQueries` implements them for every domain, each repository contributing only a field expression. +- `explore_dismissal` is keyed `{owner_id, item_type, external_source, external_id}` (unique) on the *provider's* id, since a suggestion usually has no reference document yet. + `external_source` is the **discovery** provider (`tmdb`/`rawg`), not the rating source - an IMDb-ranked movie is still identified by a TMDB id, and RAWG/TMDB ids are both plain integers with nothing but an explicit provider to keep them + apart. + `ExploreService.DiscoverySource` is the one place a domain's provider is named. +- **Adding goes through `POST /api/explore/{type}/add/{externalId}`, not the ordinary create**: it creates the item then calls `Resolve*Async` with the *exact* provider id, awaited, so the card only disappears once genuinely linked. + The ordinary create's auto-resolve is a title search that only links on a single candidate, which acclaimed titles routinely fail. + Free-tier quota is enforced here via `FreeTierQuota.CheckAsync`. + The controller is plain `[Authorize]` (movies/TV are free tier) with video games member-gated per request (`RequireAccessTo`, 403); the page hides the tab behind `` and falls back to Movies - + hiding is UX, the API is enforcement. +- `ExplorePage.razor` keeps the active tab in `?tab=`, caches one list per tab, and tops a tab up when it drops below the page size, appending below the current cards rather than reshuffling. + Add and dismiss share one `ActAsync`. + +## Blazor app + +`InventoryPageBase` centralizes list/paging/search/filter state and calls `InventoryApiClientBase`. +A concrete page supplies only its `Api` and `CloneItem`, plus a `protected virtual ExtraQuery` override for its own filters. +Pages that aren't generic CRUD lists (detail pages, Watch Next, Import) build their own layout on the shared `kt-*` classes in `app.css`, with their API clients in their own feature folder. + +**List state lives in the URL query string** (`?search=&page=&sort=` plus lowercase per-filter params), read back via `[SupplyParameterFromQuery]`. +Search/filter/pagination clicks never call `LoadAsync` - they navigate via `ApplyQueryChanges`/`ToggleFilter`/`SetFilter`, and the reload happens once in `OnParametersSetAsync`. +This is what makes browser-back from a detail page restore the exact list position, and it means a click and a back/forward share one code path. +A new filter therefore needs exactly three things: a `[SupplyParameterFromQuery]` property, an `ExtraQuery` entry (API-facing key, e.g. `IsFavorite`), and a button calling `ToggleFilter`/`SetFilter` with the URL param name (e.g. +`favorite`). +Don't add a mutate-then-`LoadAsync` handler. + +**List ordering is deterministic everywhere.** `MongoDbRepositoryBase.FindAllAsync` sorts every page read, defaulting to `_id` descending (ObjectIds embed creation time, so no created-at field is needed) with `_id` appended as tie-break +under every other key - an unsorted skip/limit page can duplicate or drop items across pages. +`PagedRequest.Sort` carries a `ListSort` key (`title`, `rating`) end-to-end; a repository opts in by overriding `SortTitleField`/`SortRatingField` with an **expression**, never an element-name string (`Car.Name` stores as `commercial_name`, +which a string sort would silently miss). +Unknown keys fall back to newest-first. +The title sort attaches a per-query `Collation` ("en", strength 2) for case/diacritic-insensitive ordering with no shadow field and no new indexes (per-owner subsets are small). + +- **Gotcha:** MongoDB rejects a collation combined with a `$text` filter. + This is safe today only because every `GetFilter` searches via regex `Contains` (the base's `builder.Text` default is effectively dead) - a future `$text`-searching repository must gate the collation. +- `InventoryList`'s search box keeps a deliberate local copy of the text (so a parent re-render racing fast typing can't revert characters) and adopts an external `Search` change only when it didn't originate from its own `OnSearchChanged` + - read the sent/received tracking in `OnParametersSet` before touching it. + +**Gotcha:** a `string`-typed component `[Parameter]` needs the `@` prefix - `Title="_movie.Title"` binds the **literal text**, not the value. +Razor only infers C# when the parameter type couldn't accept a string literal (`Year="_movie.Year"` on `int?` works unprefixed). +It compiles and renders fine; the bug shows up only in the data, which is how `InlineReferenceLinker` once searched TMDB for the literal `_movie.Title`. +Always write `Title="@_movie.Title"`. + +**Scaling is an app-level design here, not an infrastructure assumption** - the app may sit behind a Cloudflare tunnel with no cookie affinity, so nothing may rely on sticky sessions. +`DataProtection:MongoDb:*` (opt-in) persists the key ring via `DataProtection/MongoDbXmlRepository` so cookies and antiforgery tokens decrypt on every replica; without it multi-replica cookie auth breaks. +This is the only reason `BlazorApp.csproj` references `MongoDB.Driver` (it still never references `Domain`/`Infrastructure.MongoDb`). +`Features:IsWebSocketsOnlyEnabled` (default `true`) starts the circuit with `skipNegotiation` + WebSockets-only, pinning a circuit to the pod owning its state; +set it `false` only behind a proxy that can't pass WebSockets, and stay single-replica there. ### Theme -The app is dark-only — there is no light theme and no in-app toggle. -`App.razor` sets `data-bs-theme="dark"` statically on ``, server-rendered as part of the initial markup rather than applied by client-side JS, so there's no flash of a different theme on first paint or between page loads. -`app.css`'s token block (`:root { --kt-bg: ...; }`, Bootstrap 5.3's native color-mode variables) only ever defines the dark values now; a previous light+dark version -(with a `wwwroot/theme.js` that picked the initial theme from `localStorage`/`prefers-color-scheme`, a `ktToggleTheme()` toggle button in `NavMenu.razor`, -and a `Keeptrack.BlazorApp.lib.module.js` JS initializer that re-applied `data-bs-theme` after Blazor's enhanced-navigation DOM diff) was removed entirely at the owner's request — -the toggle caused visibly jarring light/dark flashes on every page load, and a single dark theme is simpler to maintain than two. -Don't reintroduce `data-bs-theme` as something client-side JS sets or removes; keep it a static attribute on `` so enhanced navigation can never strip it. -Use system-ui fonts only; no decorative/display webfonts. - -Icons throughout the app are plain Unicode symbols with no default emoji presentation (`◈`, `✓`, `✕`, `★`, `▶`, `↻`, `⌂`, `⚙`, `♪`, and the Geometric Shapes block generally: `◼ ▭ ▬ ◆`), -never a codepoint whose default rendering is a full-color emoji glyph (`⭐`, `👁`, `🔄`, `⏳`, `🏠`, `📚`, `🎬`, ...) - a color emoji reads as an inconsistent, -slightly unpolished note among otherwise monochrome UI that follows `--kt-text`/`--kt-accent` like everything else. -**Gotcha:** a codepoint isn't safe just because it "looks like" a plain symbol - `⭐` (U+2B50) and `👁` (U+1F441) were originally used as the "Best of"/"Want to watch" icons on the assumption they were plain stars/eyes, -but both have `Emoji_Presentation=Yes` by default (Unicode's `emoji-data.txt`) and render as full-color glyphs on every mainstream platform; they were replaced with `★` (U+2605, Black Star, default text presentation) -and `▶` (U+25B6, default text presentation) respectively. -Before adding a new symbol, check whether its default presentation is text or emoji - don't assume from how it looks in this file. -Appending a trailing variation selector (`️`, U+FE0F) forces emoji presentation even on an otherwise-safe codepoint, so never add one; some codepoints (`🌙`, `🚪`, `🔑`, `👤`, `📦`) have no text-presentation form at all -and were simply dropped rather than replaced with an approximate glyph, since a semantically-forced match is worse than no icon when the row's label text already carries the meaning. -`.kt-icon-spin` (reuses the same `spin` keyframes as `.kt-spinner`) makes a plain glyph rotate in place for a small inline action's "in progress" state, instead of swapping to an hourglass emoji. - -Enhanced navigation re-fetches and diffs the whole document on every in-app link click; anything set on ``/`` by client-side JS -rather than server-rendered markup gets stripped back out unless it's explicitly re-applied. -A JS initializer (`wwwroot/Keeptrack.BlazorApp.lib.module.js`, autoloaded by Blazor because its name matches the assembly - -don't add a manual `