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.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("/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("/api/games?search=metroid"); var byDeveloper = await client.GetJsonAsync("/api/games?search=Square"); var byPublisher = await client.GetJsonAsync("/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("/api/games?system=N64"); var adventure = await client.GetJsonAsync("/api/games?genre=adventure"); var finished = await client.GetJsonAsync("/api/games?finished=true"); var unplayed = await client.GetJsonAsync("/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("/api/games?sort=title&dir=asc"); var descending = await client.GetJsonAsync("/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(); Assert.Equal("Banjo-Kazooie", page!.Items.First().Title); // And the table is still there. var after = await client.GetJsonAsync("/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(); 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(); 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(); 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(); 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(); 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(); var bobUser = await bob.GetJsonAsync("/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); }