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 { 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 CreateAsync(HttpClient client, object body) { var response = await client.PostJsonAsync("/api/games", body); response.EnsureSuccessStatusCode(); return (await response.Content.ReadJsonAsync())!; } [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($"/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("/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("/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("/api/games?condition=Sealed"); var palOnly = await client.GetJsonAsync("/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("/api/games?hasValue=true"); var unvalued = await client.GetJsonAsync("/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(); 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(); 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(); 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( "/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( "/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( "/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( "/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 Items, int Total); }