Expand test coverage to match specs
This commit is contained in:
@@ -191,7 +191,7 @@ public static class AdminEndpoints
|
|||||||
var suggestions = await db.Suggestions.ToListAsync();
|
var suggestions = await db.Suggestions.ToListAsync();
|
||||||
var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId);
|
var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId);
|
||||||
if (target is null)
|
if (target is null)
|
||||||
return Results.NotFound(new { error = "Suggestion not found." });
|
return Results.Ok(new { UnlinkedSuggestionIds = Array.Empty<int>(), UnfinalizedPlayers = 0 });
|
||||||
|
|
||||||
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||||
if (!rootIndex.TryGetValue(target.Id, out var rootId))
|
if (!rootIndex.TryGetValue(target.Id, out var rootId))
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ public static class AuthEndpoints
|
|||||||
if (string.IsNullOrWhiteSpace(request.Password))
|
if (string.IsNullOrWhiteSpace(request.Password))
|
||||||
return Results.BadRequest(new { error = "Password is required." });
|
return Results.BadRequest(new { error = "Password is required." });
|
||||||
|
|
||||||
|
if (request.DisplayName?.Trim().Length > 16)
|
||||||
|
return Results.BadRequest(new { error = "Display name must be <= 16 characters." });
|
||||||
|
|
||||||
var displayName = EndpointHelpers.TrimTo(request.DisplayName, 16);
|
var displayName = EndpointHelpers.TrimTo(request.DisplayName, 16);
|
||||||
if (string.IsNullOrWhiteSpace(displayName))
|
if (string.IsNullOrWhiteSpace(displayName))
|
||||||
return Results.BadRequest(new { error = "Display name is required." });
|
return Results.BadRequest(new { error = "Display name is required." });
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ internal static class EndpointHelpers
|
|||||||
|| path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif");
|
|| path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, CancellationToken ct = default)
|
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(url)) return true;
|
if (string.IsNullOrWhiteSpace(url)) return true;
|
||||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
|
||||||
@@ -101,11 +101,9 @@ internal static class EndpointHelpers
|
|||||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
cts.CancelAfter(TimeSpan.FromSeconds(3));
|
cts.CancelAfter(TimeSpan.FromSeconds(3));
|
||||||
|
|
||||||
var handler = new HttpClientHandler
|
var client = handler is null
|
||||||
{
|
? httpFactory.CreateClient("imageValidation")
|
||||||
AllowAutoRedirect = false
|
: new HttpClient(handler, disposeHandler: false);
|
||||||
};
|
|
||||||
var client = new HttpClient(handler);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -113,10 +111,10 @@ internal static class EndpointHelpers
|
|||||||
var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||||
if (headResp.IsSuccessStatusCode && headResp.StatusCode is not System.Net.HttpStatusCode.Redirect)
|
if (headResp.IsSuccessStatusCode && headResp.StatusCode is not System.Net.HttpStatusCode.Redirect)
|
||||||
{
|
{
|
||||||
|
if (headResp.Content.Headers.ContentLength is long headLen && headLen > MaxImageBytes) return false;
|
||||||
var ctHeader = headResp.Content.Headers.ContentType?.MediaType;
|
var ctHeader = headResp.Content.Headers.ContentType?.MediaType;
|
||||||
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||||
return true;
|
return true;
|
||||||
if (headResp.Content.Headers.ContentLength is long len && len > MaxImageBytes) return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* fallback */ }
|
catch { /* fallback */ }
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ public static class StateEndpoints
|
|||||||
|
|
||||||
group.MapPost("/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) =>
|
group.MapPost("/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
|
if (request.Name?.Trim().Length > 16)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Name is required and must be <= 16 characters." });
|
||||||
|
}
|
||||||
|
|
||||||
var name = EndpointHelpers.TrimTo(request.Name, 16);
|
var name = EndpointHelpers.TrimTo(request.Name, 16);
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ public static class SuggestEndpoints
|
|||||||
if (player is null) return Results.Unauthorized();
|
if (player is null) return Results.Unauthorized();
|
||||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
||||||
|
|
||||||
|
if (!isAdmin)
|
||||||
|
{
|
||||||
|
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||||
|
if (phase != Phase.Suggest)
|
||||||
|
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||||
|
}
|
||||||
|
|
||||||
var suggestion = isAdmin
|
var suggestion = isAdmin
|
||||||
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id)
|
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id)
|
||||||
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
|
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using GameList.Domain;
|
using GameList.Domain;
|
||||||
using GameList.Tests.Support;
|
using GameList.Tests.Support;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace GameList.Tests;
|
namespace GameList.Tests;
|
||||||
|
|
||||||
@@ -156,4 +157,171 @@ public class AdminTests
|
|||||||
Assert.Single(db.AppState);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,95 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using GameList.Data;
|
||||||
|
using GameList.Infrastructure;
|
||||||
using GameList.Tests.Support;
|
using GameList.Tests.Support;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace GameList.Tests;
|
namespace GameList.Tests;
|
||||||
|
|
||||||
public class AuthTests
|
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]
|
[Fact]
|
||||||
public async Task Register_with_admin_key_sets_admin_flag()
|
public async Task Register_with_admin_key_sets_admin_flag()
|
||||||
{
|
{
|
||||||
@@ -60,6 +143,28 @@ public class AuthTests
|
|||||||
Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode);
|
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]
|
[Fact]
|
||||||
public async Task Logout_clears_cookie()
|
public async Task Logout_clears_cookie()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using GameList.Infrastructure;
|
using GameList.Infrastructure;
|
||||||
using GameList.Endpoints;
|
using GameList.Endpoints;
|
||||||
using GameList.Tests.Support;
|
using GameList.Tests.Support;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.FileProviders;
|
using Microsoft.Extensions.FileProviders;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
namespace GameList.Tests;
|
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())));
|
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]
|
[Fact]
|
||||||
public void Link_root_helpers_handle_groups()
|
public void Link_root_helpers_handle_groups()
|
||||||
{
|
{
|
||||||
@@ -98,6 +171,29 @@ public class HelperTests
|
|||||||
Assert.Equal(3, root); // cycle breaks on revisit
|
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
|
private class FakeEnv : IWebHostEnvironment
|
||||||
{
|
{
|
||||||
public string ApplicationName { get; set; } = "";
|
public string ApplicationName { get; set; } = "";
|
||||||
|
|||||||
@@ -43,4 +43,50 @@ public class ResultsTests
|
|||||||
var resp = await client.GetAsync("/api/results");
|
var resp = await client.GetAsync("/api/results");
|
||||||
Assert.Equal(System.Net.HttpStatusCode.BadRequest, resp.StatusCode);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,142 @@ namespace GameList.Tests;
|
|||||||
|
|
||||||
public class StateTests
|
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]
|
[Fact]
|
||||||
public async Task Cannot_advance_to_results_when_locked()
|
public async Task Cannot_advance_to_results_when_locked()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -87,12 +87,16 @@ public class SuggestionTests
|
|||||||
using var factory = new TestWebApplicationFactory();
|
using var factory = new TestWebApplicationFactory();
|
||||||
var player = factory.CreateClientWithCookies();
|
var player = factory.CreateClientWithCookies();
|
||||||
await player.RegisterAsync("joker");
|
await player.RegisterAsync("joker");
|
||||||
|
var other = factory.CreateClientWithCookies();
|
||||||
|
await other.RegisterAsync("other");
|
||||||
|
|
||||||
await factory.WithDbContextAsync(async db =>
|
await factory.WithDbContextAsync(async db =>
|
||||||
{
|
{
|
||||||
var p = await db.Players.FirstAsync();
|
var p = await db.Players.FirstAsync();
|
||||||
p.HasJoker = true;
|
p.HasJoker = true;
|
||||||
p.CurrentPhase = Domain.Phase.Vote;
|
p.CurrentPhase = Domain.Phase.Vote;
|
||||||
|
var o = await db.Players.SingleAsync(x => x.Username == "other");
|
||||||
|
o.VotesFinal = true;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,6 +118,8 @@ public class SuggestionTests
|
|||||||
var p = await db.Players.FirstAsync();
|
var p = await db.Players.FirstAsync();
|
||||||
Assert.False(p.HasJoker);
|
Assert.False(p.HasJoker);
|
||||||
Assert.False(p.VotesFinal);
|
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");
|
var mine = await client.GetFromJsonAsync<List<JsonElement>>("/api/suggestions/mine");
|
||||||
Assert.Equal("Second", mine![0].GetProperty("name").GetString());
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
using GameList.Tests.Support;
|
using GameList.Tests.Support;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -42,6 +43,19 @@ public class VoteTests
|
|||||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
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]
|
[Fact]
|
||||||
public async Task Invalid_suggestion_id_rejected()
|
public async Task Invalid_suggestion_id_rejected()
|
||||||
{
|
{
|
||||||
@@ -85,6 +99,25 @@ public class VoteTests
|
|||||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
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]
|
[Fact]
|
||||||
public async Task Linked_votes_apply_to_all_linked_suggestions()
|
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));
|
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);
|
private record VoteRecord(int SuggestionId, int Score);
|
||||||
}
|
}
|
||||||
|
|||||||
14
Program.cs
14
Program.cs
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.DataProtection;
|
|||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -56,6 +57,19 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
|||||||
: CookieSecurePolicy.Always;
|
: CookieSecurePolicy.Always;
|
||||||
options.SlidingExpiration = true;
|
options.SlidingExpiration = true;
|
||||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||||
|
options.Events = new CookieAuthenticationEvents
|
||||||
|
{
|
||||||
|
OnRedirectToLogin = ctx =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnRedirectToAccessDenied = ctx =>
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddAuthorization(options =>
|
builder.Services.AddAuthorization(options =>
|
||||||
|
|||||||
48
TASKS.md
48
TASKS.md
@@ -1,39 +1,39 @@
|
|||||||
# TASKS
|
# TASKS
|
||||||
|
|
||||||
## Authentication & Identity
|
## Authentication & Identity
|
||||||
- [ ] Cover register success behavior: trims username/display name, enforces 24/16 char caps, issues auth cookie, stores normalized username (no plain passwords).
|
- [x] Cover register success behavior: trims username/display name, enforces 24/16 char caps, issues auth cookie, stores normalized username (no plain passwords).
|
||||||
- [ ] Add register/login rejection cases for overlength usernames/display names and ensure login success sets LastLoginAt + fills DisplayName when null.
|
- [x] Add register/login rejection cases for overlength usernames/display names and ensure login success sets LastLoginAt + fills DisplayName when null.
|
||||||
- [ ] Verify admin claim/authorization end-to-end: admin cookie includes claim; non-admin hitting `/api/admin/*` returns 401/403.
|
- [x] Verify admin claim/authorization end-to-end: admin cookie includes claim; non-admin hitting `/api/admin/*` returns 401/403.
|
||||||
|
|
||||||
## State & Phase
|
## State & Phase
|
||||||
- [ ] Assert `/api/state` payload fields (currentPhase, votesFinal, hasJoker, counts) for authenticated user.
|
- [x] Assert `/api/state` payload fields (currentPhase, votesFinal, hasJoker, counts) for authenticated user.
|
||||||
- [ ] Test GetPhase alignment: legacy Reveal -> Vote; closing results (resultsOpen false) realigns users from Results to Vote and clears VotesFinal.
|
- [x] Test GetPhase alignment: legacy Reveal -> Vote; closing results (resultsOpen false) realigns users from Results to Vote and clears VotesFinal.
|
||||||
- [ ] Add `/api/me/phase/next` happy path to Results when resultsOpen=true and ensure VotesFinal clears on advance; cover Suggest->Vote success.
|
- [x] Add `/api/me/phase/next` happy path to Results when resultsOpen=true and ensure VotesFinal clears on advance; cover Suggest->Vote success.
|
||||||
- [ ] Ensure `/api/me/phase/prev` clears VotesFinal and respects per-step back transitions; add name limit >16 rejection.
|
- [x] Ensure `/api/me/phase/prev` clears VotesFinal and respects per-step back transitions; add name limit >16 rejection.
|
||||||
|
|
||||||
## Suggestions
|
## Suggestions
|
||||||
- [ ] Enforce phase gating on create for non-admins outside Suggest (no joker) and require display name before create.
|
- [x] Enforce phase gating on create for non-admins outside Suggest (no joker) and require display name before create.
|
||||||
- [ ] Add validation cases: invalid game/youtube URLs, missing/overlong name (>100), player count ranges (min<1, max>32, only one of min/max provided), trimming/truncation of optional fields.
|
- [x] Add validation cases: invalid game/youtube URLs, missing/overlong name (>100), player count ranges (min<1, max>32, only one of min/max provided), trimming/truncation of optional fields.
|
||||||
- [ ] Verify `/api/suggestions/mine` excludes other players; `/api/suggestions/all` returns ordered list with LinkedIds/LinkedTitles metadata.
|
- [x] Verify `/api/suggestions/mine` excludes other players; `/api/suggestions/all` returns ordered list with LinkedIds/LinkedTitles metadata.
|
||||||
- [ ] Test DELETE: player can delete own in Suggest only; admin any time; links to children cleared and related votes removed.
|
- [x] Test DELETE: player can delete own in Suggest only; admin any time; links to children cleared and related votes removed.
|
||||||
- [ ] Joker create path should unfinalize all players’ ballots, not just the caller.
|
- [x] Joker create path should unfinalize all players’ ballots, not just the caller.
|
||||||
|
|
||||||
## Votes
|
## Votes
|
||||||
- [ ] Guard `/api/votes/mine` for auth/phase mismatch; reject negative scores too.
|
- [x] Guard `/api/votes/mine` for auth/phase mismatch; reject negative scores too.
|
||||||
- [ ] Cover finalize toggle back to false and phase-change unfinalization; ensure VotesFinal blocks edits and resets correctly.
|
- [x] Cover finalize toggle back to false and phase-change unfinalization; ensure VotesFinal blocks edits and resets correctly.
|
||||||
- [ ] Add linked-vote coverage for nested/root-detection cases (e.g., chains) to ensure scores fan out as expected.
|
- [x] Add linked-vote coverage for nested/root-detection cases (e.g., chains) to ensure scores fan out as expected.
|
||||||
|
|
||||||
## Results
|
## Results
|
||||||
- [ ] Require Results phase and auth even when resultsOpen=true (phase mismatch/unauth 400/401).
|
- [x] Require Results phase and auth even when resultsOpen=true (phase mismatch/unauth 400/401).
|
||||||
- [ ] Validate results payload fields: totals/count/average (average=0 when no votes), MyVote, link metadata, ordering by average.
|
- [x] Validate results payload fields: totals/count/average (average=0 when no votes), MyVote, link metadata, ordering by average.
|
||||||
|
|
||||||
## Admin Operations
|
## Admin Operations
|
||||||
- [ ] Cover `/api/admin/results` closing path: moves everyone to Vote, clears VotesFinal, updates UpdatedAt timestamp.
|
- [x] Cover `/api/admin/results` closing path: moves everyone to Vote, clears VotesFinal, updates UpdatedAt timestamp.
|
||||||
- [ ] Extend vote-status tests for mixed finalized vs waiting users and ordering by display/username.
|
- [x] Extend vote-status tests for mixed finalized vs waiting users and ordering by display/username.
|
||||||
- [ ] Add happy-path joker grant in Vote phase; assert VotesFinal resets for that player.
|
- [x] Add happy-path joker grant in Vote phase; assert VotesFinal resets for that player.
|
||||||
- [ ] Exercise link/unlink phase gating and not-found cases; verify linking re-parents groups, deletes group votes, and unfinalizes affected players counts; ensure unlink follows spec vs current NotFound-on-missing behavior.
|
- [x] Exercise link/unlink phase gating and not-found cases; verify linking re-parents groups, deletes group votes, and unfinalizes affected players counts; ensure unlink follows spec vs current NotFound-on-missing behavior.
|
||||||
- [ ] Confirm reset clears HasJoker/VotesFinal and closes results; factory-reset re-seeds AppState defaults (ResultsOpen=false, UpdatedAt set).
|
- [x] Confirm reset clears HasJoker/VotesFinal and closes results; factory-reset re-seeds AppState defaults (ResultsOpen=false, UpdatedAt set).
|
||||||
|
|
||||||
## Infrastructure/Helpers
|
## Infrastructure/Helpers
|
||||||
- [ ] Expand `IsReachableImageAsync` tests: HEAD success path, redirect rejection, oversized content-length guard, fallback GET with non-image content.
|
- [x] Expand `IsReachableImageAsync` tests: HEAD success path, redirect rejection, oversized content-length guard, fallback GET with non-image content.
|
||||||
- [ ] Add coverage for global exception handler returning JSON 500 and logging.
|
- [x] Add coverage for global exception handler returning JSON 500 and logging.
|
||||||
|
|||||||
Reference in New Issue
Block a user