using System.Net; using System.Net.Http.Json; using System.Text.Json; using GameList.Tests.Support; using Microsoft.EntityFrameworkCore; namespace GameList.Tests; public class SuggestionTests { [Fact] public async Task Player_cannot_exceed_five_suggestions() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("suggestor"); for (var i = 0; i < 5; i++) { var resp = await client.PostAsJsonAsync("/api/suggestions", new { Name = $"Game {i}", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); resp.EnsureSuccessStatusCode(); } var sixth = await client.PostAsJsonAsync("/api/suggestions", new { Name = "Overflow", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); Assert.Equal(HttpStatusCode.BadRequest, sixth.StatusCode); } [Fact] public async Task Rejects_invalid_image_extension_and_player_counts() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("validate"); var badExt = await client.PostAsJsonAsync("/api/suggestions", new { Name = "BadImg", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = "http://example.com/file.txt", YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); Assert.Equal(HttpStatusCode.BadRequest, badExt.StatusCode); var badPlayers = await client.PostAsJsonAsync("/api/suggestions", new { Name = "BadPlayers", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = 4, MaxPlayers = 2 }); Assert.Equal(HttpStatusCode.BadRequest, badPlayers.StatusCode); } [Fact] public async Task Joker_allows_single_extra_suggestion_and_unfinalizes_votes() { await using var factory = new TestWebApplicationFactory(); var player = factory.CreateClientWithCookies(); await player.RegisterAsync("joker"); var other = factory.CreateClientWithCookies(); await other.RegisterAsync("other"); await factory.WithDbContextAsync(async db => { var p = await db.Players.FirstAsync(); p.HasJoker = true; p.CurrentPhase = Domain.Phase.Vote; var o = await db.Players.SingleAsync(x => x.Username == "other"); o.VotesFinal = true; await db.SaveChangesAsync(); }); var suggestion = await player.PostAsJsonAsync("/api/suggestions", new { Name = "JokerGame", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); suggestion.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(async db => { var p = await db.Players.FirstAsync(); Assert.False(p.HasJoker); Assert.False(p.VotesFinal); var o = await db.Players.SingleAsync(x => x.Username == "other"); Assert.False(o.VotesFinal); }); } [Fact] public async Task Admin_can_update_during_vote_phase() { await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); var player = factory.CreateClientWithCookies(); await player.RegisterAsync("author"); var id = await player.CreateSuggestionAsync("OldName"); await player.PostAsJsonAsync("/api/me/phase/next", new { }); var update = await admin.PutAsJsonAsync($"/api/suggestions/{id}", new { Name = "NewName", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); update.EnsureSuccessStatusCode(); } [Fact] public async Task Phase_gate_blocks_player_update_in_vote_phase() { await using var factory = new TestWebApplicationFactory(); var player = factory.CreateClientWithCookies(); await player.RegisterAsync("phase"); var id = await player.CreateSuggestionAsync("Lock"); await player.PostAsJsonAsync("/api/me/phase/next", new { }); var update = await player.PutAsJsonAsync($"/api/suggestions/{id}", new { Name = "Blocked", Genre = "NewGenre", Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); update.EnsureSuccessStatusCode(); var loaded = await factory.WithDbContextAsync(async db => await db.Suggestions.FindAsync(id)); Assert.Equal("Lock", loaded!.Name); // title locked Assert.Equal("NewGenre", loaded.Genre); // other fields still editable in vote } [Fact] public async Task Player_cannot_edit_suggestion_in_results_phase() { await using var factory = new TestWebApplicationFactory(); var player = factory.CreateClientWithCookies(); await player.RegisterAsync("results"); var id = await player.CreateSuggestionAsync("Frozen"); // Move everyone to Results await factory.WithDbContextAsync(async db => { var state = await db.AppState.FirstAsync(); state.ResultsOpen = true; var p = await db.Players.FirstAsync(); p.CurrentPhase = Domain.Phase.Results; await db.SaveChangesAsync(); }); var update = await player.PutAsJsonAsync($"/api/suggestions/{id}", new { Name = "ShouldFail", Genre = "Nope", Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); Assert.Equal(HttpStatusCode.BadRequest, update.StatusCode); var loaded = await factory.WithDbContextAsync(async db => await db.Suggestions.FindAsync(id)); Assert.Equal("Frozen", loaded!.Name); Assert.Equal("Coop", loaded.Genre); } [Fact] public async Task Player_cannot_edit_other_players_suggestion() { await using var factory = new TestWebApplicationFactory(); var owner = factory.CreateClientWithCookies(); await owner.RegisterAsync("owner"); var other = factory.CreateClientWithCookies(); await other.RegisterAsync("intruder"); var id = await owner.CreateSuggestionAsync("Protected"); var update = await other.PutAsJsonAsync($"/api/suggestions/{id}", new { Name = "Hacked", Genre = "Bad", Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); Assert.Equal(HttpStatusCode.Unauthorized, update.StatusCode); } [Fact] public async Task Joker_allows_unlimited_extra_suggestions_when_granted_multiple_times() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("sixth"); // Seed 5 suggestions in Suggest phase for (var i = 0; i < 5; i++) { var resp = await client.PostAsJsonAsync("/api/suggestions", new { Name = $"Game{i}", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); resp.EnsureSuccessStatusCode(); } // Move to Vote and grant joker await client.PostAsJsonAsync("/api/me/phase/next", new { }); await factory.WithDbContextAsync(async db => { var p = await db.Players.FirstAsync(); p.HasJoker = true; await db.SaveChangesAsync(); }); var sixth = await client.PostAsJsonAsync("/api/suggestions", new { Name = "JokerExtra", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); sixth.EnsureSuccessStatusCode(); // Grant another joker and add a seventh suggestion await factory.WithDbContextAsync(async db => { var p = await db.Players.FirstAsync(); p.HasJoker = true; await db.SaveChangesAsync(); }); var seventh = await client.PostAsJsonAsync("/api/suggestions", new { Name = "TooMany", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); seventh.EnsureSuccessStatusCode(); // No joker left; further suggestions should be blocked var eighth = await client.PostAsJsonAsync("/api/suggestions", new { Name = "BlockedNow", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); Assert.Equal(HttpStatusCode.BadRequest, eighth.StatusCode); } [Fact] public async Task Unreachable_screenshot_url_is_rejected() { await using var factory = new TestWebApplicationFactory(); factory.HttpHandler.SetResponder(_ => new HttpResponseMessage(HttpStatusCode.BadRequest)); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("imgtester"); var response = await client.PostAsJsonAsync("/api/suggestions", new { Name = "Needs image", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = "http://example.com/image.png", YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task Get_all_requires_vote_phase() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("viewer"); var resp = await client.GetAsync("/api/suggestions/all"); Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); } [Fact] public async Task Mine_returns_ordered_list() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("mine"); var secondId = await client.CreateSuggestionAsync("Second"); var thirdId = await client.CreateSuggestionAsync("Third"); await factory.WithDbContextAsync(async db => { var second = await db.Suggestions.FindAsync(secondId); var third = await db.Suggestions.FindAsync(thirdId); second!.CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1); third!.CreatedAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); }); var mine = await client.GetFromJsonAsync>("/api/suggestions/mine"); Assert.Equal("Second", mine![0].GetProperty("name").GetString()); } [Fact] public async Task Create_requires_suggest_phase_and_display_name() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("phasegate"); await factory.WithDbContextAsync(async db => { var p = await db.Players.FirstAsync(); p.CurrentPhase = Domain.Phase.Vote; p.DisplayName = null; await db.SaveChangesAsync(); }); var badPhase = await client.PostAsJsonAsync("/api/suggestions", new { Name = "Nope", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); Assert.Equal(HttpStatusCode.BadRequest, badPhase.StatusCode); await factory.WithDbContextAsync(async db => { var p = await db.Players.FirstAsync(); p.CurrentPhase = Domain.Phase.Suggest; await db.SaveChangesAsync(); }); var noDisplay = await client.PostAsJsonAsync("/api/suggestions", new { Name = "NoDisplay", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); Assert.Equal(HttpStatusCode.BadRequest, noDisplay.StatusCode); } [Fact] public async Task Rejects_invalid_urls_name_length_and_player_counts() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("validate2"); var badGame = await client.PostAsJsonAsync("/api/suggestions", new { Name = "Bad", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = "ftp://bad", MinPlayers = (int?)null, MaxPlayers = (int?)null }); Assert.Equal(HttpStatusCode.BadRequest, badGame.StatusCode); var badYoutube = await client.PostAsJsonAsync("/api/suggestions", new { Name = "BadYt", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = "file://bad", GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); Assert.Equal(HttpStatusCode.BadRequest, badYoutube.StatusCode); var longName = await client.PostAsJsonAsync("/api/suggestions", new { Name = new string('x', 101), Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = (int?)null, MaxPlayers = (int?)null }); Assert.Equal(HttpStatusCode.BadRequest, longName.StatusCode); var minOnly = await client.PostAsJsonAsync("/api/suggestions", new { Name = "MinOnly", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = 2, MaxPlayers = (int?)null }); Assert.Equal(HttpStatusCode.BadRequest, minOnly.StatusCode); var maxTooHigh = await client.PostAsJsonAsync("/api/suggestions", new { Name = "MaxHigh", Genre = (string?)null, Description = (string?)null, ScreenshotUrl = (string?)null, YoutubeUrl = (string?)null, GameUrl = (string?)null, MinPlayers = 2, MaxPlayers = 40 }); Assert.Equal(HttpStatusCode.BadRequest, maxTooHigh.StatusCode); } [Fact] public async Task Trims_and_truncates_optional_fields() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("trim"); var longGenre = new string('g', 60); var longDesc = new string('d', 600); var resp = await client.PostAsJsonAsync("/api/suggestions", new { Name = "Trim", Genre = $" {longGenre} ", Description = $" {longDesc} ", ScreenshotUrl = "http://example.com/img.png", YoutubeUrl = "http://example.com/y", GameUrl = "http://example.com/g", MinPlayers = 1, MaxPlayers = 4 }); resp.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(async db => { var s = await db.Suggestions.AsNoTracking().FirstAsync(); Assert.Equal(50, s.Genre!.Length); Assert.Equal(500, s.Description!.Length); Assert.Equal("http://example.com/img.png", s.ScreenshotUrl); }); } [Fact] public async Task Mine_excludes_other_players() { await using var factory = new TestWebApplicationFactory(); var a = factory.CreateClientWithCookies(); await a.RegisterAsync("alice"); var b = factory.CreateClientWithCookies(); await b.RegisterAsync("bob"); await a.CreateSuggestionAsync("AliceGame"); await b.CreateSuggestionAsync("BobGame"); var mine = await a.GetFromJsonAsync>("/api/suggestions/mine"); Assert.NotNull(mine); Assert.Single(mine); Assert.Equal("AliceGame", mine[0].GetProperty("name").GetString()); } [Fact] public async Task All_returns_link_metadata_and_ordering() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("owner"); var id1 = await client.CreateSuggestionAsync("Alpha"); var id2 = await client.CreateSuggestionAsync("Beta"); await factory.WithDbContextAsync(async db => { var alpha = await db.Suggestions.FindAsync(id1); var beta = await db.Suggestions.FindAsync(id2); alpha!.CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1); beta!.CreatedAt = DateTimeOffset.UtcNow; beta!.ParentSuggestionId = id1; await db.SaveChangesAsync(); }); await client.PostAsJsonAsync("/api/me/phase/next", new { }); // to Vote var all = await client.GetFromJsonAsync>("/api/suggestions/all"); Assert.Equal(2, all!.Count); var first = all[0]; Assert.Equal("Alpha", first.GetProperty("name").GetString()); var second = all[1]; var linkedIds = second.GetProperty("linkedIds").EnumerateArray().Select(x => x.GetInt32()).ToList(); Assert.Contains(id1, linkedIds); var linkedTitles = second.GetProperty("linkedTitles").EnumerateArray().Select(x => x.GetString()).ToList(); Assert.Contains("Alpha", linkedTitles); } [Fact] public async Task Delete_respects_phase_and_clears_links_and_votes() { await using var factory = new TestWebApplicationFactory(); var owner = factory.CreateClientWithCookies(); await owner.RegisterAsync("deleter"); var other = factory.CreateClientWithCookies(); await other.RegisterAsync("voter"); var id = await owner.CreateSuggestionAsync("DeleteMe"); var child = await owner.CreateSuggestionAsync("Child"); await factory.WithDbContextAsync(async db => { var c = await db.Suggestions.FindAsync(child); c!.ParentSuggestionId = id; await db.SaveChangesAsync(); }); await owner.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote await other.PostAsJsonAsync("/api/me/phase/next", new { }); await other.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 5 }); var blocked = await owner.DeleteAsync($"/api/suggestions/{id}"); Assert.Equal(HttpStatusCode.BadRequest, blocked.StatusCode); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); var delete = await admin.DeleteAsync($"/api/suggestions/{id}"); delete.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(async db => { Assert.False(await db.Suggestions.AnyAsync(s => s.Id == id)); var childEntity = await db.Suggestions.FindAsync(child); Assert.Null(childEntity!.ParentSuggestionId); Assert.False(db.Votes.Any(v => v.SuggestionId == id)); }); } }