Files
LlamaCasty/ytLive.Tests/YouTubeStreamServiceTests.cs
T
gramps 688682d5b5 feat(9): real broadcast close-out — transition(complete) in StopStream after RTMP EOF
The specced 'End stream -> transition(complete)' call never existed: stopping relied
entirely on enableAutoStop (viewers sat on a frozen stream-offline for ~a minute, VOD
finalized late). Found during the 2026-09-01 recording-verification pass while the
creator asked 'if there's proper close-out info yt needs, we'll provide it?'

EndBroadcastAsync POSTs liveBroadcasts/transition?broadcastStatus=complete&id=..&part=status,
called after the pump stops (RTMP EOF first) and only when a live session had a broadcast —
record-only stops stay offline. invalidTransition/410 (autoStop already ended it) is logged
and returned as an error string, never thrown: a stop must never fail over close-out.
ONE integration test (URL shape + never-throws on 403). ai.md/TASKS.md design lines marked
SHIPPED with the map-lie note.

Ref: https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition
2026-09-01 20:55:06 -07:00

268 lines
11 KiB
C#

using System.Net;
using System.Text;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// The one integration test for ship step 7 (private-only go live): the
/// broadcast-insert request must carry privacyStatus "private" — the dialog is
/// locked to Private and the service must never send anything else.
/// </summary>
public class YouTubeStreamServiceTests
{
private sealed class RecordingHandler : HttpMessageHandler
{
public string? LastBody;
public string ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}""";
public Func<HttpRequestMessage, string?>? ResponseSelector;
public HttpStatusCode StatusCode = HttpStatusCode.OK;
public readonly List<string> Requests = new();
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add($"{request.Method.Method} {request.RequestUri}");
LastBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
var body = ResponseSelector?.Invoke(request) ?? ResponseBody;
return Task.FromResult(new HttpResponseMessage(StatusCode)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
});
}
}
private static YouTubeAuthService CreateAuth() => new("test-id", "test-secret");
private static YouTubeAuthService CreateAuthed()
{
var auth = CreateAuth();
auth.SetSession(new YouTubeChannel
{
AccessToken = "acc-123",
TokenExpiry = DateTime.UtcNow.AddHours(1),
});
return auth;
}
[Fact]
public async Task CreateBroadcast_Always_Sends_Private_PrivacyStatus()
{
var handler = new RecordingHandler();
var auth = CreateAuth();
auth.SetSession(new YouTubeChannel
{
AccessToken = "acc-123",
TokenExpiry = DateTime.UtcNow.AddHours(1),
});
var service = new YouTubeStreamService(auth, new HttpClient(handler));
var id = await service.CreateBroadcast("Test Stream", "A description", DateTime.UtcNow);
Assert.Equal("BC123", id);
Assert.NotNull(handler.LastBody);
Assert.Contains("\"privacyStatus\":\"private\"", handler.LastBody);
}
[Fact]
public async Task CreateBroadcast_Without_Session_Returns_Null()
{
var handler = new RecordingHandler();
var auth = CreateAuth(); // no session
var service = new YouTubeStreamService(auth, new HttpClient(handler));
var id = await service.CreateBroadcast("Test Stream", "A description", DateTime.UtcNow);
Assert.Null(id);
Assert.Null(handler.LastBody); // never reached the API
}
// TASK 5: a reusable stream, when given, binds at broadcast insert via
// contentDetails.boundStreamId — no separate bind round-trip. One-click
// go-live also completes the v3 flags (no monitor stream, low latency).
[Fact]
public async Task CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert()
{
var handler = new RecordingHandler { ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}""" };
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var id = await service.CreateBroadcast("Test Stream", "A description", DateTime.UtcNow, "S456");
Assert.Equal("BC123", id);
Assert.NotNull(handler.LastBody);
Assert.Contains("\"boundStreamId\":\"S456\"", handler.LastBody);
Assert.Contains("\"enableMonitorStream\":false", handler.LastBody);
Assert.Contains("\"latencyPreference\":\"low\"", handler.LastBody);
Assert.Contains("\"privacyStatus\":\"private\"", handler.LastBody);
}
// TASK 5 design decision 2: reuse the existing cdn.isReusable stream instead
// of creating a new one per broadcast.
[Fact]
public async Task GetOrCreateReusableStreamAsync_Reuses_Existing_Reusable_Stream()
{
var handler = new RecordingHandler
{
ResponseBody = """
{"items":[{"id":"S456","cdn":{"isReusable":true,"ingestionInfo":{"ingestionAddress":"rtmp://a.rtmp.youtube.com/live2","streamName":"KEY123"}}}]}
"""
};
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var stream = await service.GetOrCreateReusableStreamAsync();
Assert.NotNull(stream);
Assert.Equal("S456", stream.Id);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2", stream.IngestionAddress);
Assert.Equal("KEY123", stream.StreamName);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", stream.RtmpUrl);
Assert.Contains(handler.Requests, r => r.StartsWith("GET") && r.Contains("liveStreams") && r.Contains("mine=true"));
Assert.DoesNotContain(handler.Requests, r => r.StartsWith("POST"));
}
// TASK 5: first-ever use creates the one reusable stream — variable
// resolution/frame rate so any quality tier works without recreation.
[Fact]
public async Task GetOrCreateReusableStreamAsync_Creates_When_None_Exists()
{
var handler = new RecordingHandler
{
ResponseSelector = request => request.Method.Method == "GET"
? """{"items":[]}"""
: """{"id":"S789","cdn":{"ingestionInfo":{"ingestionAddress":"rtmp://a.rtmp.youtube.com/live2","streamName":"KEY789"}}}"""
};
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var stream = await service.GetOrCreateReusableStreamAsync();
Assert.NotNull(stream);
Assert.Equal("S789", stream.Id);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY789", stream.RtmpUrl);
var post = Assert.Single(handler.Requests, r => r.StartsWith("POST"));
Assert.Contains("liveStreams", post);
Assert.Contains("\"isReusable\":true", handler.LastBody);
Assert.Contains("\"resolution\":\"variable\"", handler.LastBody);
Assert.Contains("\"frameRate\":\"variable\"", handler.LastBody);
}
[Fact]
public async Task GetOrCreateReusableStreamAsync_Without_Session_Returns_Null()
{
var handler = new RecordingHandler();
var service = new YouTubeStreamService(CreateAuth(), new HttpClient(handler));
var stream = await service.GetOrCreateReusableStreamAsync();
Assert.Null(stream);
Assert.Empty(handler.Requests); // never reached the API
}
// TASK 5 item 3: liveStreams.status — healthStatus + configurationIssues[]
// parsed into the report-by-exception surface.
[Fact]
public async Task GetStreamHealthAsync_Parses_Status_And_Issue_Severities()
{
var handler = new RecordingHandler
{
ResponseBody = """
{"items":[{"status":{"healthStatus":"bad","configurationIssues":[
{"type":"bitrateHigh","severity":"error"},
{"type":"reconnect","severity":"warning"}]}}]}
"""
};
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var health = await service.GetStreamHealthAsync("S456");
Assert.NotNull(health);
Assert.Equal("bad", health.HealthStatus);
Assert.Equal(2, health.ConfigurationIssues.Count);
Assert.Equal(StreamIssueSeverity.Error, health.ConfigurationIssues[0].Severity);
Assert.Equal("bitrateHigh", health.ConfigurationIssues[0].Type);
Assert.Equal(StreamIssueSeverity.Warning, health.ConfigurationIssues[1].Severity);
Assert.Contains(handler.Requests, r => r.Contains("liveStreams") && r.Contains("part=status"));
}
[Fact]
public async Task GetStreamHealthAsync_Good_Health_Yields_No_Issues()
{
var handler = new RecordingHandler { ResponseBody = """{"items":[{"status":{"healthStatus":"good"}}]}""" };
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var health = await service.GetStreamHealthAsync("S456");
Assert.Equal("good", health?.HealthStatus);
Assert.Empty(health?.ConfigurationIssues ?? []);
}
[Fact]
public async Task GetStreamHealthAsync_Without_Session_Returns_Null()
{
var handler = new RecordingHandler();
var service = new YouTubeStreamService(CreateAuth(), new HttpClient(handler));
var health = await service.GetStreamHealthAsync("S456");
Assert.Null(health);
Assert.Empty(handler.Requests); // never reached the API
}
/// <summary>The ONE integration test for TASK 5 item 3 (report-by-exception):
/// a healthy stream (good, no issues) produces no banner; a mid-stream
/// problem (bad + error configuration issue) drives the banner text and its
/// error color — through the real service JSON parse into the real reporter.</summary>
[Fact]
public async Task GetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error()
{
var handler = new RecordingHandler
{
ResponseBody = """{"items":[{"status":{"healthStatus":"good"}}]}"""
};
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var healthy = await service.GetStreamHealthAsync("S456");
Assert.Equal("good", healthy?.HealthStatus);
Assert.Empty(healthy?.ConfigurationIssues ?? []);
Assert.Null(StreamHealthReporter.BannerFor(healthy!.ConfigurationIssues).Text);
handler.ResponseBody = """
{"items":[{"status":{"healthStatus":"bad","configurationIssues":[
{"type":"bitrateHigh","severity":"error"}]}}]}
""";
var degraded = await service.GetStreamHealthAsync("S456");
Assert.Equal("bad", degraded?.HealthStatus);
var report = StreamHealthReporter.BannerFor(degraded!.ConfigurationIssues);
Assert.Equal("bitrateHigh", report.Text);
Assert.True(report.IsError);
}
// TASK 9 close-out (2026-09-01): the ONE integration test — the transition URL
// shape (broadcastStatus=complete + id + part, POST) and the never-throws
// contract when YouTube already auto-completed the broadcast (invalidTransition).
[Fact]
public async Task EndBroadcast_Transitions_Complete_And_Never_Throws_On_InvalidTransition()
{
var handler = new RecordingHandler();
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var error = await service.EndBroadcastAsync("BC-42");
Assert.Null(error);
var request = Assert.Single(handler.Requests);
Assert.StartsWith("POST ", request);
Assert.Contains("/liveBroadcasts/transition?", request);
Assert.Contains("broadcastStatus=complete", request);
Assert.Contains("id=BC-42", request);
Assert.Contains("part=status", request);
// Already auto-ended: 403 invalidTransition must surface as an error string,
// never an exception — the stop path must complete regardless (autoStop backstop).
handler.StatusCode = HttpStatusCode.Forbidden;
handler.ResponseBody = """{"error":{"code":403,"message":"Invalid transition"}}""";
var closeOutError = await service.EndBroadcastAsync("BC-42");
Assert.NotNull(closeOutError);
Assert.Contains("403", closeOutError);
}
}