458 lines
18 KiB
C#
458 lines
18 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using GameList.Domain;
|
|
using GameList.Tests.Support;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using GameList.Data;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace GameList.Tests;
|
|
|
|
public class StateTests
|
|
{
|
|
[Fact]
|
|
public async Task State_endpoint_returns_expected_payload_for_authenticated_user()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var client = factory.CreateClientWithCookies();
|
|
await client.RegisterAsync("payload");
|
|
await factory.WithDbContextAsync(async db =>
|
|
{
|
|
var player = await db.Players.SingleAsync();
|
|
player.HasJoker = true;
|
|
await db.SaveChangesAsync();
|
|
});
|
|
await client.CreateSuggestionAsync("One");
|
|
|
|
var state = await client.GetFromJsonAsync<JsonElement>("/api/state");
|
|
|
|
Assert.True(Guid.TryParse(state.GetProperty("id").GetString(), out _));
|
|
Assert.Equal("payload", state.GetProperty("username").GetString());
|
|
Assert.Equal("payload-name", state.GetProperty("displayName").GetString());
|
|
Assert.False(state.GetProperty("isAdmin").GetBoolean());
|
|
Assert.False(state.GetProperty("isOwner").GetBoolean());
|
|
Assert.Equal(nameof(Phase.Suggest), 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 GetCurrentPhase_reads_effective_phase_without_mutating_player()
|
|
{
|
|
await 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 = [1],
|
|
PasswordSalt = [1],
|
|
DisplayName = "Legacy",
|
|
CurrentPhase = (Phase)1,
|
|
VotesFinal = true
|
|
};
|
|
playerId = player.Id;
|
|
db.Players.Add(player);
|
|
var state = await db.AppState.SingleAsync();
|
|
state.ResultsOpen = true;
|
|
await db.SaveChangesAsync();
|
|
});
|
|
|
|
using (var scope = factory.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var phase = await Endpoints.EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
|
Assert.Equal(Phase.Results, phase);
|
|
}
|
|
|
|
await factory.WithDbContextAsync(async db =>
|
|
{
|
|
var state = await db.AppState.SingleAsync();
|
|
state.ResultsOpen = false;
|
|
await db.SaveChangesAsync();
|
|
});
|
|
|
|
using (var scope = factory.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var phase = await Endpoints.EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
|
var player = await db.Players.FindAsync(playerId);
|
|
Assert.Equal(Phase.Vote, phase);
|
|
Assert.Equal((Phase)1, player!.CurrentPhase);
|
|
Assert.True(player.VotesFinal);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Phase_next_advances_and_clears_votesfinal()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var client = factory.CreateClientWithCookies();
|
|
await client.RegisterAsync("advance");
|
|
await client.CreateSuggestionAsync("Advance game");
|
|
|
|
await factory.WithDbContextAsync(async db =>
|
|
{
|
|
var player = await db.Players.SingleAsync();
|
|
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(nameof(Phase.Results), me.GetProperty("currentPhase").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Phase_prev_moves_back_and_clears_votesfinal()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var admin = factory.CreateClientWithCookies();
|
|
await admin.RegisterAsync("admin", admin: true);
|
|
await admin.CreateSuggestionAsync("Admin game");
|
|
|
|
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote
|
|
await factory.WithDbContextAsync(async db =>
|
|
{
|
|
var player = await db.Players.SingleAsync();
|
|
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(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
|
|
Assert.False(me.GetProperty("votesFinal").GetBoolean());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cannot_advance_to_results_when_locked()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var client = factory.CreateClientWithCookies();
|
|
await client.RegisterAsync("player");
|
|
await client.CreateSuggestionAsync("Player game");
|
|
|
|
var toVote = await client.PostAsync("/api/me/phase/next", JsonContent.Create(new { }));
|
|
toVote.EnsureSuccessStatusCode();
|
|
|
|
var toResults = await client.PostAsync("/api/me/phase/next", JsonContent.Create(new { }));
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, toResults.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Phase_next_from_suggest_requires_at_least_one_suggestion()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var client = factory.CreateClientWithCookies();
|
|
await client.RegisterAsync("nosuggest");
|
|
|
|
var response = await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
|
|
Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Admin_opening_results_moves_players_to_results_phase()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var admin = factory.CreateClientWithCookies();
|
|
await admin.RegisterAsync("admin", admin: true);
|
|
|
|
var player = factory.CreateClientWithCookies();
|
|
await player.RegisterAsync("user");
|
|
|
|
var toggle = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
|
|
toggle.EnsureSuccessStatusCode();
|
|
|
|
var state = await player.GetFromJsonAsync<JsonElement>("/api/state");
|
|
|
|
Assert.Equal(nameof(Phase.Results), state.GetProperty("currentPhase").GetString());
|
|
Assert.True(state.GetProperty("resultsOpen").GetBoolean());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Display_name_cannot_be_changed_after_registration()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var client = factory.CreateClientWithCookies();
|
|
var username = "fixedname";
|
|
await client.RegisterAsync(username);
|
|
var originalDisplay = $"{username}-name";
|
|
|
|
var attempt = await client.PostAsJsonAsync("/api/me/name", new { name = "New Name" });
|
|
Assert.Equal(HttpStatusCode.NotFound, attempt.StatusCode);
|
|
|
|
var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
|
|
Assert.Equal(originalDisplay, me.GetProperty("displayName").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Phase_prev_admin_only()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var player = factory.CreateClientWithCookies();
|
|
await player.RegisterAsync("phase");
|
|
|
|
var notAdmin = await player.PostAsJsonAsync("/api/me/phase/prev", new { });
|
|
Assert.Equal(HttpStatusCode.BadRequest, notAdmin.StatusCode);
|
|
|
|
var admin = factory.CreateClientWithCookies();
|
|
await admin.RegisterAsync("admin", admin: true);
|
|
await admin.CreateSuggestionAsync("Admin phase game");
|
|
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // to Vote
|
|
var back = await admin.PostAsJsonAsync("/api/me/phase/prev", new { });
|
|
back.EnsureSuccessStatusCode();
|
|
var me = await admin.GetFromJsonAsync<JsonElement>("/api/me");
|
|
Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task State_endpoint_requires_auth_and_counts()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var anon = factory.CreateClient();
|
|
var unauthorized = await anon.GetAsync("/api/state");
|
|
Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);
|
|
var unauthorizedJson = await unauthorized.Content.ReadFromJsonAsync<JsonElement>();
|
|
Assert.Equal("Unauthorized", unauthorizedJson.GetProperty("title").GetString());
|
|
Assert.Equal("Unauthorized", unauthorizedJson.GetProperty("detail").GetString());
|
|
Assert.Equal("Unauthorized", unauthorizedJson.GetProperty("error").GetString());
|
|
|
|
var client = factory.CreateClientWithCookies();
|
|
await client.RegisterAsync("counting");
|
|
await client.CreateSuggestionAsync("One");
|
|
var state = await client.GetFromJsonAsync<JsonElement>("/api/state");
|
|
Assert.True(state.TryGetProperty("Players", out var players) || state.TryGetProperty("players", out players));
|
|
Assert.True(players.GetInt32() >= 1);
|
|
Assert.True(state.TryGetProperty("Suggestions", out var suggestions) || state.TryGetProperty("suggestions", out suggestions));
|
|
Assert.True(suggestions.GetInt32() >= 1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task State_endpoint_with_stale_cookie_returns_unauthorized_and_clears_cookie()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var client = factory.CreateClientWithCookies();
|
|
await client.RegisterAsync("stale");
|
|
|
|
await factory.WithDbContextAsync(async db =>
|
|
{
|
|
var player = await db.Players.SingleAsync();
|
|
db.Players.Remove(player);
|
|
await db.SaveChangesAsync();
|
|
});
|
|
|
|
var resp = await client.GetAsync("/api/state");
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
|
Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies));
|
|
Assert.Contains(cookies, c => c.Contains("player=", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Health_endpoint_ok()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var resp = await factory.CreateClient().GetFromJsonAsync<JsonElement>("/health");
|
|
Assert.Equal("ok", resp.GetProperty("status").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task State_and_me_reads_do_not_persist_phase_reconciliation()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var client = factory.CreateClientWithCookies();
|
|
await client.RegisterAsync("reader");
|
|
|
|
await factory.WithDbContextAsync(async db =>
|
|
{
|
|
var player = await db.Players.SingleAsync();
|
|
player.CurrentPhase = Phase.Results;
|
|
player.VotesFinal = true;
|
|
var state = await db.AppState.SingleAsync();
|
|
state.ResultsOpen = false;
|
|
await db.SaveChangesAsync();
|
|
});
|
|
|
|
var stateResponse = await client.GetFromJsonAsync<JsonElement>("/api/state");
|
|
var meResponse = await client.GetFromJsonAsync<JsonElement>("/api/me");
|
|
|
|
Assert.Equal(nameof(Phase.Vote), stateResponse.GetProperty("currentPhase").GetString());
|
|
Assert.Equal(nameof(Phase.Vote), meResponse.GetProperty("currentPhase").GetString());
|
|
|
|
await factory.WithDbContextAsync(async db =>
|
|
{
|
|
var player = await db.Players.AsNoTracking().SingleAsync();
|
|
Assert.Equal(Phase.Results, player.CurrentPhase);
|
|
Assert.True(player.VotesFinal);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetPhase_aligns_to_results_when_open()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
await factory.WithDbContextAsync(async db =>
|
|
{
|
|
var player = new Player
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Username = "phase",
|
|
NormalizedUsername = "phase",
|
|
PasswordHash = [1],
|
|
PasswordSalt = [1],
|
|
DisplayName = "phase",
|
|
CurrentPhase = Phase.Vote
|
|
};
|
|
db.Players.Add(player);
|
|
var state = await db.AppState.SingleAsync();
|
|
state.ResultsOpen = true;
|
|
await db.SaveChangesAsync();
|
|
});
|
|
|
|
using var scope = factory.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var playerId = await db.Players.Select(p => p.Id).SingleAsync();
|
|
var phase = await Endpoints.EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
|
|
|
Assert.Equal(Phase.Results, phase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task State_endpoint_supports_conditional_get_with_etag()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var client = factory.CreateClientWithCookies();
|
|
await client.RegisterAsync("etag");
|
|
|
|
var first = await client.GetAsync("/api/state");
|
|
first.EnsureSuccessStatusCode();
|
|
var firstEtag = first.Headers.ETag?.ToString();
|
|
Assert.False(string.IsNullOrWhiteSpace(firstEtag));
|
|
|
|
var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/state");
|
|
conditional.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
|
var notModified = await client.SendAsync(conditional);
|
|
|
|
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
|
Assert.Equal(firstEtag, notModified.Headers.ETag?.ToString());
|
|
|
|
await client.CreateSuggestionAsync("etag-changed");
|
|
|
|
var stale = new HttpRequestMessage(HttpMethod.Get, "/api/state");
|
|
stale.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
|
var changed = await client.SendAsync(stale);
|
|
|
|
changed.EnsureSuccessStatusCode();
|
|
Assert.NotEqual(firstEtag, changed.Headers.ETag?.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task State_events_endpoint_emits_state_change_after_mutation()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var watcher = factory.CreateClientWithCookies();
|
|
await watcher.RegisterAsync("watcher");
|
|
|
|
using var streamResponse = await watcher.GetAsync("/api/events/state", HttpCompletionOption.ResponseHeadersRead);
|
|
streamResponse.EnsureSuccessStatusCode();
|
|
Assert.Equal("text/event-stream", streamResponse.Content.Headers.ContentType?.MediaType);
|
|
|
|
await using var stream = await streamResponse.Content.ReadAsStreamAsync();
|
|
using var reader = new StreamReader(stream);
|
|
|
|
var readyVersion = await ReadSseEventVersionAsync(reader, "ready", TimeSpan.FromSeconds(2));
|
|
|
|
var mutator = factory.CreateClientWithCookies();
|
|
var register = await mutator.RegisterAsync("mutator");
|
|
register.EnsureSuccessStatusCode();
|
|
|
|
var changedVersion = await ReadSseEventVersionAsync(reader, "state", TimeSpan.FromSeconds(3));
|
|
Assert.True(changedVersion > readyVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Login_does_not_invalidate_state_etag()
|
|
{
|
|
await using var factory = new TestWebApplicationFactory();
|
|
var client = factory.CreateClientWithCookies();
|
|
await client.RegisterAsync("quietetag");
|
|
|
|
var first = await client.GetAsync("/api/state");
|
|
first.EnsureSuccessStatusCode();
|
|
var firstEtag = first.Headers.ETag?.ToString();
|
|
Assert.False(string.IsNullOrWhiteSpace(firstEtag));
|
|
|
|
var loginClient = factory.CreateClientWithCookies();
|
|
var login = await loginClient.LoginAsync("quietetag", "Pass123!");
|
|
login.EnsureSuccessStatusCode();
|
|
|
|
var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/state");
|
|
conditional.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
|
var notModified = await client.SendAsync(conditional);
|
|
|
|
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
|
Assert.Equal(firstEtag, notModified.Headers.ETag?.ToString());
|
|
}
|
|
|
|
private static async Task<long> ReadSseEventVersionAsync(StreamReader reader, string expectedEventName, TimeSpan timeout)
|
|
{
|
|
using var cts = new CancellationTokenSource(timeout);
|
|
var eventName = string.Empty;
|
|
|
|
while (true)
|
|
{
|
|
var line = await reader.ReadLineAsync(cts.Token);
|
|
if (line is null)
|
|
throw new Xunit.Sdk.XunitException("SSE stream closed unexpectedly.");
|
|
|
|
if (line.Length == 0)
|
|
{
|
|
eventName = string.Empty;
|
|
continue;
|
|
}
|
|
|
|
if (line.StartsWith("event: ", StringComparison.Ordinal))
|
|
{
|
|
eventName = line["event: ".Length..].Trim();
|
|
continue;
|
|
}
|
|
|
|
if (!line.StartsWith("data: ", StringComparison.Ordinal))
|
|
continue;
|
|
|
|
if (!string.Equals(eventName, expectedEventName, StringComparison.Ordinal))
|
|
continue;
|
|
|
|
var payload = line["data: ".Length..].Trim();
|
|
if (long.TryParse(payload, out var version))
|
|
return version;
|
|
}
|
|
}
|
|
}
|
|
|
|
|