Add collector fields, including market value

Rating, notes, condition, region, purchase price and date, plus a market
value carrying the timestamp and source that make it interpretable.

Condition is load-bearing rather than cosmetic: price feeds quote per
condition, so it selects which quoted price applies to a copy. Market value
records when it was captured and where it came from — a collection total is
only as good as its staleness — and an edit to an unrelated field leaves
that timestamp alone, so a stale price cannot start looking freshly checked.

Two storage decisions worth naming:

  * Money is stored as integer minor units. SQLite has no decimal type and
    EF Core maps decimal to TEXT, which compares lexically: "9.00" sorts
    above "10.00" and SUM is unavailable. A value converter keeps decimals
    in C# while ordering and totalling work. A test pins the ordering.
  * Enums serialise as names. The default is ordinals, which meant the API
    rejected the browser's {"condition":"Cib"} with a 400 while the C# tests
    passed, because they round-tripped ints and never spoke the client's
    dialect. The tests now share the API's serializer options.

Also fixes a data-loss bug in the Python tools. Both built their PUT body
from a hardcoded list of field names, so any column added to the model was
omitted and therefore nulled. Adding collector fields meant the next art or
enrichment run would have erased every rating, note, condition, price and
valuation in the library. Payloads are now built by excluding the handful of
server-owned fields, so new columns carry through by default.

The migration was rehearsed against a copy of the live database before being
applied: 105 rows, descriptions and developers intact.

67 backend tests, 8 frontend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 13:29:48 -04:00
co-authored by Claude Opus 5
parent b69a5c9d14
commit d5a0e42fed
29 changed files with 1585 additions and 105 deletions
@@ -9,7 +9,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
{
private static async Task SeedAsync(HttpClient client)
{
await client.PostAsJsonAsync("/api/games", new
await client.PostJsonAsync("/api/games", new
{
title = "Chrono Trigger",
system = "SNES",
@@ -20,7 +20,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
played = true,
finished = true,
});
await client.PostAsJsonAsync("/api/games", new
await client.PostJsonAsync("/api/games", new
{
title = "Ico",
system = "PS2",
@@ -54,7 +54,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
Assert.Contains("attachment", response.Content.Headers.ContentDisposition?.DispositionType
?? response.Content.Headers.ContentDisposition?.ToString() ?? "attachment");
var payload = await response.Content.ReadFromJsonAsync<LibraryExport>();
var payload = await response.Content.ReadJsonAsync<LibraryExport>();
Assert.Equal(2, payload!.Count);
Assert.Contains(payload.Games, g => g.Title == "Chrono Trigger" && g.Developer == "Square");
}
@@ -79,9 +79,9 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
var alice = await factory.CreateUserClientAsync("exp-alice");
var bob = await factory.CreateUserClientAsync("exp-bob");
await SeedAsync(alice);
await bob.PostAsJsonAsync("/api/games", new { title = "Bob Only", system = "N64", own = true });
await bob.PostJsonAsync("/api/games", new { title = "Bob Only", system = "N64", own = true });
var payload = await bob.GetFromJsonAsync<LibraryExport>("/api/library/export?format=json");
var payload = await bob.GetJsonAsync<LibraryExport>("/api/library/export?format=json");
Assert.Equal(1, payload!.Count);
Assert.Equal("Bob Only", payload.Games[0].Title);
@@ -109,8 +109,8 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
FileContent(exported, "library.json", "application/json"));
response.EnsureSuccessStatusCode();
var before = await source.GetFromJsonAsync<LibraryExport>("/api/library/export?format=json");
var after = await target.GetFromJsonAsync<LibraryExport>("/api/library/export?format=json");
var before = await source.GetJsonAsync<LibraryExport>("/api/library/export?format=json");
var after = await target.GetJsonAsync<LibraryExport>("/api/library/export?format=json");
Assert.Equal(before!.Count, after!.Count);
Assert.Equal(
@@ -133,14 +133,14 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(1, result!.Created); // Super Metroid
Assert.Equal(1, result.Updated); // Chrono Trigger matched on title + system
Assert.Equal(0, result.Deleted);
// The update took effect: it was finished before, and the file says otherwise.
var page = await client.GetFromJsonAsync<PagePayload>("/api/games?search=Chrono");
var page = await client.GetJsonAsync<PagePayload>("/api/games?search=Chrono");
Assert.False(page!.Items[0].Finished);
}
@@ -148,7 +148,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
public async Task The_same_title_on_a_different_system_is_a_different_game()
{
var client = await factory.CreateUserClientAsync("imp-platform");
await client.PostAsJsonAsync("/api/games", new
await client.PostJsonAsync("/api/games", new
{
title = "Donkey Kong Country", system = "SNES", own = true,
});
@@ -161,7 +161,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(2, result!.Created); // GB and GBA
Assert.Equal(1, result.Updated); // the existing SNES row
@@ -179,12 +179,12 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
""";
var result = await (await client.PostAsync("/api/library/import?dryRun=true",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.True(result!.DryRun);
Assert.Equal(1, result.Created);
var page = await client.GetFromJsonAsync<PagePayload>("/api/games");
var page = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(2, page!.Total); // still just the seeded pair
}
@@ -200,12 +200,12 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
""";
var result = await (await client.PostAsync("/api/library/import?mode=Replace",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(2, result!.Deleted);
Assert.Equal(1, result.Created);
var page = await client.GetFromJsonAsync<PagePayload>("/api/games");
var page = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(1, page!.Total);
Assert.Equal("Only Survivor", page.Items[0].Title);
}
@@ -222,7 +222,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
await bob.PostAsync("/api/library/import?mode=Replace",
FileContent("title,system,own\nBob Only,GC,true", "in.csv", "text/csv"));
var alicePage = await alice.GetFromJsonAsync<PagePayload>("/api/games");
var alicePage = await alice.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(2, alicePage!.Total);
}
@@ -238,7 +238,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync<ImportResult>();
FileContent(csv, "in.csv", "text/csv"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(1, result!.Created);
Assert.Single(result.Errors);
@@ -251,7 +251,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
var client = await factory.CreateUserClientAsync("imp-quoting");
var awkward = "A description with, a comma, \"quotes\" and\na newline.";
await client.PostAsJsonAsync("/api/games", new
await client.PostJsonAsync("/api/games", new
{
title = "Awkward, Game \"Title\"",
system = "PS1",
@@ -265,7 +265,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
var target = await factory.CreateUserClientAsync("imp-quoting-target");
await target.PostAsync("/api/library/import", FileContent(csv, "in.csv", "text/csv"));
var payload = await target.GetFromJsonAsync<LibraryExport>("/api/library/export?format=json");
var payload = await target.GetJsonAsync<LibraryExport>("/api/library/export?format=json");
var game = Assert.Single(payload!.Games);
Assert.Equal("Awkward, Game \"Title\"", game.Title);
@@ -282,7 +282,7 @@ public class LibraryTests(LudosApiFactory factory) : IClassFixture<LudosApiFacto
""";
var result = await (await client.PostAsync("/api/library/import",
FileContent(json, "in.json", "application/json"))).Content.ReadFromJsonAsync<ImportResult>();
FileContent(json, "in.json", "application/json"))).Content.ReadJsonAsync<ImportResult>();
Assert.Equal(1, result!.Created);
}