test: ControlSurfaceServer route table smoke coverage

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) <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-15 20:52:36 -04:00
parent fbcc56289e
commit d91f95379b
4 changed files with 315 additions and 4 deletions

View file

@ -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<IReadOnlyList<Participant>> _participants =
new(Array.Empty<Participant>());
private readonly BehaviorSubject<EngineAlert?> _alerts = new(default);
public IObservable<IReadOnlyList<Participant>> Participants => _participants;
public IObservable<EngineAlert> 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<Guid, IsoHealthStats>? GetStatsHandler { get; set; }
public Func<Guid, ProcessedFrame?>? GetLatestProcessedFrameHandler { get; set; }
public Func<Guid, FrameProcessingSettings?>? 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<Guid> 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);
}

View file

@ -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("<html", "the response should be a real HTML document");
}
finally
{
server.Stop();
client.Dispose();
}
}
[Fact]
public async Task OptionsRequest_Returns204_WithCorsHeaders()
{
// Companion / browser-based controllers preflight POSTs; the
// server must answer 204 with the allow-origin/allow-methods
// headers or the actual call gets blocked by CORS.
var (server, client, _) = await BootAsync();
try
{
var req = new HttpRequestMessage(HttpMethod.Options, "/participants");
var res = await client.SendAsync(req);
res.StatusCode.Should().Be(HttpStatusCode.NoContent);
res.Headers.GetValues("Access-Control-Allow-Origin").Should().Contain("*");
}
finally
{
server.Stop();
client.Dispose();
}
}
}

View file

@ -1,3 +1,4 @@
using System.IO;
using System.Text; using System.Text;
using FluentAssertions; using FluentAssertions;
using TeamsISO.App.Services; using TeamsISO.App.Services;

View file

@ -6,15 +6,17 @@
because TeamsISO.App is net8.0-windows + WinExe — a pure net8.0 test because TeamsISO.App is net8.0-windows + WinExe — a pure net8.0 test
project can't reference it. project can't reference it.
We DON'T reference WPF or System.Windows here — the tests cover services Tests cover services that are mostly framework-free, but
that are intentionally framework-free even though they live in the host ControlSurfaceServer transitively references System.Windows.Threading
assembly. Future test cases that touch WPF types (e.g. WriteableBitmap) (DispatcherTimer) and System.Windows.Application — UseWPF=true pulls
would need <UseWPF>true</UseWPF> added. in those types so test code compiles against the App's project
reference without "could not load type" errors at run time.
--> -->
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>