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);
}
}