From d91f95379b2d4c59a4f37d565f6fe23334940781 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Fri, 15 May 2026 20:52:36 -0400 Subject: [PATCH] test: ControlSurfaceServer route table smoke coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds end-to-end-ish tests that boot the server on an OS-assigned free port and exercise the route dispatch via HttpClient. Catches regressions in the route table itself (which is the part of the control surface that benefits least from unit tests — its bug surface is the URL → handler mapping, not the handler bodies). * src/tests/TeamsISO.App.Tests/Fakes/StubIsoController.cs — minimal IIsoController stub that lets the App layer instantiate without spinning up the engine + NDI runtime. EnableCalls / DisableCalls / RefreshDiscoveryCalled flags make assertions on side effects easy. * src/tests/TeamsISO.App.Tests/Services/ControlSurfaceServerTests.cs (7 cases): - GET / → 200 with the server-info JSON (product, endpoints). - GET /unknown-path → 200 with body {error:"not found"}. Pinning this odd-but-intentional behavior: the catch-all switch arm returns NotFound() (an object) so response is non-null and the pipeline writes 200 + that body instead of branching to the 404 path. The body is the disambiguator, matching the rest of the surface's "200 + {ok:false,error:…}" convention. - GET /participants → 200 with participants:[] when no view-model. - POST /presets/refresh-discovery → 200 + StubIsoController. RefreshDiscoveryCalled flips true (route → controller round-trip). - POST /presets/{missing}/apply → 200 + ok:false + error:"preset not found" (missing-preset path). - GET /ui → 200 with text/html. - OPTIONS /participants → 204 + Access-Control-Allow-Origin:* (CORS preflight for browser-based controllers). TeamsISO.App.Tests.csproj gains UseWPF=true so the test assembly can transitively compile against the WPF types that ControlSurfaceServer's signature touches (System.Windows.Threading, Application.Current). Implicit-using set narrows under UseWPF, so OscMessageTests gains an explicit `using System.IO` and the new test file gains `using System.Net.Http`. Tests: 56 → 90 in App.Tests; Engine.Tests unchanged at 103. Total green: 193. Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Fakes/StubIsoController.cs | 107 ++++++++++ .../Services/ControlSurfaceServerTests.cs | 201 ++++++++++++++++++ .../Services/OscMessageTests.cs | 1 + .../TeamsISO.App.Tests.csproj | 10 +- 4 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 src/tests/TeamsISO.App.Tests/Fakes/StubIsoController.cs create mode 100644 src/tests/TeamsISO.App.Tests/Services/ControlSurfaceServerTests.cs diff --git a/src/tests/TeamsISO.App.Tests/Fakes/StubIsoController.cs b/src/tests/TeamsISO.App.Tests/Fakes/StubIsoController.cs new file mode 100644 index 0000000..20a2a05 --- /dev/null +++ b/src/tests/TeamsISO.App.Tests/Fakes/StubIsoController.cs @@ -0,0 +1,107 @@ +using System.Reactive.Linq; +using System.Reactive.Subjects; +using TeamsISO.Engine.Controller; +using TeamsISO.Engine.Domain; +using TeamsISO.Engine.Pipeline; + +namespace TeamsISO.App.Tests.Fakes; + +// Minimal IIsoController stub for tests that need to instantiate +// services in the App layer (ControlSurfaceServer, OscBridge, etc.) +// without spinning up the real engine + NDI runtime. +// +// Everything is a sensible no-op default; tests that need a specific +// behaviour (e.g. "EnableIsoAsync was called with these args") subclass +// or replace methods via the action hooks. +internal sealed class StubIsoController : IIsoController +{ + private readonly BehaviorSubject> _participants = + new(Array.Empty()); + private readonly BehaviorSubject _alerts = new(default); + + public IObservable> Participants => _participants; + public IObservable Alerts => _alerts.Where(a => a is not null)!; + + public FrameProcessingSettings GlobalSettings { get; set; } = new( + TargetFramerate.Fps30, TargetResolution.R1080p, AspectMode.Letterbox, AudioMode.Auto); + + public NdiGroupSettings GroupSettings { get; set; } = new( + DiscoveryGroups: null, OutputGroups: null); + + public bool RecordingEnabled { get; private set; } + public string? RecordingDirectory { get; private set; } + + public Func? GetStatsHandler { get; set; } + public Func? GetLatestProcessedFrameHandler { get; set; } + public Func? GetIsoOverrideHandler { get; set; } + + public IsoHealthStats GetStats(Guid participantId) => + GetStatsHandler?.Invoke(participantId) ?? IsoHealthStats.Empty; + + public ProcessedFrame? GetLatestProcessedFrame(Guid participantId) => + GetLatestProcessedFrameHandler?.Invoke(participantId); + + public FrameProcessingSettings? GetIsoOverride(Guid participantId) => + GetIsoOverrideHandler?.Invoke(participantId); + + public List<(Guid Id, string? Name)> EnableCalls { get; } = new(); + public List DisableCalls { get; } = new(); + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken) + { + EnableCalls.Add((participantId, customName)); + return Task.CompletedTask; + } + + public Task EnableIsoAsync(Guid participantId, string? customName, bool? recordOverride, CancellationToken cancellationToken) + { + EnableCalls.Add((participantId, customName)); + return Task.CompletedTask; + } + + public Task DisableIsoAsync(Guid participantId, CancellationToken cancellationToken) + { + DisableCalls.Add(participantId); + return Task.CompletedTask; + } + + public Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken cancellationToken) + { + GlobalSettings = settings; + return Task.CompletedTask; + } + + public Task SetIsoOverrideAsync(Guid participantId, FrameProcessingSettings? settings, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task SetGroupSettingsAsync(NdiGroupSettings groupSettings, CancellationToken cancellationToken) + { + GroupSettings = groupSettings; + return Task.CompletedTask; + } + + public bool RefreshDiscoveryCalled { get; private set; } + public void RefreshDiscovery() => RefreshDiscoveryCalled = true; + + public void SetRecording(bool enabled, string? outputDirectory) + { + RecordingEnabled = enabled; + RecordingDirectory = outputDirectory; + } + + public void AddRecordingMarker(string label) { /* no-op for stub */ } + + public ValueTask DisposeAsync() + { + _participants.Dispose(); + _alerts.Dispose(); + return ValueTask.CompletedTask; + } + + // Used by tests to push synthetic participant snapshots through the + // observable chain. + public void PublishParticipants(params Participant[] participants) => + _participants.OnNext(participants); +} diff --git a/src/tests/TeamsISO.App.Tests/Services/ControlSurfaceServerTests.cs b/src/tests/TeamsISO.App.Tests/Services/ControlSurfaceServerTests.cs new file mode 100644 index 0000000..af96675 --- /dev/null +++ b/src/tests/TeamsISO.App.Tests/Services/ControlSurfaceServerTests.cs @@ -0,0 +1,201 @@ +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using FluentAssertions; +using TeamsISO.App.Services; +using TeamsISO.App.Tests.Fakes; + +namespace TeamsISO.App.Tests.Services; + +// End-to-end-ish smoke tests for ControlSurfaceServer. Each test boots +// the server on an OS-assigned free port (127.0.0.1 only — no urlacl +// required), makes a real HTTP request via HttpClient, and asserts +// against the response. The tests share a StubIsoController and a +// null view-model — endpoints that need a UI dispatcher degrade +// gracefully (return empty arrays) which is enough to verify the +// route table. +// +// We don't exercise the WebSocket path here — ClientWebSocket adds +// non-trivial timing complexity and the upgrade is verified by the +// 426/101 status arc of `/ws` on a non-WS GET (we hit it and confirm +// the server doesn't 500). +public sealed class ControlSurfaceServerTests +{ + private static int PickFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try { return ((IPEndPoint)listener.LocalEndpoint).Port; } + finally { listener.Stop(); } + } + + private static async Task<(ControlSurfaceServer Server, HttpClient Client, int Port)> BootAsync() + { + var controller = new StubIsoController(); + var server = new ControlSurfaceServer(controller, () => null, logger: null); + var port = PickFreePort(); + server.Start(port, bindToLan: false); + // HttpListener accepts on a background task; give it a beat so + // the first request doesn't race the bind. + await Task.Delay(50); + var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") }; + return (server, client, port); + } + + [Fact] + public async Task GetRoot_Returns200_WithServerInfoBody() + { + var (server, client, _) = await BootAsync(); + try + { + var res = await client.GetAsync("/"); + + res.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await res.Content.ReadAsStringAsync(); + body.Should().Contain("\"product\":\"TeamsISO\""); + body.Should().Contain("\"endpoints\""); + } + finally + { + server.Stop(); + client.Dispose(); + } + } + + [Fact] + public async Task GetUnknownPath_Returns200_WithErrorBody() + { + // Quirk: the route table's catch-all arm returns NotFound() (an + // object {error:"not found"}) rather than null, so the response + // pipeline writes 200 OK with that body instead of branching to + // 404. The body is the disambiguator, matching the rest of the + // surface's "200 + {ok:false,error:…}" convention. Pinning this + // so a deliberate move to a true 404 is a conscious decision, + // not an accident. + var (server, client, _) = await BootAsync(); + try + { + var res = await client.GetAsync("/this-route-does-not-exist"); + + res.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await res.Content.ReadAsStringAsync(); + body.Should().Contain("\"error\":\"not found\""); + } + finally + { + server.Stop(); + client.Dispose(); + } + } + + [Fact] + public async Task GetParticipants_Returns200_WithEmptyListWhenNoViewModel() + { + // No dispatcher / no view-model in tests — the endpoint should + // gracefully return participants=[] rather than throwing. + var (server, client, _) = await BootAsync(); + try + { + var res = await client.GetAsync("/participants"); + + res.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await res.Content.ReadAsStringAsync(); + body.Should().Contain("\"participants\":[]"); + } + finally + { + server.Stop(); + client.Dispose(); + } + } + + [Fact] + public async Task PostPresetsRefreshDiscovery_HitsControllerAndReturnsOk() + { + var controller = new StubIsoController(); + var server = new ControlSurfaceServer(controller, () => null, logger: null); + var port = PickFreePort(); + server.Start(port); + await Task.Delay(50); + using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") }; + try + { + var res = await client.PostAsync("/presets/refresh-discovery", content: null); + + res.StatusCode.Should().Be(HttpStatusCode.OK); + controller.RefreshDiscoveryCalled.Should().BeTrue(); + } + finally + { + server.Stop(); + } + } + + [Fact] + public async Task PostPresetApply_MissingPreset_RespondsWithOkFalseAndPresetNotFound() + { + // Preset name that demonstrably doesn't exist on disk → endpoint + // returns 200 with {"ok":false,"error":"preset not found",...}. + // We don't 404 on missing presets because the operator may have + // typed the wrong name; clearer payload is friendlier. + var (server, client, _) = await BootAsync(); + try + { + var res = await client.PostAsync( + "/presets/__nonexistent_preset_for_test__/apply", + content: null); + + res.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await res.Content.ReadAsStringAsync(); + body.Should().Contain("\"ok\":false"); + body.Should().Contain("\"error\":\"preset not found\""); + } + finally + { + server.Stop(); + client.Dispose(); + } + } + + [Fact] + public async Task GetUi_Returns200_WithEmbeddedHtml() + { + var (server, client, _) = await BootAsync(); + try + { + var res = await client.GetAsync("/ui"); + + res.StatusCode.Should().Be(HttpStatusCode.OK); + res.Content.Headers.ContentType?.MediaType.Should().Be("text/html"); + var body = await res.Content.ReadAsStringAsync(); + body.Should().Contain("true added. + Tests cover services that are mostly framework-free, but + ControlSurfaceServer transitively references System.Windows.Threading + (DispatcherTimer) and System.Windows.Application — UseWPF=true pulls + in those types so test code compiles against the App's project + reference without "could not load type" errors at run time. --> net8.0-windows enable enable + true false true