DeviceHub: wake → session lifecycle + uplink relay + tool_result routing
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
using backend.Realtime;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace backend.tests;
|
||||
|
||||
@@ -17,8 +21,14 @@ public class ApiFactory : WebApplicationFactory<Program>
|
||||
cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:Default"] = $"Data Source={_dbPath}",
|
||||
["OPENAI_API_KEY"] = "test-key",
|
||||
});
|
||||
});
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.AddScoped<RealtimeSessionFactory, FakeRealtimeSessionFactory>();
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
@@ -32,3 +42,24 @@ public class ApiFactory : WebApplicationFactory<Program>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeRealtimeSessionFactory(
|
||||
OpenAIKeyProvider keys,
|
||||
backend.Tools.ToolRegistry registry,
|
||||
backend.Conversations.ConversationLog log,
|
||||
ILoggerFactory loggerFactory)
|
||||
: RealtimeSessionFactory(keys, registry, log, loggerFactory)
|
||||
{
|
||||
public static ScriptedRealtimeUpstream Upstream { get; set; } = new();
|
||||
|
||||
public override Task<RealtimeSession> CreateAsync(
|
||||
Guid deviceId, RealtimeSettings cfg, ISessionOutput output,
|
||||
backend.Tools.IDeviceChannel channel, CancellationToken ct)
|
||||
{
|
||||
Upstream.Push(new System.Text.Json.Nodes.JsonObject { ["type"] = "session.updated" });
|
||||
var s = new RealtimeSession(
|
||||
deviceId, Upstream, output, log, registry, cfg, channel,
|
||||
loggerFactory.CreateLogger<RealtimeSession>());
|
||||
return Task.FromResult(s);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory = factory;
|
||||
|
||||
private async Task<string> 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<Dictionary<string, object>>())!["code"].ToString()!;
|
||||
var pairRes = await _factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||
new { code, hostname = "smoke", client_version = "0.1" });
|
||||
var pairBody = await pairRes.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||
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 ReceiveJson(ws);
|
||||
done.GetProperty("type").GetString().Should().Be("assistant_done");
|
||||
|
||||
await SendJson(ws, new { type = "cancel" });
|
||||
var ended = await ReceiveJson(ws);
|
||||
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<JsonElement> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user