Files
LudosData/backend/tests/LudosData.Api.Tests/GamesTests.cs
T
ckochandClaude Opus 5 d5a0e42fed 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>
2026-08-04 13:29:48 -04:00

259 lines
10 KiB
C#

using System.Net;
using System.Net.Http.Json;
namespace LudosData.Api.Tests;
public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static object Game(
string title, string system = "SNES", string genre = "rpg", string? year = "1995",
string? developer = null, string? publisher = null,
bool own = true, bool dumped = false, bool played = false, bool finished = false) => new
{
title, system, genre, year, developer, publisher,
own, dumped, played, finished,
};
private static async Task SeedLibraryAsync(HttpClient client)
{
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]
public async Task List_paginates_and_reports_totals()
{
var client = await factory.CreateUserClientAsync("page-user");
await SeedLibraryAsync(client);
var page = await client.GetJsonAsync<PagePayload>("/api/games?page=1&pageSize=2");
Assert.Equal(2, page!.Items.Count);
Assert.Equal(5, page.Total);
Assert.Equal(3, page.TotalPages);
}
[Fact]
public async Task Search_matches_title_developer_and_publisher()
{
var client = await factory.CreateUserClientAsync("search-user");
await SeedLibraryAsync(client);
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);
Assert.Equal("GoldenEye 007", Assert.Single(byPublisher!.Items).Title);
}
[Fact]
public async Task Filters_narrow_by_system_genre_and_status()
{
var client = await factory.CreateUserClientAsync("filter-user");
await SeedLibraryAsync(client);
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);
Assert.Equal(1, finished!.Total);
Assert.Equal(3, unplayed!.Total);
}
[Fact]
public async Task Sort_orders_ascending_and_descending()
{
var client = await factory.CreateUserClientAsync("sort-user");
await SeedLibraryAsync(client);
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);
}
[Fact]
public async Task An_unknown_sort_key_falls_back_to_title_rather_than_failing()
{
var client = await factory.CreateUserClientAsync("sort-unknown");
await SeedLibraryAsync(client);
// The sort parameter is matched against an allow-list, so a value the API
// does not define can neither error nor reach the query shape.
var response = await client.GetAsync("/api/games?sort=id);DROP%20TABLE%20Games;--");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
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.GetJsonAsync<PagePayload>("/api/games");
Assert.Equal(5, after!.Total);
}
[Theory]
[InlineData("pageSize=0")]
[InlineData("pageSize=1000")]
[InlineData("page=0")]
public async Task Out_of_range_paging_is_rejected(string query)
{
var client = await factory.CreateUserClientAsync($"range-{query.GetHashCode():X}");
var response = await client.GetAsync($"/api/games?{query}");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Create_requires_a_title()
{
var client = await factory.CreateUserClientAsync("title-required");
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);
}
[Fact]
public async Task Update_changes_fields_and_moves_the_updated_timestamp()
{
var client = await factory.CreateUserClientAsync("update-user");
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.PutJsonAsync(
$"/api/games/{created!.Id}", Game("After", finished: true)))
.Content.ReadJsonAsync<GamePayload>();
Assert.Equal("After", updated!.Title);
Assert.True(updated.Finished);
Assert.Equal(created.CreatedAt, updated.CreatedAt);
Assert.True(updated.UpdatedAt > created.UpdatedAt);
}
[Fact]
public async Task Delete_removes_the_game()
{
var client = await factory.CreateUserClientAsync("delete-user");
var created = await (await client.PostJsonAsync("/api/games", Game("Doomed")))
.Content.ReadJsonAsync<GamePayload>();
var response = await client.DeleteAsync($"/api/games/{created!.Id}");
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/games/{created.Id}")).StatusCode);
}
[Fact]
public async Task Blank_optional_fields_round_trip_as_null()
{
var client = await factory.CreateUserClientAsync("null-user");
var created = await (await client.PostJsonAsync("/api/games", new
{
title = " Trimmed ",
system = (string?)null,
genre = (string?)null,
own = true,
})).Content.ReadJsonAsync<GamePayload>();
Assert.Equal("Trimmed", created!.Title);
Assert.Null(created.System);
Assert.Null(created.Genre);
}
// ---- uploads ---------------------------------------------------------
[Fact]
public async Task Uploading_a_real_image_stores_it_as_webp_and_serves_it()
{
var client = await factory.CreateUserClientAsync("upload-ok");
using var content = new MultipartFormDataContent();
var image = new ByteArrayContent(TestImages.Png(16, 16));
image.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
content.Add(image, "file", "cover.png");
var response = await client.PostAsync("/api/images", content);
response.EnsureSuccessStatusCode();
var upload = await response.Content.ReadJsonAsync<UploadPayload>();
Assert.EndsWith(".webp", upload!.FileName);
// The stored name is generated server-side, never taken from the upload.
Assert.DoesNotContain("cover", upload.FileName);
var served = await client.GetAsync(upload.Url);
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
Assert.Equal("image/webp", served.Content.Headers.ContentType?.MediaType);
}
[Fact]
public async Task Uploading_something_that_is_not_an_image_is_rejected()
{
var client = await factory.CreateUserClientAsync("upload-bad");
using var content = new MultipartFormDataContent();
var text = new ByteArrayContent("this is not an image"u8.ToArray());
// A truthful-looking content type and extension are not enough: the bytes
// have to decode.
text.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
content.Add(text, "file", "payload.png");
var response = await client.PostAsync("/api/images", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Uploading_nothing_is_rejected()
{
var client = await factory.CreateUserClientAsync("upload-empty");
using var content = new MultipartFormDataContent();
content.Add(new ByteArrayContent([]), "file", "empty.png");
var response = await client.PostAsync("/api/images", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task One_users_upload_is_not_reachable_under_another_users_folder()
{
var alice = await factory.CreateUserClientAsync("art-alice");
var bob = await factory.CreateUserClientAsync("art-bob");
using var content = new MultipartFormDataContent();
var image = new ByteArrayContent(TestImages.Png(8, 8));
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.ReadJsonAsync<UploadPayload>();
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}");
Assert.Equal(HttpStatusCode.NotFound, probe.StatusCode);
}
private record GamePayload(
int Id, string Title, string? System, string? Genre, bool Finished,
DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt);
private record PagePayload(List<GamePayload> Items, int Page, int PageSize, int Total, int TotalPages);
private record UploadPayload(string FileName, string Url);
}