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
+16 -16
View File
@@ -17,10 +17,10 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/auth/register", Registration("reg-ok"));
var response = await client.PostJsonAsync("/api/auth/register", Registration("reg-ok"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var auth = await response.Content.ReadFromJsonAsync<LudosApiFactory.AuthPayload>();
var auth = await response.Content.ReadJsonAsync<LudosApiFactory.AuthPayload>();
Assert.False(string.IsNullOrWhiteSpace(auth!.Token));
Assert.Equal("reg-ok", auth.User.UserName);
Assert.True(auth.ExpiresAt > DateTimeOffset.UtcNow);
@@ -35,7 +35,7 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync(
var response = await client.PostJsonAsync(
"/api/auth/register", Registration($"weak-{password.Length}-{password[0]}", password));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -45,9 +45,9 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
public async Task Register_rejects_a_duplicate_username()
{
var client = factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register", Registration("dupe-user"));
await client.PostJsonAsync("/api/auth/register", Registration("dupe-user"));
var second = await client.PostAsJsonAsync("/api/auth/register", Registration("dupe-user"));
var second = await client.PostJsonAsync("/api/auth/register", Registration("dupe-user"));
Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode);
}
@@ -56,9 +56,9 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
public async Task Login_succeeds_with_the_right_password()
{
var client = factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register", Registration("login-ok"));
await client.PostJsonAsync("/api/auth/register", Registration("login-ok"));
var response = await client.PostAsJsonAsync(
var response = await client.PostJsonAsync(
"/api/auth/login", new { userName = "login-ok", password = "TestPassword123" });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
@@ -68,9 +68,9 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
public async Task Login_rejects_a_wrong_password()
{
var client = factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register", Registration("login-bad"));
await client.PostJsonAsync("/api/auth/register", Registration("login-bad"));
var response = await client.PostAsJsonAsync(
var response = await client.PostJsonAsync(
"/api/auth/login", new { userName = "login-bad", password = "WrongPassword123" });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
@@ -80,11 +80,11 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
public async Task Login_does_not_reveal_whether_a_username_exists()
{
var client = factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register", Registration("enum-real"));
await client.PostJsonAsync("/api/auth/register", Registration("enum-real"));
var wrongPassword = await client.PostAsJsonAsync(
var wrongPassword = await client.PostJsonAsync(
"/api/auth/login", new { userName = "enum-real", password = "WrongPassword123" });
var noSuchUser = await client.PostAsJsonAsync(
var noSuchUser = await client.PostJsonAsync(
"/api/auth/login", new { userName = "enum-absent", password = "WrongPassword123" });
// Identical status and body, so the endpoint cannot be used to harvest
@@ -99,11 +99,11 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
public async Task Availability_reports_taken_and_free_names_without_leaking_the_row()
{
var client = factory.CreateClient();
await client.PostAsJsonAsync("/api/auth/register", Registration("taken-name"));
await client.PostJsonAsync("/api/auth/register", Registration("taken-name"));
var taken = await client.GetFromJsonAsync<AvailabilityPayload>(
var taken = await client.GetJsonAsync<AvailabilityPayload>(
"/api/auth/available?userName=taken-name");
var free = await client.GetFromJsonAsync<AvailabilityPayload>(
var free = await client.GetJsonAsync<AvailabilityPayload>(
"/api/auth/available?userName=definitely-free-name");
Assert.False(taken!.Available);
@@ -131,7 +131,7 @@ public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
var client = await factory.CreateUserClientAsync("me-user");
var user = await client.GetFromJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
var user = await client.GetJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
Assert.Equal("me-user", user!.UserName);
}
@@ -0,0 +1,325 @@
using System.Net;
using System.Net.Http.Json;
using LudosData.Api.Contracts;
using LudosData.Api.Domain;
namespace LudosData.Api.Tests;
public class CollectorFieldTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static object Game(
string title,
string system = "SNES",
int? rating = null,
string? notes = null,
GameCondition condition = GameCondition.Unspecified,
GameRegion region = GameRegion.Unspecified,
decimal? purchasePrice = null,
string? purchaseDate = null,
decimal? marketValue = null,
string? marketValueSource = null) => new
{
title, system, own = true,
rating, notes, condition, region,
purchasePrice, purchaseDate, marketValue, marketValueSource,
};
private static async Task<GamePayload> CreateAsync(HttpClient client, object body)
{
var response = await client.PostJsonAsync("/api/games", body);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadJsonAsync<GamePayload>())!;
}
[Fact]
public async Task Collector_fields_round_trip()
{
var client = await factory.CreateUserClientAsync("cf-roundtrip");
var created = await CreateAsync(client, Game(
"Panzer Dragoon Saga",
rating: 9,
notes: "Bought at a swap meet. Disc 2 has a scratch.",
condition: GameCondition.Cib,
region: GameRegion.Ntsc,
purchasePrice: 249.99m,
purchaseDate: "2019-06-14",
marketValue: 1150.00m,
marketValueSource: "pricecharting"));
Assert.Equal(9, created.Rating);
Assert.Equal("Bought at a swap meet. Disc 2 has a scratch.", created.Notes);
Assert.Equal(GameCondition.Cib, created.Condition);
Assert.Equal(GameRegion.Ntsc, created.Region);
Assert.Equal(249.99m, created.PurchasePrice);
Assert.Equal(new DateOnly(2019, 6, 14), created.PurchaseDate);
Assert.Equal(1150.00m, created.MarketValue);
Assert.Equal("pricecharting", created.MarketValueSource);
}
[Fact]
public async Task Money_keeps_its_cents_through_storage()
{
var client = await factory.CreateUserClientAsync("cf-cents");
// Money is stored as integer minor units, so the awkward values are the
// ones worth checking.
var created = await CreateAsync(client, Game("Cent Test",
purchasePrice: 0.01m, marketValue: 19.99m));
Assert.Equal(0.01m, created.PurchasePrice);
Assert.Equal(19.99m, created.MarketValue);
var reloaded = await client.GetJsonAsync<GamePayload>($"/api/games/{created.Id}");
Assert.Equal(0.01m, reloaded!.PurchasePrice);
Assert.Equal(19.99m, reloaded.MarketValue);
}
[Fact]
public async Task Sorting_by_value_is_numeric_not_lexical()
{
var client = await factory.CreateUserClientAsync("cf-sort");
await CreateAsync(client, Game("Nine", marketValue: 9m));
await CreateAsync(client, Game("Ten", marketValue: 10m));
await CreateAsync(client, Game("Hundred", marketValue: 100m));
var page = await client.GetJsonAsync<PagePayload>("/api/games?sort=value&dir=desc");
// Stored as text, "9" would sort above "100" and this would read
// Nine, Ten, Hundred.
Assert.Equal(["Hundred", "Ten", "Nine"], page!.Items.Select(g => g.Title));
}
[Fact]
public async Task Rating_must_be_between_1_and_10()
{
var client = await factory.CreateUserClientAsync("cf-rating");
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostJsonAsync("/api/games", Game("Too low", rating: 0))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostJsonAsync("/api/games", Game("Too high", rating: 11))).StatusCode);
// Null is unrated, which is legitimate and not the same as zero.
var unrated = await CreateAsync(client, Game("Unrated"));
Assert.Null(unrated.Rating);
}
[Fact]
public async Task A_minimum_rating_filter_excludes_unrated_games()
{
var client = await factory.CreateUserClientAsync("cf-minrating");
await CreateAsync(client, Game("Great", rating: 9));
await CreateAsync(client, Game("Fine", rating: 6));
await CreateAsync(client, Game("Unrated"));
var page = await client.GetJsonAsync<PagePayload>("/api/games?minRating=7");
Assert.Equal("Great", Assert.Single(page!.Items).Title);
}
[Fact]
public async Task Condition_and_region_filter()
{
var client = await factory.CreateUserClientAsync("cf-filters");
await CreateAsync(client, Game("Sealed Copy", condition: GameCondition.Sealed, region: GameRegion.Ntsc));
await CreateAsync(client, Game("Loose Copy", condition: GameCondition.Loose, region: GameRegion.Pal));
var sealedOnly = await client.GetJsonAsync<PagePayload>("/api/games?condition=Sealed");
var palOnly = await client.GetJsonAsync<PagePayload>("/api/games?region=Pal");
Assert.Equal("Sealed Copy", Assert.Single(sealedOnly!.Items).Title);
Assert.Equal("Loose Copy", Assert.Single(palOnly!.Items).Title);
}
[Fact]
public async Task HasValue_separates_valued_from_unvalued_games()
{
var client = await factory.CreateUserClientAsync("cf-hasvalue");
await CreateAsync(client, Game("Valued", marketValue: 40m));
await CreateAsync(client, Game("Unvalued"));
var valued = await client.GetJsonAsync<PagePayload>("/api/games?hasValue=true");
var unvalued = await client.GetJsonAsync<PagePayload>("/api/games?hasValue=false");
Assert.Equal("Valued", Assert.Single(valued!.Items).Title);
Assert.Equal("Unvalued", Assert.Single(unvalued!.Items).Title);
}
[Fact]
public async Task Setting_a_value_stamps_when_and_where_it_came_from()
{
var client = await factory.CreateUserClientAsync("cf-stamp");
var before = DateTimeOffset.UtcNow.AddSeconds(-1);
var created = await CreateAsync(client, Game("Stamped", marketValue: 55m));
Assert.NotNull(created.MarketValueUpdatedAt);
Assert.True(created.MarketValueUpdatedAt >= before);
// No source given, so it is recorded as hand-entered.
Assert.Equal("manual", created.MarketValueSource);
}
[Fact]
public async Task An_unrelated_edit_does_not_make_a_stale_valuation_look_fresh()
{
var client = await factory.CreateUserClientAsync("cf-nostamp");
var created = await CreateAsync(client, Game("Keeps Its Date", marketValue: 30m));
var originalStamp = created.MarketValueUpdatedAt;
await Task.Delay(20);
// Change the notes, leave the value alone.
var updated = await (await client.PutJsonAsync($"/api/games/{created.Id}",
Game("Keeps Its Date", notes: "Edited something else", marketValue: 30m)))
.Content.ReadJsonAsync<GamePayload>();
Assert.Equal("Edited something else", updated!.Notes);
Assert.Equal(originalStamp, updated.MarketValueUpdatedAt);
}
[Fact]
public async Task Changing_the_value_moves_the_timestamp()
{
var client = await factory.CreateUserClientAsync("cf-restamp");
var created = await CreateAsync(client, Game("Repriced", marketValue: 30m));
await Task.Delay(20);
var updated = await (await client.PutJsonAsync($"/api/games/{created.Id}",
Game("Repriced", marketValue: 45m))).Content.ReadJsonAsync<GamePayload>();
Assert.Equal(45m, updated!.MarketValue);
Assert.True(updated.MarketValueUpdatedAt > created.MarketValueUpdatedAt);
}
[Fact]
public async Task Clearing_the_value_clears_its_metadata_too()
{
var client = await factory.CreateUserClientAsync("cf-clear");
var created = await CreateAsync(client, Game("Devalued", marketValue: 30m, marketValueSource: "feed"));
var updated = await (await client.PutJsonAsync($"/api/games/{created.Id}",
Game("Devalued"))).Content.ReadJsonAsync<GamePayload>();
Assert.Null(updated!.MarketValue);
Assert.Null(updated.MarketValueUpdatedAt);
Assert.Null(updated.MarketValueSource);
}
[Fact]
public async Task Negative_money_is_rejected()
{
var client = await factory.CreateUserClientAsync("cf-negative");
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostJsonAsync("/api/games", Game("Negative", purchasePrice: -5m))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,
(await client.PostJsonAsync("/api/games", Game("Negative", marketValue: -5m))).StatusCode);
}
// ---- export / import -------------------------------------------------
[Fact]
public async Task Collector_fields_survive_a_json_round_trip()
{
var source = await factory.CreateUserClientAsync("cf-json-src");
await CreateAsync(source, Game("Full House",
rating: 8, notes: "note", condition: GameCondition.Cib, region: GameRegion.NtscJ,
purchasePrice: 12.34m, purchaseDate: "2020-01-02",
marketValue: 56.78m, marketValueSource: "feed"));
var exported = await source.GetStringAsync("/api/library/export?format=json");
var target = await factory.CreateUserClientAsync("cf-json-dst");
await target.PostAsync("/api/library/import", FileContent(exported, "l.json"));
var game = Assert.Single((await target.GetJsonAsync<LibraryExport>(
"/api/library/export?format=json"))!.Games);
Assert.Equal(8, game.Rating);
Assert.Equal(GameCondition.Cib, game.Condition);
Assert.Equal(GameRegion.NtscJ, game.Region);
Assert.Equal(12.34m, game.PurchasePrice);
Assert.Equal(new DateOnly(2020, 1, 2), game.PurchaseDate);
Assert.Equal(56.78m, game.MarketValue);
Assert.Equal("feed", game.MarketValueSource);
}
[Fact]
public async Task Collector_fields_survive_a_csv_round_trip()
{
var source = await factory.CreateUserClientAsync("cf-csv-src");
await CreateAsync(source, Game("CSV House",
rating: 7, condition: GameCondition.Sealed, region: GameRegion.Pal,
purchasePrice: 99.95m, purchaseDate: "2021-11-30", marketValue: 250m));
var csv = await source.GetStringAsync("/api/library/export?format=csv");
var target = await factory.CreateUserClientAsync("cf-csv-dst");
await target.PostAsync("/api/library/import", FileContent(csv, "l.csv"));
var game = Assert.Single((await target.GetJsonAsync<LibraryExport>(
"/api/library/export?format=json"))!.Games);
Assert.Equal(7, game.Rating);
Assert.Equal(GameCondition.Sealed, game.Condition);
Assert.Equal(GameRegion.Pal, game.Region);
Assert.Equal(99.95m, game.PurchasePrice);
Assert.Equal(250m, game.MarketValue);
}
[Fact]
public async Task Importing_restores_a_valuation_date_rather_than_resetting_it()
{
var client = await factory.CreateUserClientAsync("cf-import-date");
// A valuation captured well in the past should still read as old after a
// restore — an import is not a fresh price check.
var json = """
[{ "title": "Old Valuation", "system": "PS1", "own": true,
"marketValue": 42.00, "marketValueUpdatedAt": "2020-03-01T00:00:00+00:00",
"marketValueSource": "archive" }]
""";
await client.PostAsync("/api/library/import", FileContent(json, "l.json"));
var game = Assert.Single((await client.GetJsonAsync<LibraryExport>(
"/api/library/export?format=json"))!.Games);
Assert.Equal(2020, game.MarketValueUpdatedAt!.Value.Year);
Assert.Equal("archive", game.MarketValueSource);
}
[Fact]
public async Task Spreadsheet_style_money_is_accepted_on_import()
{
var client = await factory.CreateUserClientAsync("cf-messy-money");
// What a spreadsheet actually emits after someone formats a column.
const string csv = """
title,system,own,purchasePrice,marketValue
Formatted,PS2,true,"$1,234.56","$2,000.00"
""";
await client.PostAsync("/api/library/import", FileContent(csv, "l.csv"));
var game = Assert.Single((await client.GetJsonAsync<LibraryExport>(
"/api/library/export?format=json"))!.Games);
Assert.Equal(1234.56m, game.PurchasePrice);
Assert.Equal(2000m, game.MarketValue);
}
private static MultipartFormDataContent FileContent(string body, string name)
{
var content = new MultipartFormDataContent();
content.Add(new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(body)), "file", name);
return content;
}
private record GamePayload(
int Id, string Title, int? Rating, string? Notes,
GameCondition Condition, GameRegion Region,
decimal? PurchasePrice, DateOnly? PurchaseDate,
decimal? MarketValue, DateTimeOffset? MarketValueUpdatedAt, string? MarketValueSource);
private record PagePayload(List<GamePayload> Items, int Total);
}
+30 -30
View File
@@ -16,11 +16,11 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
private static async Task SeedLibraryAsync(HttpClient client)
{
await client.PostAsJsonAsync("/api/games", Game("Chrono Trigger", "SNES", "rpg", "1995", developer: "Square"));
await client.PostAsJsonAsync("/api/games", Game("Super Metroid", "SNES", "platformer", "1994"));
await client.PostAsJsonAsync("/api/games", Game("GoldenEye 007", "N64", "fps", "1997", publisher: "Nintendo"));
await client.PostAsJsonAsync("/api/games", Game("Banjo-Kazooie", "N64", "adventure", "1998", played: true, finished: true));
await client.PostAsJsonAsync("/api/games", Game("Ico", "PS2", "adventure", "2001", played: true));
await client.PostJsonAsync("/api/games", Game("Chrono Trigger", "SNES", "rpg", "1995", developer: "Square"));
await client.PostJsonAsync("/api/games", Game("Super Metroid", "SNES", "platformer", "1994"));
await client.PostJsonAsync("/api/games", Game("GoldenEye 007", "N64", "fps", "1997", publisher: "Nintendo"));
await client.PostJsonAsync("/api/games", Game("Banjo-Kazooie", "N64", "adventure", "1998", played: true, finished: true));
await client.PostJsonAsync("/api/games", Game("Ico", "PS2", "adventure", "2001", played: true));
}
[Fact]
@@ -29,7 +29,7 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
var client = await factory.CreateUserClientAsync("page-user");
await SeedLibraryAsync(client);
var page = await client.GetFromJsonAsync<PagePayload>("/api/games?page=1&pageSize=2");
var page = await client.GetJsonAsync<PagePayload>("/api/games?page=1&pageSize=2");
Assert.Equal(2, page!.Items.Count);
Assert.Equal(5, page.Total);
@@ -42,9 +42,9 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
var client = await factory.CreateUserClientAsync("search-user");
await SeedLibraryAsync(client);
var byTitle = await client.GetFromJsonAsync<PagePayload>("/api/games?search=metroid");
var byDeveloper = await client.GetFromJsonAsync<PagePayload>("/api/games?search=Square");
var byPublisher = await client.GetFromJsonAsync<PagePayload>("/api/games?search=Nintendo");
var byTitle = await client.GetJsonAsync<PagePayload>("/api/games?search=metroid");
var byDeveloper = await client.GetJsonAsync<PagePayload>("/api/games?search=Square");
var byPublisher = await client.GetJsonAsync<PagePayload>("/api/games?search=Nintendo");
Assert.Equal("Super Metroid", Assert.Single(byTitle!.Items).Title);
Assert.Equal("Chrono Trigger", Assert.Single(byDeveloper!.Items).Title);
@@ -57,10 +57,10 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
var client = await factory.CreateUserClientAsync("filter-user");
await SeedLibraryAsync(client);
var n64 = await client.GetFromJsonAsync<PagePayload>("/api/games?system=N64");
var adventure = await client.GetFromJsonAsync<PagePayload>("/api/games?genre=adventure");
var finished = await client.GetFromJsonAsync<PagePayload>("/api/games?finished=true");
var unplayed = await client.GetFromJsonAsync<PagePayload>("/api/games?played=false");
var n64 = await client.GetJsonAsync<PagePayload>("/api/games?system=N64");
var adventure = await client.GetJsonAsync<PagePayload>("/api/games?genre=adventure");
var finished = await client.GetJsonAsync<PagePayload>("/api/games?finished=true");
var unplayed = await client.GetJsonAsync<PagePayload>("/api/games?played=false");
Assert.Equal(2, n64!.Total);
Assert.Equal(2, adventure!.Total);
@@ -74,8 +74,8 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
var client = await factory.CreateUserClientAsync("sort-user");
await SeedLibraryAsync(client);
var ascending = await client.GetFromJsonAsync<PagePayload>("/api/games?sort=title&dir=asc");
var descending = await client.GetFromJsonAsync<PagePayload>("/api/games?sort=title&dir=desc");
var ascending = await client.GetJsonAsync<PagePayload>("/api/games?sort=title&dir=asc");
var descending = await client.GetJsonAsync<PagePayload>("/api/games?sort=title&dir=desc");
Assert.Equal("Banjo-Kazooie", ascending!.Items.First().Title);
Assert.Equal("Super Metroid", descending!.Items.First().Title);
@@ -92,11 +92,11 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
var response = await client.GetAsync("/api/games?sort=id);DROP%20TABLE%20Games;--");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var page = await response.Content.ReadFromJsonAsync<PagePayload>();
var page = await response.Content.ReadJsonAsync<PagePayload>();
Assert.Equal("Banjo-Kazooie", page!.Items.First().Title);
// And the table is still there.
var after = await client.GetFromJsonAsync<PagePayload>("/api/games");
var after = await client.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(5, after!.Total);
}
@@ -118,8 +118,8 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
{
var client = await factory.CreateUserClientAsync("title-required");
var empty = await client.PostAsJsonAsync("/api/games", new { title = "", system = "SNES" });
var whitespace = await client.PostAsJsonAsync("/api/games", new { title = " ", system = "SNES" });
var empty = await client.PostJsonAsync("/api/games", new { title = "", system = "SNES" });
var whitespace = await client.PostJsonAsync("/api/games", new { title = " ", system = "SNES" });
Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, whitespace.StatusCode);
@@ -129,13 +129,13 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
public async Task Update_changes_fields_and_moves_the_updated_timestamp()
{
var client = await factory.CreateUserClientAsync("update-user");
var created = await (await client.PostAsJsonAsync("/api/games", Game("Before")))
.Content.ReadFromJsonAsync<GamePayload>();
var created = await (await client.PostJsonAsync("/api/games", Game("Before")))
.Content.ReadJsonAsync<GamePayload>();
await Task.Delay(15); // the stamp has sub-second resolution, but not zero
var updated = await (await client.PutAsJsonAsync(
var updated = await (await client.PutJsonAsync(
$"/api/games/{created!.Id}", Game("After", finished: true)))
.Content.ReadFromJsonAsync<GamePayload>();
.Content.ReadJsonAsync<GamePayload>();
Assert.Equal("After", updated!.Title);
Assert.True(updated.Finished);
@@ -147,8 +147,8 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
public async Task Delete_removes_the_game()
{
var client = await factory.CreateUserClientAsync("delete-user");
var created = await (await client.PostAsJsonAsync("/api/games", Game("Doomed")))
.Content.ReadFromJsonAsync<GamePayload>();
var created = await (await client.PostJsonAsync("/api/games", Game("Doomed")))
.Content.ReadJsonAsync<GamePayload>();
var response = await client.DeleteAsync($"/api/games/{created!.Id}");
@@ -161,13 +161,13 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
{
var client = await factory.CreateUserClientAsync("null-user");
var created = await (await client.PostAsJsonAsync("/api/games", new
var created = await (await client.PostJsonAsync("/api/games", new
{
title = " Trimmed ",
system = (string?)null,
genre = (string?)null,
own = true,
})).Content.ReadFromJsonAsync<GamePayload>();
})).Content.ReadJsonAsync<GamePayload>();
Assert.Equal("Trimmed", created!.Title);
Assert.Null(created.System);
@@ -188,7 +188,7 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
var response = await client.PostAsync("/api/images", content);
response.EnsureSuccessStatusCode();
var upload = await response.Content.ReadFromJsonAsync<UploadPayload>();
var upload = await response.Content.ReadJsonAsync<UploadPayload>();
Assert.EndsWith(".webp", upload!.FileName);
// The stored name is generated server-side, never taken from the upload.
@@ -240,9 +240,9 @@ public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory
image.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
content.Add(image, "file", "a.png");
var upload = await (await alice.PostAsync("/api/images", content))
.Content.ReadFromJsonAsync<UploadPayload>();
.Content.ReadJsonAsync<UploadPayload>();
var bobUser = await bob.GetFromJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
var bobUser = await bob.GetJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
var file = upload!.Url.Split('/').Last();
var probe = await bob.GetAsync($"/uploads/{bobUser!.Id}/{file}");
@@ -0,0 +1,33 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LudosData.Api.Tests;
/// <summary>
/// JSON helpers configured exactly like the API's own serializer.
///
/// Without this the tests would speak a different dialect from the browser:
/// System.Text.Json writes enums as ordinals by default, so a test could pass
/// while the real client's <c>"condition": "Cib"</c> was rejected with a 400 —
/// which is precisely what happened before the API adopted string enums.
/// </summary>
internal static class HttpJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() },
};
public static Task<HttpResponseMessage> PostJsonAsync<T>(this HttpClient client, string url, T value)
=> client.PostAsJsonAsync(url, value, Options);
public static Task<HttpResponseMessage> PutJsonAsync<T>(this HttpClient client, string url, T value)
=> client.PutAsJsonAsync(url, value, Options);
public static Task<T?> GetJsonAsync<T>(this HttpClient client, string url)
=> client.GetFromJsonAsync<T>(url, Options);
public static Task<T?> ReadJsonAsync<T>(this HttpContent content)
=> content.ReadFromJsonAsync<T>(Options);
}
@@ -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);
}
@@ -41,7 +41,7 @@ public class LudosApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
public async Task<HttpClient> CreateUserClientAsync(string userName)
{
var client = CreateClient();
var response = await client.PostAsJsonAsync("/api/auth/register", new
var response = await client.PostJsonAsync("/api/auth/register", new
{
userName,
email = $"{userName}@example.test",
@@ -49,7 +49,7 @@ public class LudosApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
});
response.EnsureSuccessStatusCode();
var auth = await response.Content.ReadFromJsonAsync<AuthPayload>();
var auth = await response.Content.ReadJsonAsync<AuthPayload>();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", auth!.Token);
@@ -28,9 +28,9 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
private static async Task<int> CreateGameAsync(HttpClient client, string title)
{
var response = await client.PostAsJsonAsync("/api/games", Game(title));
var response = await client.PostJsonAsync("/api/games", Game(title));
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<GamePayload>();
var created = await response.Content.ReadJsonAsync<GamePayload>();
return created!.Id;
}
@@ -43,8 +43,8 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
await CreateGameAsync(alice, "Alice's Game");
await CreateGameAsync(bob, "Bob's Game");
var alicePage = await alice.GetFromJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetFromJsonAsync<PagePayload>("/api/games");
var alicePage = await alice.GetJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetJsonAsync<PagePayload>("/api/games");
Assert.Single(alicePage!.Items);
Assert.Equal("Alice's Game", alicePage.Items[0].Title);
@@ -73,11 +73,11 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
var bob = await factory.CreateUserClientAsync("upd-bob");
var aliceGame = await CreateGameAsync(alice, "Untouched");
var response = await bob.PutAsJsonAsync($"/api/games/{aliceGame}", Game("Hijacked"));
var response = await bob.PutJsonAsync($"/api/games/{aliceGame}", Game("Hijacked"));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var after = await alice.GetFromJsonAsync<GamePayload>($"/api/games/{aliceGame}");
var after = await alice.GetJsonAsync<GamePayload>($"/api/games/{aliceGame}");
Assert.Equal("Untouched", after!.Title);
}
@@ -102,11 +102,11 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
var alice = await factory.CreateUserClientAsync("spoof-alice");
var bob = await factory.CreateUserClientAsync("spoof-bob");
var bobUser = await bob.GetFromJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
var bobUser = await bob.GetJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
// Alice creates a game while claiming it belongs to Bob. The contract has
// no ownerId, so this should be ignored rather than honoured.
var response = await alice.PostAsJsonAsync("/api/games", new
var response = await alice.PostJsonAsync("/api/games", new
{
title = "Attempted Handover",
system = "SNES",
@@ -116,8 +116,8 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
});
response.EnsureSuccessStatusCode();
var alicePage = await alice.GetFromJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetFromJsonAsync<PagePayload>("/api/games");
var alicePage = await alice.GetJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetJsonAsync<PagePayload>("/api/games");
Assert.Single(alicePage!.Items);
Assert.Empty(bobPage!.Items);
@@ -129,10 +129,10 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
var alice = await factory.CreateUserClientAsync("facet-alice");
var bob = await factory.CreateUserClientAsync("facet-bob");
await alice.PostAsJsonAsync("/api/games", new { title = "A", system = "N64", genre = "fps", own = true });
await bob.PostAsJsonAsync("/api/games", new { title = "B", system = "PS2", genre = "rpg", own = true });
await alice.PostJsonAsync("/api/games", new { title = "A", system = "N64", genre = "fps", own = true });
await bob.PostJsonAsync("/api/games", new { title = "B", system = "PS2", genre = "rpg", own = true });
var facets = await alice.GetFromJsonAsync<FacetsPayload>("/api/games/facets");
var facets = await alice.GetJsonAsync<FacetsPayload>("/api/games/facets");
Assert.Equal(["N64"], facets!.Systems);
Assert.Equal(["fps"], facets.Genres);
@@ -147,9 +147,9 @@ public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFac
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/games/1")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/games/facets")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.PostAsJsonAsync("/api/games", Game("x"))).StatusCode);
(await anonymous.PostJsonAsync("/api/games", Game("x"))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.PutAsJsonAsync("/api/games/1", Game("x"))).StatusCode);
(await anonymous.PutJsonAsync("/api/games/1", Game("x"))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.DeleteAsync("/api/games/1")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.PostAsync("/api/images", null)).StatusCode);
}