using System; using System.IO; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using Microsoft.Data.Sqlite; using Xunit; using ytLive.Services; using ytLive.ViewModels; namespace ytLive.Tests; /// /// TASK 32·5 Good Dog: "I forgot the VPN" used to create a broadcast on YouTube that nothing ever /// pushed to. Go-live now probes the ingest with one TCP connect first; a dead network costs two /// seconds and leaves nothing behind on YouTube. /// The gate is asserted through the view-model's own entry point, and the probe is asserted /// against real sockets — a listening port and a closed one — so neither half is a mock talking to a /// mock. /// [Collection("RealApp")] public sealed class IngestPreflightTests { private readonly RealAppHost _app; public IngestPreflightTests(RealAppHost app) => _app = app; [Fact] public async Task UnreachableIngest_AbortsGoLive() { await _app.RunAsync(async () => { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-ingest-{Guid.NewGuid():N}.db"); MainViewModel.LayoutPathOverride = tempDb; MainViewModel.IngestProbeOverride = new FakeProbe(false); var window = new MainWindow(); var vm = (MainViewModel)window.DataContext; try { Assert.False(await vm.IngestPreflightAsync("rtmp://a.rtmp.youtube.com/live2/key"), "an unreachable ingest must abort go-live before anything is created on YouTube"); Assert.False(vm.IsLive); } finally { window.Close(); MainViewModel.IngestProbeOverride = null; MainViewModel.LayoutPathOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } }); } [Fact] public async Task ReachableIngest_LetsGoLiveContinue() { await _app.RunAsync(async () => { var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-ingest-{Guid.NewGuid():N}.db"); MainViewModel.LayoutPathOverride = tempDb; MainViewModel.IngestProbeOverride = new FakeProbe(true); var window = new MainWindow(); var vm = (MainViewModel)window.DataContext; try { Assert.True(await vm.IngestPreflightAsync("rtmp://a.rtmp.youtube.com/live2/key")); } finally { window.Close(); MainViewModel.IngestProbeOverride = null; MainViewModel.LayoutPathOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } }); } [Fact] public void TheProbe_ParsesTheIngestUrl_AndDefaultsToTheRtmpPort() { Assert.True(TcpIngestProbe.TryParse("rtmp://a.rtmp.youtube.com/live2/key", out var host, out var port)); Assert.Equal("a.rtmp.youtube.com", host); Assert.Equal(1935, port); Assert.True(TcpIngestProbe.TryParse("rtmps://a.rtmps.youtube.com/live2/key", out _, out var securePort)); Assert.Equal(443, securePort); Assert.False(TcpIngestProbe.TryParse("not a url", out _, out _)); Assert.False(TcpIngestProbe.TryParse("", out _, out _)); } [Fact] public async Task TheProbe_AnswersFromRealSockets() { // A listener on loopback: reachable. var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; try { var probe = new TcpIngestProbe(TimeSpan.FromSeconds(2)); Assert.True(await probe.CanReachAsync($"rtmp://127.0.0.1:{port}/live2/key")); } finally { listener.Stop(); } // The same port, now closed: unreachable, and it must answer quickly rather than hang. var closed = new TcpIngestProbe(TimeSpan.FromMilliseconds(400)); var clock = System.Diagnostics.Stopwatch.StartNew(); Assert.False(await closed.CanReachAsync($"rtmp://127.0.0.1:{port}/live2/key")); Assert.True(clock.Elapsed < TimeSpan.FromSeconds(3), $"the probe must fail fast, took {clock.Elapsed.TotalSeconds:0.0}s"); } private sealed class FakeProbe : IIngestProbe { private readonly bool _reachable; public FakeProbe(bool reachable) => _reachable = reachable; public Task CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default) => Task.FromResult(_reachable); } }