Expand test coverage to match specs

This commit is contained in:
2026-02-05 18:57:25 +01:00
parent e11cb23313
commit 67a164e53b
14 changed files with 861 additions and 32 deletions

View File

@@ -3,6 +3,7 @@ using System.Net.Http.Json;
using System.Text.Json;
using GameList.Domain;
using GameList.Tests.Support;
using Microsoft.EntityFrameworkCore;
namespace GameList.Tests;
@@ -156,4 +157,171 @@ public class AdminTests
Assert.Single(db.AppState);
});
}
[Fact]
public async Task Admin_results_closing_moves_back_to_vote_and_clears_finalize()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("player");
var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
open.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var p = await db.Players.FirstAsync(x => !x.IsAdmin);
p.VotesFinal = true;
await db.SaveChangesAsync();
});
var beforeState = await factory.WithDbContextAsync(async db => await db.AppState.AsNoTracking().FirstAsync());
await Task.Delay(5);
var close = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = false });
close.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var p = await db.Players.FirstAsync(x => !x.IsAdmin);
Assert.Equal(Phase.Vote, p.CurrentPhase);
Assert.False(p.VotesFinal);
var state = await db.AppState.AsNoTracking().FirstAsync();
Assert.False(state.ResultsOpen);
Assert.True(state.UpdatedAt > beforeState.UpdatedAt);
});
}
[Fact]
public async Task Vote_status_lists_waiting_players()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
var p1 = factory.CreateClientWithCookies();
await p1.RegisterAsync("alice");
var p2 = factory.CreateClientWithCookies();
await p2.RegisterAsync("bob");
var s = await p1.CreateSuggestionAsync("Game");
await p1.PostAsJsonAsync("/api/me/phase/next", new { });
await p2.PostAsJsonAsync("/api/me/phase/next", new { });
await p1.PostAsJsonAsync("/api/votes", new { SuggestionId = s, Score = 5 });
await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
var status = await admin.GetFromJsonAsync<JsonElement>("/api/admin/vote-status");
Assert.False(status.GetProperty("ready").GetBoolean());
var waiting = status.GetProperty("waiting").EnumerateArray().Select(e => e.GetString()).ToList();
Assert.Contains("bob-name", waiting);
}
[Fact]
public async Task Grant_joker_in_vote_sets_flag_and_unfinalizes()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var p = factory.CreateClientWithCookies();
await p.RegisterAsync("player");
await p.PostAsJsonAsync("/api/me/phase/next", new { });
await p.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
var give = await admin.PostAsJsonAsync("/api/admin/joker", new { playerId = (await p.GetProfileIdAsync()) });
give.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var player = await db.Players.SingleAsync(x => x.Username == "player");
Assert.True(player.HasJoker);
Assert.False(player.VotesFinal);
});
}
[Fact]
public async Task Link_requires_vote_phase_and_reparents_votes_reset()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("linker");
var a = await player.CreateSuggestionAsync("A");
var b = await player.CreateSuggestionAsync("B");
var beforeVotePhase = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b });
Assert.Equal(HttpStatusCode.BadRequest, beforeVotePhase.StatusCode);
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
await player.PostAsJsonAsync("/api/me/phase/next", new { });
await player.PostAsJsonAsync("/api/votes", new { SuggestionId = a, Score = 3 });
await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
var link = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b });
link.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var votes = await db.Votes.ToListAsync();
Assert.Empty(votes);
var p = await db.Players.SingleAsync(x => x.Username == "linker");
Assert.False(p.VotesFinal);
});
}
[Fact]
public async Task Unlink_not_found_returns_empty_payload()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
var resp = await admin.PostAsJsonAsync("/api/admin/unlink-suggestions", new { suggestionId = 9999 });
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(0, json.GetProperty("unlinkedSuggestionIds").GetArrayLength());
}
[Fact]
public async Task Reset_clears_flags_and_factory_reset_seeds_defaults()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var p = factory.CreateClientWithCookies();
await p.RegisterAsync("flags");
await factory.WithDbContextAsync(async db =>
{
var player = await db.Players.SingleAsync(x => x.Username == "flags");
player.HasJoker = true;
player.VotesFinal = true;
await db.SaveChangesAsync();
});
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { });
reset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var player = await db.Players.SingleAsync(x => x.Username == "flags");
Assert.False(player.HasJoker);
Assert.False(player.VotesFinal);
var state = await db.AppState.AsNoTracking().FirstAsync();
Assert.False(state.ResultsOpen);
});
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { });
factoryReset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var state = await db.AppState.AsNoTracking().FirstAsync();
Assert.False(state.ResultsOpen);
});
}
}

