using System.Text.Json.Nodes; using backend.Conversations; using backend.Data; using backend.Realtime; using backend.Tools; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace backend.tests; public class RealtimeSessionLifecycleTests(ApiFactory factory) : IClassFixture { private readonly ApiFactory _factory = factory; [Fact] public async Task Open_emits_session_update_then_session_started_then_session_ended_on_cancel() { using var scope = _factory.Services.CreateScope(); var log = scope.ServiceProvider.GetRequiredService(); var registry = new ToolRegistry(new ITool[] { new GetCurrentTimeTool() }); var upstream = new ScriptedRealtimeUpstream(); var sink = new RecordingSink(); var cfg = new RealtimeSettings( Model: "gpt-4o-realtime-preview", Voice: "alloy", SystemPrompt: "test", IdleTimeoutSeconds: 30, EnabledTools: new HashSet { "get_current_time" }); var session = new RealtimeSession( deviceId: Guid.NewGuid(), upstream, sink, log, registry, cfg, channel: new FakeDeviceChannel(), logger: NullLogger.Instance); upstream.Push(new JsonObject { ["type"] = "session.updated" }); var cts = new CancellationTokenSource(); var runTask = session.RunAsync(cts.Token); await sink.WaitForAsync("session_started", TimeSpan.FromSeconds(2)); cts.Cancel(); await runTask; upstream.Sent.Select(e => (string?)e["type"]).Should().StartWith("session.update"); sink.Envelopes.Select(e => (string?)e["type"]).Should().Contain("session_started"); sink.Envelopes.Select(e => (string?)e["type"]).Should().Contain("session_ended"); } } internal class NullLogger : Microsoft.Extensions.Logging.ILogger { public static readonly NullLogger Instance = new(); public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => false; public void Log(Microsoft.Extensions.Logging.LogLevel l, Microsoft.Extensions.Logging.EventId e, TState s, Exception? ex, Func f) { } } internal class RecordingSink : ISessionOutput { public List Envelopes { get; } = new(); public List Binary { get; } = new(); public Task WriteEnvelopeAsync(JsonObject e, CancellationToken ct) { lock (Envelopes) Envelopes.Add(e); _gate.Release(); return Task.CompletedTask; } public Task WriteBinaryAsync(ReadOnlyMemory b, CancellationToken ct) { Binary.Add(b.ToArray()); return Task.CompletedTask; } private readonly SemaphoreSlim _gate = new(0, int.MaxValue); public async Task WaitForAsync(string type, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { lock (Envelopes) { if (Envelopes.Any(e => (string?)e["type"] == type)) return; } await _gate.WaitAsync(timeout); } throw new TimeoutException($"never saw {type}"); } }