using System.Net.Http.Json; using System.Net.WebSockets; using System.Text; using System.Text.Json; using FluentAssertions; using Xunit; namespace backend.tests; public class DeviceHubHandshakeTests(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 = "ws-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 Connect_without_token_fails_upgrade() { var client = _factory.Server.CreateWebSocketClient(); var uri = new Uri(_factory.Server.BaseAddress, "device"); var act = async () => await client.ConnectAsync(uri, CancellationToken.None); await act.Should().ThrowAsync(); } [Fact] public async Task Hello_ack_includes_config() { var token = await PairAndGetTokenAsync("ws1@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" }); var ack = await ReceiveJson(ws); ack.GetProperty("type").GetString().Should().Be("hello_ack"); ack.GetProperty("config").GetProperty("voice").GetString().Should().NotBeNullOrEmpty(); ack.GetProperty("config").GetProperty("model").GetString().Should().NotBeNullOrEmpty(); ack.GetProperty("config").GetProperty("enabled_tools").GetArrayLength().Should().BeGreaterThan(0); } [Fact] public async Task Ping_replies_with_pong() { var token = await PairAndGetTokenAsync("ws2@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 = "ping" }); var pong = await ReceiveJson(ws); pong.GetProperty("type").GetString().Should().Be("pong"); } 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[8192]; using var stream = new MemoryStream(); while (true) { var r = await ws.ReceiveAsync(buf, CancellationToken.None); stream.Write(buf, 0, r.Count); if (r.EndOfMessage) break; } return JsonDocument.Parse(stream.ToArray()).RootElement.Clone(); } }