Add event-driven state sync with ETag optimization
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user