diff --git a/backend/LudosData.slnx b/backend/LudosData.slnx
index 00978ca..170f591 100644
--- a/backend/LudosData.slnx
+++ b/backend/LudosData.slnx
@@ -2,4 +2,7 @@
+
+
+
diff --git a/backend/src/LudosData.Api/Program.cs b/backend/src/LudosData.Api/Program.cs
index ba8f29b..1bbbc0f 100644
--- a/backend/src/LudosData.Api/Program.cs
+++ b/backend/src/LudosData.Api/Program.cs
@@ -166,3 +166,10 @@ app.MapHealthChecks("/health").AllowAnonymous();
await DbSeeder.MigrateAndSeedAsync(app.Services);
app.Run();
+
+///
+/// Top-level statements compile to an internal Program class, which
+/// WebApplicationFactory cannot reach. Declaring it public here lets the test
+/// project boot the real application rather than a stand-in.
+///
+public partial class Program;
diff --git a/backend/tests/LudosData.Api.Tests/AuthTests.cs b/backend/tests/LudosData.Api.Tests/AuthTests.cs
new file mode 100644
index 0000000..3605802
--- /dev/null
+++ b/backend/tests/LudosData.Api.Tests/AuthTests.cs
@@ -0,0 +1,171 @@
+using System.Net;
+using System.Net.Http.Json;
+
+namespace LudosData.Api.Tests;
+
+public class AuthTests(LudosApiFactory factory) : IClassFixture
+{
+ private static object Registration(string user, string password = "TestPassword123") => new
+ {
+ userName = user,
+ email = $"{user}@example.test",
+ password,
+ };
+
+ [Fact]
+ public async Task Register_returns_a_usable_token()
+ {
+ var client = factory.CreateClient();
+
+ var response = await client.PostAsJsonAsync("/api/auth/register", Registration("reg-ok"));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var auth = await response.Content.ReadFromJsonAsync();
+ Assert.False(string.IsNullOrWhiteSpace(auth!.Token));
+ Assert.Equal("reg-ok", auth.User.UserName);
+ Assert.True(auth.ExpiresAt > DateTimeOffset.UtcNow);
+ }
+
+ [Theory]
+ [InlineData("short1A")] // under 12 characters
+ [InlineData("alllowercase123")] // no uppercase
+ [InlineData("ALLUPPERCASE123")] // no lowercase
+ [InlineData("NoDigitsInHerePlease")] // no digit
+ public async Task Register_enforces_the_password_policy(string password)
+ {
+ var client = factory.CreateClient();
+
+ var response = await client.PostAsJsonAsync(
+ "/api/auth/register", Registration($"weak-{password.Length}-{password[0]}", password));
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Register_rejects_a_duplicate_username()
+ {
+ var client = factory.CreateClient();
+ await client.PostAsJsonAsync("/api/auth/register", Registration("dupe-user"));
+
+ var second = await client.PostAsJsonAsync("/api/auth/register", Registration("dupe-user"));
+
+ Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode);
+ }
+
+ [Fact]
+ public async Task Login_succeeds_with_the_right_password()
+ {
+ var client = factory.CreateClient();
+ await client.PostAsJsonAsync("/api/auth/register", Registration("login-ok"));
+
+ var response = await client.PostAsJsonAsync(
+ "/api/auth/login", new { userName = "login-ok", password = "TestPassword123" });
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Login_rejects_a_wrong_password()
+ {
+ var client = factory.CreateClient();
+ await client.PostAsJsonAsync("/api/auth/register", Registration("login-bad"));
+
+ var response = await client.PostAsJsonAsync(
+ "/api/auth/login", new { userName = "login-bad", password = "WrongPassword123" });
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Login_does_not_reveal_whether_a_username_exists()
+ {
+ var client = factory.CreateClient();
+ await client.PostAsJsonAsync("/api/auth/register", Registration("enum-real"));
+
+ var wrongPassword = await client.PostAsJsonAsync(
+ "/api/auth/login", new { userName = "enum-real", password = "WrongPassword123" });
+ var noSuchUser = await client.PostAsJsonAsync(
+ "/api/auth/login", new { userName = "enum-absent", password = "WrongPassword123" });
+
+ // Identical status and body, so the endpoint cannot be used to harvest
+ // valid usernames.
+ Assert.Equal(noSuchUser.StatusCode, wrongPassword.StatusCode);
+ Assert.Equal(
+ await noSuchUser.Content.ReadAsStringAsync(),
+ await wrongPassword.Content.ReadAsStringAsync());
+ }
+
+ [Fact]
+ 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"));
+
+ var taken = await client.GetFromJsonAsync(
+ "/api/auth/available?userName=taken-name");
+ var free = await client.GetFromJsonAsync(
+ "/api/auth/available?userName=definitely-free-name");
+
+ Assert.False(taken!.Available);
+ Assert.True(free!.Available);
+
+ // The response carries a boolean and nothing else — the old API answered
+ // this by returning the whole users row to anonymous callers.
+ var raw = await client.GetStringAsync("/api/auth/available?userName=taken-name");
+ Assert.DoesNotContain("email", raw, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("@example.test", raw, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Me_requires_authentication()
+ {
+ var client = factory.CreateClient();
+
+ var response = await client.GetAsync("/api/auth/me");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Me_returns_the_signed_in_user()
+ {
+ var client = await factory.CreateUserClientAsync("me-user");
+
+ var user = await client.GetFromJsonAsync("/api/auth/me");
+
+ Assert.Equal("me-user", user!.UserName);
+ }
+
+ [Fact]
+ public async Task A_token_signed_with_the_wrong_key_is_rejected()
+ {
+ var client = factory.CreateClient();
+ client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", ForgedToken());
+
+ var response = await client.GetAsync("/api/games");
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ /// A structurally valid token signed with a key the API does not trust.
+ private static string ForgedToken()
+ {
+ var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();
+ var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
+ System.Text.Encoding.UTF8.GetBytes("an-attacker-controlled-key-32-chars-min"));
+
+ var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(
+ issuer: "LudosData",
+ audience: "LudosData",
+ claims: [new System.Security.Claims.Claim(
+ System.Security.Claims.ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())],
+ expires: DateTime.UtcNow.AddHours(1),
+ signingCredentials: new Microsoft.IdentityModel.Tokens.SigningCredentials(
+ key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256));
+
+ return handler.WriteToken(token);
+ }
+
+ private record AvailabilityPayload(bool Available);
+}
diff --git a/backend/tests/LudosData.Api.Tests/GamesTests.cs b/backend/tests/LudosData.Api.Tests/GamesTests.cs
new file mode 100644
index 0000000..280d9f9
--- /dev/null
+++ b/backend/tests/LudosData.Api.Tests/GamesTests.cs
@@ -0,0 +1,258 @@
+using System.Net;
+using System.Net.Http.Json;
+
+namespace LudosData.Api.Tests;
+
+public class GamesTests(LudosApiFactory factory) : IClassFixture
+{
+ 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.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));
+ }
+
+ [Fact]
+ public async Task List_paginates_and_reports_totals()
+ {
+ var client = await factory.CreateUserClientAsync("page-user");
+ await SeedLibraryAsync(client);
+
+ var page = await client.GetFromJsonAsync("/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.GetFromJsonAsync("/api/games?search=metroid");
+ var byDeveloper = await client.GetFromJsonAsync("/api/games?search=Square");
+ var byPublisher = await client.GetFromJsonAsync("/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.GetFromJsonAsync("/api/games?system=N64");
+ var adventure = await client.GetFromJsonAsync("/api/games?genre=adventure");
+ var finished = await client.GetFromJsonAsync("/api/games?finished=true");
+ var unplayed = await client.GetFromJsonAsync("/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.GetFromJsonAsync("/api/games?sort=title&dir=asc");
+ var descending = await client.GetFromJsonAsync("/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.ReadFromJsonAsync();
+ Assert.Equal("Banjo-Kazooie", page!.Items.First().Title);
+
+ // And the table is still there.
+ var after = await client.GetFromJsonAsync("/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.PostAsJsonAsync("/api/games", new { title = "", system = "SNES" });
+ var whitespace = await client.PostAsJsonAsync("/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.PostAsJsonAsync("/api/games", Game("Before")))
+ .Content.ReadFromJsonAsync();
+
+ await Task.Delay(15); // the stamp has sub-second resolution, but not zero
+ var updated = await (await client.PutAsJsonAsync(
+ $"/api/games/{created!.Id}", Game("After", finished: true)))
+ .Content.ReadFromJsonAsync();
+
+ 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.PostAsJsonAsync("/api/games", Game("Doomed")))
+ .Content.ReadFromJsonAsync();
+
+ 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.PostAsJsonAsync("/api/games", new
+ {
+ title = " Trimmed ",
+ system = (string?)null,
+ genre = (string?)null,
+ own = true,
+ })).Content.ReadFromJsonAsync();
+
+ 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.ReadFromJsonAsync();
+
+ 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.ReadFromJsonAsync();
+
+ var bobUser = await bob.GetFromJsonAsync("/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 Items, int Page, int PageSize, int Total, int TotalPages);
+ private record UploadPayload(string FileName, string Url);
+}
diff --git a/backend/tests/LudosData.Api.Tests/LudosApiFactory.cs b/backend/tests/LudosData.Api.Tests/LudosApiFactory.cs
new file mode 100644
index 0000000..9617c53
--- /dev/null
+++ b/backend/tests/LudosData.Api.Tests/LudosApiFactory.cs
@@ -0,0 +1,79 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Hosting;
+
+namespace LudosData.Api.Tests;
+
+///
+/// Boots the real application against a throwaway SQLite file and upload folder.
+///
+/// Every collaborator the API uses in production is exercised here — the same
+/// pipeline, the same Identity configuration, the same JWT validation. Only the
+/// storage locations and the signing key are swapped, so a test that passes says
+/// something about the shipped application rather than about a stand-in.
+///
+public class LudosApiFactory : WebApplicationFactory, IAsyncLifetime
+{
+ private readonly string _root = Path.Combine(
+ Path.GetTempPath(), "ludos-tests", Guid.NewGuid().ToString("N"));
+
+ public string SigningKey { get; } = "test-signing-key-of-at-least-32-characters-long";
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ Directory.CreateDirectory(_root);
+
+ builder.UseEnvironment(Environments.Development);
+ builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(_root, "test.db")}");
+ builder.UseSetting("Uploads:RootPath", Path.Combine(_root, "uploads"));
+ builder.UseSetting("DataProtection:KeysPath", Path.Combine(_root, "keys"));
+ builder.UseSetting("Jwt:Key", SigningKey);
+ builder.UseSetting("Jwt:Issuer", "LudosData");
+ builder.UseSetting("Jwt:Audience", "LudosData");
+ // The seeder would otherwise create a user and import 105 games, which
+ // would make "does this user see only their own rows" untestable.
+ builder.UseSetting("Seed:Enabled", "false");
+ }
+
+ /// Registers a fresh user and returns a client authenticated as them.
+ public async Task CreateUserClientAsync(string userName)
+ {
+ var client = CreateClient();
+ var response = await client.PostAsJsonAsync("/api/auth/register", new
+ {
+ userName,
+ email = $"{userName}@example.test",
+ password = "TestPassword123",
+ });
+
+ response.EnsureSuccessStatusCode();
+ var auth = await response.Content.ReadFromJsonAsync();
+
+ client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", auth!.Token);
+ return client;
+ }
+
+ public Task InitializeAsync() => Task.CompletedTask;
+
+ public new async Task DisposeAsync()
+ {
+ await base.DisposeAsync();
+ try
+ {
+ if (Directory.Exists(_root))
+ {
+ Directory.Delete(_root, recursive: true);
+ }
+ }
+ catch (IOException)
+ {
+ // A locked SQLite handle on a temp file is not worth failing a run over.
+ }
+ }
+
+ public record AuthPayload(string Token, DateTimeOffset ExpiresAt, UserPayload User);
+ public record UserPayload(string Id, string UserName, string? Email);
+}
diff --git a/backend/tests/LudosData.Api.Tests/LudosData.Api.Tests.csproj b/backend/tests/LudosData.Api.Tests/LudosData.Api.Tests.csproj
new file mode 100644
index 0000000..318aebf
--- /dev/null
+++ b/backend/tests/LudosData.Api.Tests/LudosData.Api.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/tests/LudosData.Api.Tests/OwnershipTests.cs b/backend/tests/LudosData.Api.Tests/OwnershipTests.cs
new file mode 100644
index 0000000..e5da53b
--- /dev/null
+++ b/backend/tests/LudosData.Api.Tests/OwnershipTests.cs
@@ -0,0 +1,160 @@
+using System.Net;
+using System.Net.Http.Json;
+
+namespace LudosData.Api.Tests;
+
+///
+/// The rules that matter most.
+///
+/// The API this replaced took the owner from a client-supplied query parameter
+/// (filter[]=userId,eq,N), 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.
+///
+public class OwnershipTests(LudosApiFactory factory) : IClassFixture
+{
+ 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 CreateGameAsync(HttpClient client, string title)
+ {
+ var response = await client.PostAsJsonAsync("/api/games", Game(title));
+ response.EnsureSuccessStatusCode();
+ var created = await response.Content.ReadFromJsonAsync();
+ 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.GetFromJsonAsync("/api/games");
+ var bobPage = await bob.GetFromJsonAsync("/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.PutAsJsonAsync($"/api/games/{aliceGame}", Game("Hijacked"));
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+
+ var after = await alice.GetFromJsonAsync($"/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.GetFromJsonAsync("/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
+ {
+ title = "Attempted Handover",
+ system = "SNES",
+ own = true,
+ ownerId = bobUser!.Id,
+ userId = bobUser.Id,
+ });
+ response.EnsureSuccessStatusCode();
+
+ var alicePage = await alice.GetFromJsonAsync("/api/games");
+ var bobPage = await bob.GetFromJsonAsync("/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.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 });
+
+ var facets = await alice.GetFromJsonAsync("/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.PostAsJsonAsync("/api/games", Game("x"))).StatusCode);
+ Assert.Equal(HttpStatusCode.Unauthorized,
+ (await anonymous.PutAsJsonAsync("/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 Items, int Page, int PageSize, int Total, int TotalPages);
+ private record FacetsPayload(List Systems, List Genres);
+}
diff --git a/backend/tests/LudosData.Api.Tests/TestImages.cs b/backend/tests/LudosData.Api.Tests/TestImages.cs
new file mode 100644
index 0000000..2308a35
--- /dev/null
+++ b/backend/tests/LudosData.Api.Tests/TestImages.cs
@@ -0,0 +1,90 @@
+using System.IO.Compression;
+
+namespace LudosData.Api.Tests;
+
+///
+/// Builds a real PNG in memory, so upload tests exercise the actual decode path
+/// rather than a fixture file that could drift or go missing.
+///
+public static class TestImages
+{
+ public static byte[] Png(int width, int height)
+ {
+ // One filter byte per scanline, then RGB triples.
+ var raw = new byte[height * (1 + width * 3)];
+ var offset = 0;
+ for (var y = 0; y < height; y++)
+ {
+ raw[offset++] = 0; // filter: none
+ for (var x = 0; x < width; x++)
+ {
+ raw[offset++] = (byte)(x * 8 % 256);
+ raw[offset++] = (byte)(y * 8 % 256);
+ raw[offset++] = 128;
+ }
+ }
+
+ using var output = new MemoryStream();
+ output.Write([0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A]);
+
+ var header = new byte[13];
+ WriteBigEndian(header, 0, width);
+ WriteBigEndian(header, 4, height);
+ header[8] = 8; // bit depth
+ header[9] = 2; // colour type: truecolour
+ WriteChunk(output, "IHDR", header);
+
+ WriteChunk(output, "IDAT", ZlibCompress(raw));
+ WriteChunk(output, "IEND", []);
+
+ return output.ToArray();
+ }
+
+ private static void WriteBigEndian(byte[] buffer, int index, int value)
+ {
+ buffer[index] = (byte)(value >> 24);
+ buffer[index + 1] = (byte)(value >> 16);
+ buffer[index + 2] = (byte)(value >> 8);
+ buffer[index + 3] = (byte)value;
+ }
+
+ private static void WriteChunk(Stream stream, string type, byte[] data)
+ {
+ var length = new byte[4];
+ WriteBigEndian(length, 0, data.Length);
+ stream.Write(length);
+
+ var typeBytes = System.Text.Encoding.ASCII.GetBytes(type);
+ stream.Write(typeBytes);
+ stream.Write(data);
+
+ var crc = Crc32([.. typeBytes, .. data]);
+ var crcBytes = new byte[4];
+ WriteBigEndian(crcBytes, 0, unchecked((int)crc));
+ stream.Write(crcBytes);
+ }
+
+ private static byte[] ZlibCompress(byte[] data)
+ {
+ using var output = new MemoryStream();
+ using (var deflate = new ZLibStream(output, CompressionLevel.Fastest, leaveOpen: true))
+ {
+ deflate.Write(data);
+ }
+ return output.ToArray();
+ }
+
+ private static uint Crc32(byte[] data)
+ {
+ var crc = 0xFFFFFFFFu;
+ foreach (var b in data)
+ {
+ crc ^= b;
+ for (var i = 0; i < 8; i++)
+ {
+ crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320u : crc >> 1;
+ }
+ }
+ return crc ^ 0xFFFFFFFFu;
+ }
+}