Files
LudosData/backend/tests/LudosData.Api.Tests/OwnershipTests.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

161 lines
6.3 KiB
C#

using System.Net;
using System.Net.Http.Json;
namespace LudosData.Api.Tests;
/// <summary>
/// The rules that matter most.
///
/// The API this replaced took the owner from a client-supplied query parameter
/// (<c>filter[]=userId,eq,N</c>), so any valid token could read any other user's
/// library by editing a number. These tests pin the replacement: ownership comes
/// from the JWT subject, and a row belonging to someone else is indistinguishable
/// from one that does not exist.
/// </summary>
public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static object Game(string title) => new
{
title,
system = "SNES",
genre = "rpg",
year = "1995",
own = true,
dumped = false,
played = false,
finished = false,
};
private static async Task<int> CreateGameAsync(HttpClient client, string title)
{
var response = await client.PostJsonAsync("/api/games", Game(title));
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadJsonAsync<GamePayload>();
return created!.Id;
}
[Fact]
public async Task A_user_sees_only_their_own_games()
{
var alice = await factory.CreateUserClientAsync("own-alice");
var bob = await factory.CreateUserClientAsync("own-bob");
await CreateGameAsync(alice, "Alice's Game");
await CreateGameAsync(bob, "Bob's Game");
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);
Assert.Single(bobPage!.Items);
Assert.Equal("Bob's Game", bobPage.Items[0].Title);
}
[Fact]
public async Task Reading_another_users_game_returns_404_not_403()
{
var alice = await factory.CreateUserClientAsync("read-alice");
var bob = await factory.CreateUserClientAsync("read-bob");
var aliceGame = await CreateGameAsync(alice, "Private");
var response = await bob.GetAsync($"/api/games/{aliceGame}");
// 404, not 403: a 403 would confirm the id exists.
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Updating_another_users_game_is_refused_and_changes_nothing()
{
var alice = await factory.CreateUserClientAsync("upd-alice");
var bob = await factory.CreateUserClientAsync("upd-bob");
var aliceGame = await CreateGameAsync(alice, "Untouched");
var response = await bob.PutJsonAsync($"/api/games/{aliceGame}", Game("Hijacked"));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var after = await alice.GetJsonAsync<GamePayload>($"/api/games/{aliceGame}");
Assert.Equal("Untouched", after!.Title);
}
[Fact]
public async Task Deleting_another_users_game_is_refused_and_the_row_survives()
{
var alice = await factory.CreateUserClientAsync("del-alice");
var bob = await factory.CreateUserClientAsync("del-bob");
var aliceGame = await CreateGameAsync(alice, "Survivor");
var response = await bob.DeleteAsync($"/api/games/{aliceGame}");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var after = await alice.GetAsync($"/api/games/{aliceGame}");
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
}
[Fact]
public async Task An_ownerId_in_the_request_body_cannot_reassign_a_game()
{
var alice = await factory.CreateUserClientAsync("spoof-alice");
var bob = await factory.CreateUserClientAsync("spoof-bob");
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.PostJsonAsync("/api/games", new
{
title = "Attempted Handover",
system = "SNES",
own = true,
ownerId = bobUser!.Id,
userId = bobUser.Id,
});
response.EnsureSuccessStatusCode();
var alicePage = await alice.GetJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetJsonAsync<PagePayload>("/api/games");
Assert.Single(alicePage!.Items);
Assert.Empty(bobPage!.Items);
}
[Fact]
public async Task Facets_are_scoped_to_the_signed_in_user()
{
var alice = await factory.CreateUserClientAsync("facet-alice");
var bob = await factory.CreateUserClientAsync("facet-bob");
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.GetJsonAsync<FacetsPayload>("/api/games/facets");
Assert.Equal(["N64"], facets!.Systems);
Assert.Equal(["fps"], facets.Genres);
}
[Fact]
public async Task Every_games_route_requires_a_token()
{
var anonymous = factory.CreateClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/games")).StatusCode);
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.PostJsonAsync("/api/games", Game("x"))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(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);
}
private record GamePayload(int Id, string Title, string? System, string? Art, string? ArtUrl);
private record PagePayload(List<GamePayload> Items, int Page, int PageSize, int Total, int TotalPages);
private record FacetsPayload(List<string> Systems, List<string> Genres);
}