Add event-driven state sync with ETag optimization

This commit is contained in:
2026-02-18 19:58:57 +01:00
parent 5b921063ec
commit 3c7f3d2114
17 changed files with 493 additions and 30 deletions

View File

@@ -27,6 +27,11 @@ public class StateTests
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());
@@ -335,6 +340,118 @@ public class StateTests
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;
}
}
}