The only backup was the Docker volume. Export writes the caller's whole library as JSON or CSV; import reads either back, into the same account or a different one. Rows are matched on title + system rather than id, so a file is portable between accounts and instances, and the same game on three consoles stays three entries. Merge adds and updates but deletes nothing. Replace wipes first, and is gated behind an explicit confirm dialog in the UI. dryRun reports what would happen and writes nothing. CSV is hand-rolled rather than pulling a dependency, but handles the parts that actually bite: quoted fields containing commas, escaped quotes, embedded newlines and CRLF endings. That is not hypothetical here — 101 of the 105 descriptions contain newlines, and two titles contain accents, so a naive split-on-comma would corrupt most of the library. Exports carry a BOM so Excel reads them as UTF-8. Verified against the real library, not just fixtures: 105 games exported to CSV, imported into a scratch account and re-exported compare identical field for field. 15 new tests cover round-trip fidelity, merge vs replace, dry run, per-user isolation on the destructive path, malformed input, and the awkward-quoting case. 51 backend tests total. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
322 lines
12 KiB
C#
322 lines
12 KiB
C#
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<LudosApiFactory>
|
||
{
|
||
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<LibraryExport>();
|
||
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<LibraryExport>("/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<LibraryExport>("/api/library/export?format=json");
|
||
var after = await target.GetFromJsonAsync<LibraryExport>("/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<ImportResult>();
|
||
|
||
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<PagePayload>("/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<ImportResult>();
|
||
|
||
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<ImportResult>();
|
||
|
||
Assert.True(result!.DryRun);
|
||
Assert.Equal(1, result.Created);
|
||
|
||
var page = await client.GetFromJsonAsync<PagePayload>("/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<ImportResult>();
|
||
|
||
Assert.Equal(2, result!.Deleted);
|
||
Assert.Equal(1, result.Created);
|
||
|
||
var page = await client.GetFromJsonAsync<PagePayload>("/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<PagePayload>("/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<ImportResult>();
|
||
|
||
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<LibraryExport>("/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<ImportResult>();
|
||
|
||
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<GamePayload> Items, int Total);
|
||
}
|