using System.Net.Http.Json; using System.Net.WebSockets; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using FluentAssertions; using Xunit; namespace backend.tests; [Collection("WakeFlow")] // tests touch FakeRealtimeSessionFactory.Upstream static — serialize public class DeviceHubWakeFlowTests(ApiFactory factory) : IClassFixture { private readonly ApiFactory _factory = factory; private async Task PairAndGetTokenAsync(string email) { var http = _factory.CreateClient(); await http.PostAsJsonAsync("/api/auth/register", new { email, password = "Passw0rd!" }); await http.PostAsJsonAsync("/api/auth/login", new { email, password = "Passw0rd!" }); var codeRes = await http.PostAsJsonAsync("/api/pair-code", new { name = "wake-test" }); var code = (await codeRes.Content.ReadFromJsonAsync>())!["code"].ToString()!; var pairRes = await _factory.CreateClient().PostAsJsonAsync("/api/pair", new { code, hostname = "smoke", client_version = "0.1" }); var pairBody = await pairRes.Content.ReadFromJsonAsync>(); return pairBody!["device_token"].ToString()!; } [Fact] public async Task Wake_opens_session_started_then_assistant_done_then_session_ended_on_cancel() { FakeRealtimeSessionFactory.Upstream = new ScriptedRealtimeUpstream(); var token = await PairAndGetTokenAsync("wake1@example.com"); var client = _factory.Server.CreateWebSocketClient(); client.ConfigureRequest = req => req.Headers["Authorization"] = $"Bearer {token}"; var uri = new Uri(_factory.Server.BaseAddress, "device"); var ws = await client.ConnectAsync(uri, CancellationToken.None); await SendJson(ws, new { type = "hello", device_id = Guid.NewGuid(), client_version = "0.1" }); await ReceiveJson(ws); // ack await SendJson(ws, new { type = "wake", at = 0L }); var started = await ReceiveJson(ws); started.GetProperty("type").GetString().Should().Be("session_started"); FakeRealtimeSessionFactory.Upstream.Push(new JsonObject { ["type"] = "response.audio_transcript.done", ["transcript"] = "hello!", }); FakeRealtimeSessionFactory.Upstream.Push(new JsonObject { ["type"] = "response.done" }); var done = await ReceiveUntil(ws, "assistant_done"); done.GetProperty("type").GetString().Should().Be("assistant_done"); await SendJson(ws, new { type = "cancel" }); var ended = await ReceiveUntil(ws, "session_ended"); ended.GetProperty("type").GetString().Should().Be("session_ended"); } [Fact] public async Task Binary_uplink_after_wake_reaches_upstream_as_input_audio_buffer_append() { FakeRealtimeSessionFactory.Upstream = new ScriptedRealtimeUpstream(); var token = await PairAndGetTokenAsync("wake2@example.com"); var client = _factory.Server.CreateWebSocketClient(); client.ConfigureRequest = req => req.Headers["Authorization"] = $"Bearer {token}"; var uri = new Uri(_factory.Server.BaseAddress, "device"); var ws = await client.ConnectAsync(uri, CancellationToken.None); await SendJson(ws, new { type = "hello", device_id = Guid.NewGuid(), client_version = "0.1" }); await ReceiveJson(ws); await SendJson(ws, new { type = "wake", at = 0L }); await ReceiveJson(ws); // session_started var frame = new byte[3840]; for (int i = 0; i < frame.Length; i++) frame[i] = 0x55; await ws.SendAsync(frame, WebSocketMessageType.Binary, true, CancellationToken.None); // Allow time for the pump's 250 ms poll + send. var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); while (DateTime.UtcNow < deadline) { if (FakeRealtimeSessionFactory.Upstream.Sent.Any(e => (string?)e["type"] == "input_audio_buffer.append")) break; await Task.Delay(50); } FakeRealtimeSessionFactory.Upstream.Sent .Any(e => (string?)e["type"] == "input_audio_buffer.append") .Should().BeTrue(); await SendJson(ws, new { type = "cancel" }); await ReceiveJson(ws); } private static async Task SendJson(WebSocket ws, object payload) { var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload)); await ws.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None); } private static async Task ReceiveJson(WebSocket ws) { var buf = new byte[16384]; using var stream = new MemoryStream(); while (true) { var r = await ws.ReceiveAsync(buf, CancellationToken.None); if (r.MessageType == WebSocketMessageType.Binary) { stream.Write(buf, 0, r.Count); continue; } stream.Write(buf, 0, r.Count); if (r.EndOfMessage) break; } return JsonDocument.Parse(stream.ToArray()).RootElement.Clone(); } // The relay forwards an upstream-event "debug" envelope for every event // it sees, so envelopes the test cares about are interleaved with debugs. private static async Task ReceiveUntil(WebSocket ws, string type) { for (int i = 0; i < 50; i++) { var env = await ReceiveJson(ws); if (env.GetProperty("type").GetString() == type) return env; } throw new TimeoutException($"never saw envelope type={type}"); } }