using System.Net; using System.Net.Http.Json; using System.Text; using LudosData.Api.Contracts; namespace LudosData.Api.Tests; public class LibraryTests(LudosApiFactory factory) : IClassFixture { private static async Task SeedAsync(HttpClient client) { await client.PostAsJsonAsync("/api/games", new { title = "Chrono Trigger", system = "SNES", genre = "rpg", year = "1995", developer = "Square", own = true, played = true, finished = true, }); await client.PostAsJsonAsync("/api/games", new { title = "Ico", system = "PS2", genre = "adventure", year = "2001", own = true, }); } private static MultipartFormDataContent FileContent(string body, string name, string mediaType) { var content = new MultipartFormDataContent(); var part = new ByteArrayContent(Encoding.UTF8.GetBytes(body)); part.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(mediaType); content.Add(part, "file", name); return content; } // ---- export ---------------------------------------------------------- [Fact] public async Task Json_export_contains_the_library() { var client = await factory.CreateUserClientAsync("exp-json"); await SeedAsync(client); var response = await client.GetAsync("/api/library/export?format=json"); response.EnsureSuccessStatusCode(); Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); Assert.Contains("attachment", response.Content.Headers.ContentDisposition?.DispositionType ?? response.Content.Headers.ContentDisposition?.ToString() ?? "attachment"); var payload = await response.Content.ReadFromJsonAsync(); Assert.Equal(2, payload!.Count); Assert.Contains(payload.Games, g => g.Title == "Chrono Trigger" && g.Developer == "Square"); } [Fact] public async Task Csv_export_has_a_header_and_one_row_per_game() { var client = await factory.CreateUserClientAsync("exp-csv"); await SeedAsync(client); var csv = await client.GetStringAsync("/api/library/export?format=csv"); var rows = LudosData.Api.Services.Csv.Parse(csv.TrimStart('')); Assert.Equal("title", rows[0][0]); Assert.Equal(3, rows.Count); // header + 2 games Assert.Contains(rows.Skip(1), r => r[0] == "Chrono Trigger"); } [Fact] public async Task Export_only_covers_the_signed_in_users_games() { var alice = await factory.CreateUserClientAsync("exp-alice"); var bob = await factory.CreateUserClientAsync("exp-bob"); await SeedAsync(alice); await bob.PostAsJsonAsync("/api/games", new { title = "Bob Only", system = "N64", own = true }); var payload = await bob.GetFromJsonAsync("/api/library/export?format=json"); Assert.Equal(1, payload!.Count); Assert.Equal("Bob Only", payload.Games[0].Title); } [Fact] public async Task Export_rejects_an_unknown_format() { var client = await factory.CreateUserClientAsync("exp-bad"); var response = await client.GetAsync("/api/library/export?format=xml"); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task Export_and_import_round_trip_without_loss() { var source = await factory.CreateUserClientAsync("round-source"); await SeedAsync(source); var exported = await source.GetStringAsync("/api/library/export?format=json"); var target = await factory.CreateUserClientAsync("round-target"); var response = await target.PostAsync("/api/library/import", FileContent(exported, "library.json", "application/json")); response.EnsureSuccessStatusCode(); var before = await source.GetFromJsonAsync("/api/library/export?format=json"); var after = await target.GetFromJsonAsync("/api/library/export?format=json"); Assert.Equal(before!.Count, after!.Count); Assert.Equal( before.Games.OrderBy(g => g.Title).Select(g => (g.Title, g.System, g.Developer, g.Finished)), after.Games.OrderBy(g => g.Title).Select(g => (g.Title, g.System, g.Developer, g.Finished))); } // ---- import ---------------------------------------------------------- [Fact] public async Task Import_creates_missing_games_and_updates_matching_ones() { var client = await factory.CreateUserClientAsync("imp-merge"); await SeedAsync(client); const string csv = """ title,system,genre,year,own,finished Chrono Trigger,SNES,rpg,1995,true,false Super Metroid,SNES,platformer,1994,true,true """; var result = await (await client.PostAsync("/api/library/import", FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync(); Assert.Equal(1, result!.Created); // Super Metroid Assert.Equal(1, result.Updated); // Chrono Trigger matched on title + system Assert.Equal(0, result.Deleted); // The update took effect: it was finished before, and the file says otherwise. var page = await client.GetFromJsonAsync("/api/games?search=Chrono"); Assert.False(page!.Items[0].Finished); } [Fact] public async Task The_same_title_on_a_different_system_is_a_different_game() { var client = await factory.CreateUserClientAsync("imp-platform"); await client.PostAsJsonAsync("/api/games", new { title = "Donkey Kong Country", system = "SNES", own = true, }); const string csv = """ title,system,own Donkey Kong Country,SNES,true Donkey Kong Country,GB,true Donkey Kong Country,GBA,true """; var result = await (await client.PostAsync("/api/library/import", FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync(); Assert.Equal(2, result!.Created); // GB and GBA Assert.Equal(1, result.Updated); // the existing SNES row } [Fact] public async Task Dry_run_reports_what_would_happen_and_changes_nothing() { var client = await factory.CreateUserClientAsync("imp-dry"); await SeedAsync(client); const string csv = """ title,system,own Brand New Game,N64,true """; var result = await (await client.PostAsync("/api/library/import?dryRun=true", FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync(); Assert.True(result!.DryRun); Assert.Equal(1, result.Created); var page = await client.GetFromJsonAsync("/api/games"); Assert.Equal(2, page!.Total); // still just the seeded pair } [Fact] public async Task Replace_mode_clears_the_library_first() { var client = await factory.CreateUserClientAsync("imp-replace"); await SeedAsync(client); const string csv = """ title,system,own Only Survivor,GC,true """; var result = await (await client.PostAsync("/api/library/import?mode=Replace", FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync(); Assert.Equal(2, result!.Deleted); Assert.Equal(1, result.Created); var page = await client.GetFromJsonAsync("/api/games"); Assert.Equal(1, page!.Total); Assert.Equal("Only Survivor", page.Items[0].Title); } [Fact] public async Task Import_never_touches_another_users_library() { var alice = await factory.CreateUserClientAsync("imp-alice"); var bob = await factory.CreateUserClientAsync("imp-bob"); await SeedAsync(alice); await SeedAsync(bob); // Replace is the most destructive mode; it must stop at the caller. await bob.PostAsync("/api/library/import?mode=Replace", FileContent("title,system,own\nBob Only,GC,true", "in.csv", "text/csv")); var alicePage = await alice.GetFromJsonAsync("/api/games"); Assert.Equal(2, alicePage!.Total); } [Fact] public async Task Rows_without_a_title_are_reported_rather_than_imported() { var client = await factory.CreateUserClientAsync("imp-invalid"); const string csv = """ title,system,own ,SNES,true Valid Game,SNES,true """; var result = await (await client.PostAsync("/api/library/import", FileContent(csv, "in.csv", "text/csv"))).Content.ReadFromJsonAsync(); Assert.Equal(1, result!.Created); Assert.Single(result.Errors); Assert.Equal(2, result.Errors[0].Row); // header is row 1 } [Fact] public async Task Csv_survives_commas_quotes_and_newlines_in_a_description() { var client = await factory.CreateUserClientAsync("imp-quoting"); var awkward = "A description with, a comma, \"quotes\" and\na newline."; await client.PostAsJsonAsync("/api/games", new { title = "Awkward, Game \"Title\"", system = "PS1", description = awkward, own = true, }); // Round-trip through CSV rather than asserting on the encoding itself. var csv = await client.GetStringAsync("/api/library/export?format=csv"); var target = await factory.CreateUserClientAsync("imp-quoting-target"); await target.PostAsync("/api/library/import", FileContent(csv, "in.csv", "text/csv")); var payload = await target.GetFromJsonAsync("/api/library/export?format=json"); var game = Assert.Single(payload!.Games); Assert.Equal("Awkward, Game \"Title\"", game.Title); Assert.Equal(awkward, game.Description); } [Fact] public async Task A_bare_json_array_is_accepted_as_well_as_the_envelope() { var client = await factory.CreateUserClientAsync("imp-bare"); const string json = """ [ { "title": "Bare Array Game", "system": "N64", "own": true } ] """; var result = await (await client.PostAsync("/api/library/import", FileContent(json, "in.json", "application/json"))).Content.ReadFromJsonAsync(); Assert.Equal(1, result!.Created); } [Fact] public async Task Malformed_files_are_rejected_with_a_reason() { var client = await factory.CreateUserClientAsync("imp-malformed"); var badJson = await client.PostAsync("/api/library/import", FileContent("{ not valid json", "in.json", "application/json")); var headerless = await client.PostAsync("/api/library/import", FileContent("name,platform\nFoo,SNES", "in.csv", "text/csv")); var empty = await client.PostAsync("/api/library/import", FileContent("", "in.csv", "text/csv")); Assert.Equal(HttpStatusCode.BadRequest, badJson.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, headerless.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); } [Fact] public async Task Export_and_import_require_a_token() { var anonymous = factory.CreateClient(); Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/library/export")).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.PostAsync("/api/library/import", FileContent("title\nX", "in.csv", "text/csv"))).StatusCode); } private record GamePayload(int Id, string Title, bool Finished); private record PagePayload(List Items, int Total); }