View File

@@ -1,12 +1,95 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GameList.Data;
using GameList.Infrastructure;
using GameList.Tests.Support;
using Microsoft.EntityFrameworkCore;
namespace GameList.Tests;
public class AuthTests
{
[Fact]
public async Task Register_trims_limits_and_sets_cookie_and_normalized_username()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
var response = await client.PostAsJsonAsync("/api/auth/register", new
{
Username = " MixedCaseUser ",
Password = "Pass123!",
DisplayName = " Display Name ",
AdminKey = (string?)null
});
response.EnsureSuccessStatusCode();
Assert.True(response.Headers.TryGetValues("Set-Cookie", out var cookies) &&
cookies.Any(c => c.Contains(PlayerIdentityExtensions.PlayerCookieName)));
await factory.WithDbContextAsync(async db =>
{
var player = await db.Players.AsNoTracking().SingleAsync(p => p.Username == "MixedCaseUser");
Assert.Equal("mixedcaseuser", player.NormalizedUsername);
Assert.True(player.DisplayName!.Length <= 16);
Assert.NotEqual(Array.Empty<byte>(), player.PasswordHash);
Assert.NotEqual(Array.Empty<byte>(), player.PasswordSalt);
});
}
[Fact]
public async Task Register_rejects_overlength_username_or_display_name()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
var tooLongUser = new string('u', 25);
var userResp = await client.PostAsJsonAsync("/api/auth/register", new
{
Username = tooLongUser,
Password = "Pass123!",
DisplayName = "short"
});
Assert.Equal(HttpStatusCode.BadRequest, userResp.StatusCode);
var longDisplay = new string('d', 17);
var displayResp = await client.PostAsJsonAsync("/api/auth/register", new
{
Username = "okuser",
Password = "Pass123!",
DisplayName = longDisplay
});
Assert.Equal(HttpStatusCode.BadRequest, displayResp.StatusCode);
}
[Fact]
public async Task Login_sets_last_login_and_fills_missing_display_name()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("loginfill");
await factory.WithDbContextAsync(async db =>
{
var player = await db.Players.FirstAsync();
player.DisplayName = null;
player.LastLoginAt = DateTimeOffset.UnixEpoch;
await db.SaveChangesAsync();
});
var login = await client.LoginAsync("loginfill", "Pass123!");
login.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var player = await db.Players.AsNoTracking().SingleAsync();
Assert.NotEqual(DateTimeOffset.UnixEpoch, player.LastLoginAt);
Assert.Equal("loginfill", player.DisplayName);
});
}
[Fact]
public async Task Register_with_admin_key_sets_admin_flag()
{
@@ -60,6 +143,28 @@ public class AuthTests
Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode);
}
[Fact]
public async Task Non_admin_cannot_access_admin_routes()
{
using var factory = new TestWebApplicationFactory();
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("regular");
var resp = await player.GetAsync("/api/admin/vote-status");
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
}
[Fact]
public async Task Admin_can_access_admin_routes()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("adminuser", admin: true);
var resp = await admin.GetAsync("/api/admin/vote-status");
resp.EnsureSuccessStatusCode();
}
[Fact]
public async Task Logout_clears_cookie()
{

View File

@@ -1,13 +1,18 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using GameList.Infrastructure;
using GameList.Endpoints;
using GameList.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Text.Json;
using System.Net.Http.Json;
namespace GameList.Tests;
@@ -62,6 +67,74 @@ public class HelperTests
Assert.False(await EndpointHelpers.IsReachableImageAsync("http://127.0.0.1/img.png", new StubHttpClientFactory(new StubHttpMessageHandler())));
}
[Fact]
public async Task IsReachableImageAsync_handles_head_success_redirect_and_size_guard()
{
var handler = new StubHttpMessageHandler();
handler.SetResponder(req =>
{
if (req.Method == HttpMethod.Head)
{
var resp = new HttpResponseMessage(HttpStatusCode.OK);
resp.Content = new ByteArrayContent(Array.Empty<byte>());
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
resp.Content.Headers.ContentLength = 100;
return resp;
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(Array.Empty<byte>())
{
Headers =
{
ContentType = new MediaTypeHeaderValue("image/png"),
ContentLength = 100
}
}
};
});
var ok = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler);
Assert.True(ok);
handler.SetResponder(_ =>
{
var resp = new HttpResponseMessage(HttpStatusCode.Redirect);
resp.Headers.Location = new Uri("http://example.com/other");
return resp;
});
var redirect = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler);
Assert.False(redirect);
handler.SetResponder(_ =>
{
var resp = new HttpResponseMessage(HttpStatusCode.OK);
resp.Content = new ByteArrayContent(new byte[10]);
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
resp.Content.Headers.ContentLength = 6 * 1024 * 1024; // over 5 MB
return resp;
});
var tooLarge = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler);
Assert.False(tooLarge);
}
[Fact]
public async Task IsReachableImageAsync_rejects_non_image_content()
{
var handler = new StubHttpMessageHandler();
handler.SetResponder(_ =>
{
var resp = new HttpResponseMessage(HttpStatusCode.OK);
resp.Content = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes("not image"));
resp.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
resp.Content.Headers.ContentLength = 9;
return resp;
});
var result = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler);
Assert.False(result);
}
[Fact]
public void Link_root_helpers_handle_groups()
{
@@ -98,6 +171,29 @@ public class HelperTests
Assert.Equal(3, root); // cycle breaks on revisit
}
[Fact]
public async Task Global_exception_handler_returns_json_error()
{
using var factory = new TestWebApplicationFactory().WithWebHostBuilder(builder =>
{
builder.Configure(app =>
{
app.UseGlobalExceptionLogging();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("/boom", _ => throw new InvalidOperationException("boom"));
});
});
});
var client = factory.CreateClient();
var resp = await client.GetAsync("/boom");
Assert.Equal(HttpStatusCode.InternalServerError, resp.StatusCode);
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Unexpected server error", json.GetProperty("error").GetString());
}
private class FakeEnv : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "";

