using System.Net.WebSockets; using System.Net.Http.Json; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using TermRemoteCtl.Agent.Terminal; namespace TermRemoteCtl.Agent.IntegrationTests; public sealed class SessionFlowTests { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); [Fact] public async Task Create_Attach_Reconnect_Sequence_Returns_Consistent_Session_Metadata() { await using var fixture = new AgentFixture(); using var client = fixture.CreateClient(); var createResponse = await client.PostAsJsonAsync("/api/sessions", new { name = "codex-main" }); createResponse.EnsureSuccessStatusCode(); var created = await createResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(created); var listedSessions = await client.GetFromJsonAsync>("/api/sessions", JsonOptions); Assert.NotNull(listedSessions); Assert.Contains(listedSessions!, session => session.SessionId == created!.SessionId && session.Name == created.Name && session.Status == created.Status && session.CreatedAtUtc == created.CreatedAtUtc && session.UpdatedAtUtc == created.UpdatedAtUtc); var initialSnapshot = created; using (var firstSocket = await fixture.ConnectTerminalAsync(created.SessionId)) { var attached = await ReceiveTextAsync(firstSocket); var attachedPayload = JsonSerializer.Deserialize(attached, JsonOptions); Assert.NotNull(attachedPayload); Assert.Equal(created.SessionId, attachedPayload!.SessionId); Assert.Equal("attached", attachedPayload.Type); } using (var secondSocket = await fixture.ConnectTerminalAsync(created.SessionId)) { var reattached = await ReceiveTextAsync(secondSocket); var reattachedPayload = JsonSerializer.Deserialize(reattached, JsonOptions); Assert.NotNull(reattachedPayload); Assert.Equal(initialSnapshot.SessionId, reattachedPayload!.SessionId); Assert.Equal("attached", reattachedPayload.Type); } var replayedSessions = await client.GetFromJsonAsync>("/api/sessions", JsonOptions); Assert.NotNull(replayedSessions); Assert.Contains(replayedSessions!, session => session.SessionId == initialSnapshot.SessionId && session.Name == initialSnapshot.Name && session.Status == initialSnapshot.Status && session.CreatedAtUtc == initialSnapshot.CreatedAtUtc && session.UpdatedAtUtc == initialSnapshot.UpdatedAtUtc); Assert.Equal(1, fixture.SessionHost.StartCountFor(initialSnapshot.SessionId)); } [Fact] public async Task Delete_Removes_Session_From_List_And_Stops_Host() { await using var fixture = new AgentFixture(); using var client = fixture.CreateClient(); var createResponse = await client.PostAsJsonAsync("/api/sessions", new { name = "codex-main" }); createResponse.EnsureSuccessStatusCode(); var created = await createResponse.Content.ReadFromJsonAsync(JsonOptions); Assert.NotNull(created); using (var socket = await fixture.ConnectTerminalAsync(created!.SessionId)) { _ = await ReceiveTextAsync(socket); } var deleteResponse = await client.DeleteAsync($"/api/sessions/{created.SessionId}"); deleteResponse.EnsureSuccessStatusCode(); var listedSessions = await client.GetFromJsonAsync>("/api/sessions", JsonOptions); Assert.NotNull(listedSessions); Assert.DoesNotContain(listedSessions!, session => session.SessionId == created.SessionId); Assert.Equal(1, fixture.SessionHost.StopCountFor(created.SessionId)); } private static async Task ReceiveTextAsync(WebSocket socket) { var buffer = new byte[4096]; using var stream = new MemoryStream(); while (true) { var result = await socket.ReceiveAsync(buffer, CancellationToken.None); if (result.MessageType == WebSocketMessageType.Close) { throw new InvalidOperationException("Socket closed before a text frame was received."); } stream.Write(buffer, 0, result.Count); if (result.EndOfMessage) { return Encoding.UTF8.GetString(stream.ToArray()); } } } private sealed class AgentFixture : WebApplicationFactory { private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N")); private readonly RecordingSessionHost _sessionHost = new(); public RecordingSessionHost SessionHost => _sessionHost; protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Development"); builder.ConfigureAppConfiguration((_, configBuilder) => { configBuilder.AddInMemoryCollection(new Dictionary { ["Agent:DataRoot"] = _dataRoot, ["Agent:BindAddress"] = "127.0.0.1", ["Agent:HttpsPort"] = "9443", ["Agent:WebSocketFrameFlushMilliseconds"] = "33", ["Agent:RingBufferLineLimit"] = "4000" }); }); builder.ConfigureServices(services => { services.RemoveAll(); services.AddSingleton(_sessionHost); }); } public async Task ConnectTerminalAsync(string sessionId) { return await Server.CreateWebSocketClient() .ConnectAsync(new Uri($"ws://localhost/ws/terminal?sessionId={sessionId}"), CancellationToken.None); } public new async ValueTask DisposeAsync() { await base.DisposeAsync(); if (Directory.Exists(_dataRoot)) { Directory.Delete(_dataRoot, true); } } } private sealed class RecordingSessionHost : ISessionHost { private readonly Dictionary _startCounts = new(StringComparer.Ordinal); private readonly Dictionary _stopCounts = new(StringComparer.Ordinal); private readonly HashSet _startedSessions = new(StringComparer.Ordinal); public event EventHandler? OutputReceived { add { } remove { } } public Task StartAsync(string sessionId, CancellationToken cancellationToken) { if (_startedSessions.Add(sessionId)) { _startCounts[sessionId] = 1; } return Task.CompletedTask; } public Task WriteInputAsync(string sessionId, string input, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task ResizeAsync(string sessionId, int columns, int rows, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task StopAsync(string sessionId, CancellationToken cancellationToken) { if (_stopCounts.TryGetValue(sessionId, out var count)) { _stopCounts[sessionId] = count + 1; } else { _stopCounts[sessionId] = 1; } _startedSessions.Remove(sessionId); return Task.CompletedTask; } public int StartCountFor(string sessionId) { return _startCounts.TryGetValue(sessionId, out var count) ? count : 0; } public int StopCountFor(string sessionId) { return _stopCounts.TryGetValue(sessionId, out var count) ? count : 0; } } private sealed record SessionResponse( string SessionId, string Name, string Status, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc); private sealed record TerminalAttachResponse(string SessionId, string Type); }