"I forgot the VPN" used to create a broadcast on YouTube that nothing ever pushed to: the app looked
live, YouTube held an empty broadcast, and the creator had to work out what happened. Prevention beats
recovery here, so go-live now asks one question first.
- `Services/IngestProbe.cs` — `IIngestProbe` + `TcpIngestProbe`: parse the RTMP URL, connect to the host
(rtmp → 1935, rtmps → 443) with a 2 s timeout. Any failure answers "not reachable"; the point is a
fast unambiguous answer, not a diagnosis.
- `MainViewModel.IngestPreflightAsync` runs **before the first API call** whenever a cached ingest URL
exists; on a first run it probes once the stream is resolved, still before any broadcast is created.
A false answer logs, tells the creator ("Nothing was created on YouTube…"), and rolls the session
back — nothing to clean up on YouTube.
- The probe is a test seam (`IngestProbeOverride`) like the camera and chroma ones.
Tests: `IngestPreflightTests` — unreachable aborts, reachable continues, URL parsing (rtmp/rtmps,
garbage), and the probe against **real sockets**: a listening port answers true, the same port closed
answers false and does so fast. FAST gate: 547 passed, 0 failed, 0 warnings.
128 lines
4.7 KiB
C#
128 lines
4.7 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
[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<bool> CanReachAsync(string rtmpUrl, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(_reachable);
|
|
}
|
|
}
|