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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user