View File

@@ -43,4 +43,50 @@ public class ResultsTests
var resp = await client.GetAsync("/api/results");
Assert.Equal(System.Net.HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Results_require_results_phase_and_auth()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("player");
await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
var anon = factory.CreateClient();
var unauthorized = await anon.GetAsync("/api/results");
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, unauthorized.StatusCode);
var ok = await player.GetAsync("/api/results");
Assert.Equal(System.Net.HttpStatusCode.OK, ok.StatusCode);
}
[Fact]
public async Task Results_payload_contains_fields_and_ordering()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("player");
var s1 = await player.CreateSuggestionAsync("High");
var s2 = await player.CreateSuggestionAsync("NoVotes");
await player.PostAsJsonAsync("/api/me/phase/next", new { });
await player.PostAsJsonAsync("/api/votes", new { SuggestionId = s1, Score = 9 });
await player.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
await player.PostAsJsonAsync("/api/me/phase/next", new { });
var results = await player.GetFromJsonAsync<List<JsonElement>>("/api/results");
Assert.NotNull(results);
Assert.Equal(2, results!.Count);
Assert.Equal("High", results[0].GetProperty("name").GetString());
Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble());
Assert.Equal(1, results[0].GetProperty("count").GetInt32());
Assert.Equal(0, results[1].GetProperty("average").GetDouble());
}
}

