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