View File

@@ -11,6 +11,142 @@ namespace GameList.Tests;
public class StateTests
{
[Fact]
public async Task State_endpoint_returns_expected_payload_for_authenticated_user()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("payload");
await factory.WithDbContextAsync(async db =>
{
var player = await db.Players.FirstAsync();
player.HasJoker = true;
await db.SaveChangesAsync();
});
await client.CreateSuggestionAsync("One");
var state = await client.GetFromJsonAsync<JsonElement>("/api/state");
Assert.Equal(Phase.Suggest.ToString(), state.GetProperty("currentPhase").GetString());
Assert.False(state.GetProperty("votesFinal").GetBoolean());
Assert.True(state.GetProperty("hasJoker").GetBoolean());
Assert.True(state.GetProperty("players").GetInt32() >= 1);
Assert.True(state.GetProperty("suggestions").GetInt32() >= 1);
Assert.True(state.GetProperty("votes").GetInt32() >= 0);
}
[Fact]
public async Task GetPhase_upgrades_reveal_and_resets_when_results_close()
{
using var factory = new TestWebApplicationFactory();
Guid playerId = Guid.Empty;
await factory.WithDbContextAsync(async db =>
{
var player = new Player
{
Id = Guid.NewGuid(),
Username = "legacy",
NormalizedUsername = "legacy",
PasswordHash = new byte[] { 1 },
PasswordSalt = new byte[] { 1 },
DisplayName = "Legacy",
CurrentPhase = Phase.Reveal,
VotesFinal = true
};
playerId = player.Id;
db.Players.Add(player);
var state = await db.AppState.FirstAsync();
state.ResultsOpen = true;
await db.SaveChangesAsync();
});
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId);
Assert.Equal(Phase.Results, phase);
}
await factory.WithDbContextAsync(async db =>
{
var state = await db.AppState.FirstAsync();
state.ResultsOpen = false;
await db.SaveChangesAsync();
});
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var phase = await GameList.Endpoints.EndpointHelpers.GetPhase(db, playerId);
var player = await db.Players.FindAsync(playerId);
Assert.Equal(Phase.Vote, phase);
Assert.False(player!.VotesFinal);
}
}
[Fact]
public async Task Phase_next_advances_and_clears_votesfinal()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("advance");
await factory.WithDbContextAsync(async db =>
{
var player = await db.Players.FirstAsync();
player.VotesFinal = true;
await db.SaveChangesAsync();
});
var toVote = await client.PostAsJsonAsync("/api/me/phase/next", new { });
toVote.EnsureSuccessStatusCode();
var toResultsLocked = await client.PostAsJsonAsync("/api/me/phase/next", new { });
Assert.Equal(HttpStatusCode.BadRequest, toResultsLocked.StatusCode);
// unlock results and advance
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
var toResults = await client.PostAsJsonAsync("/api/me/phase/next", new { });
toResults.EnsureSuccessStatusCode();
var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
Assert.False(me.GetProperty("votesFinal").GetBoolean());
Assert.Equal(Phase.Results.ToString(), me.GetProperty("currentPhase").GetString());
}
[Fact]
public async Task Phase_prev_moves_back_and_clears_votesfinal()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote
await factory.WithDbContextAsync(async db =>
{
var player = await db.Players.FirstAsync();
player.VotesFinal = true;
await db.SaveChangesAsync();
});
var backToSuggest = await admin.PostAsJsonAsync("/api/me/phase/prev", new { });
backToSuggest.EnsureSuccessStatusCode();
var me = await admin.GetFromJsonAsync<JsonElement>("/api/me");
Assert.Equal(Phase.Suggest.ToString(), me.GetProperty("currentPhase").GetString());
Assert.False(me.GetProperty("votesFinal").GetBoolean());
}
[Fact]
public async Task Name_endpoint_rejects_over_16_chars()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("namelimit");
var resp = await client.PostAsJsonAsync("/api/me/name", new { name = new string('a', 17) });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Cannot_advance_to_results_when_locked()
{

View File

@@ -87,12 +87,16 @@ public class SuggestionTests
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();
});
@@ -114,6 +118,8 @@ public class SuggestionTests
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);
});
}
@@ -219,4 +225,173 @@ public class SuggestionTests
var mine = await client.GetFromJsonAsync<List<JsonElement>>("/api/suggestions/mine");
Assert.Equal("Second", mine![0].GetProperty("name").GetString());
}
[Fact]
public async Task Create_requires_suggest_phase_and_display_name()
{
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()
{
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()
{
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()
{
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<List<JsonElement>>("/api/suggestions/mine");
Assert.Single(mine!);
Assert.Equal("AliceGame", mine[0].GetProperty("name").GetString());
}
[Fact]
public async Task All_returns_link_metadata_and_ordering()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("owner");
var id1 = await client.CreateSuggestionAsync("Alpha");
await Task.Delay(10);
var id2 = await client.CreateSuggestionAsync("Beta");
await factory.WithDbContextAsync(async db =>
{
var beta = await db.Suggestions.FindAsync(id2);
beta!.ParentSuggestionId = id1;
await db.SaveChangesAsync();
});
await client.PostAsJsonAsync("/api/me/phase/next", new { }); // to Vote
var all = await client.GetFromJsonAsync<List<JsonElement>>("/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()
{
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));
});
}
}

View File

@@ -1,5 +1,6 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GameList.Tests.Support;
using Microsoft.EntityFrameworkCore;
@@ -42,6 +43,19 @@ public class VoteTests
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Negative_score_rejected()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("negative");
var id = await client.CreateSuggestionAsync("RangeGame2");
await client.PostAsJsonAsync("/api/me/phase/next", new { });
var resp = await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = -1 });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Invalid_suggestion_id_rejected()
{
@@ -85,6 +99,25 @@ public class VoteTests
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Finalize_toggle_allows_unfinalize()
{
using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("toggle");
var id = await client.CreateSuggestionAsync("Toggle");
await client.PostAsJsonAsync("/api/me/phase/next", new { });
await client.PostAsJsonAsync("/api/votes", new { SuggestionId = id, Score = 5 });
var finalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
finalize.EnsureSuccessStatusCode();
var unfinalize = await client.PostAsJsonAsync("/api/votes/finalize", new { Final = false });
unfinalize.EnsureSuccessStatusCode();
var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
Assert.False(me.GetProperty("votesFinal").GetBoolean());
}
[Fact]
public async Task Linked_votes_apply_to_all_linked_suggestions()
{
@@ -114,5 +147,48 @@ public class VoteTests
Assert.All(mine, v => Assert.Equal(9, v.Score));
}
[Fact]
public async Task Linked_votes_apply_across_chain()
{
using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("chain");
var a = await player.CreateSuggestionAsync("A");
var b = await player.CreateSuggestionAsync("B");
var c = await player.CreateSuggestionAsync("C");
await player.PostAsJsonAsync("/api/me/phase/next", new { });
await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = a, TargetSuggestionId = b });
await admin.PostAsJsonAsync("/api/admin/link-suggestions", new { SourceSuggestionId = b, TargetSuggestionId = c });
var vote = await player.PostAsJsonAsync("/api/votes", new { SuggestionId = c, Score = 6 });
vote.EnsureSuccessStatusCode();
var mine = await player.GetFromJsonAsync<List<VoteRecord>>("/api/votes/mine");
Assert.NotNull(mine);
Assert.Equal(3, mine!.Count);
Assert.All(mine, v => Assert.Equal(6, v.Score));
}
[Fact]
public async Task Votes_mine_requires_vote_phase_and_auth()
{
using var factory = new TestWebApplicationFactory();
var anon = factory.CreateClient();
var unauth = await anon.GetAsync("/api/votes/mine");
Assert.Equal(HttpStatusCode.Unauthorized, unauth.StatusCode);
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("phaseguard");
var resp = await client.GetAsync("/api/votes/mine");
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
private record VoteRecord(int SuggestionId, int Score);
}