using System.Net; using System.Text; using Xunit; using ytLive.Models; using ytLive.Services; namespace ytLive.Tests; /// /// 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. /// public class YouTubeStreamServiceTests { private sealed class RecordingHandler : HttpMessageHandler { public string? LastBody; public string ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}"""; public Func? ResponseSelector; public HttpStatusCode StatusCode = HttpStatusCode.OK; public readonly List Requests = new(); protected override Task 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 — status.healthStatus = {status, // configurationIssues[]} (the REAL API shape — nested object, not a string; // the flat-string shape was our own wrong fixture) parsed into the // report-by-exception surface. This fixture is what a live test/real stream // actually returns — it used to crash the poll with // "requires an element of type 'String'" (2026-09-22 log, two sessions). [Fact] public async Task GetStreamHealthAsync_Parses_Status_And_Issue_Severities() { var handler = new RecordingHandler { ResponseBody = """ {"items":[{"status":{"healthStatus":{"status":"bad","lastUpdateTimeSeconds":1748000000, "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":{"status":"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 } /// 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. [Fact] public async Task GetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error() { var handler = new RecordingHandler { ResponseBody = """{"items":[{"status":{"healthStatus":{"status":"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":{"status":"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, hardened 2026-09-22 (the ONE integration test): the end now // pre-flights the broadcast's OWN lifeCycleStatus before POSTing complete — // every 2026-09-22 end logged 403 invalidTransition (enableAutoStop/auto-stop // raced us to complete), so a blind POST only earned the 403 + noise. This // proves the contract: live → GET pre-check + POST transition; already // complete → skip entirely (zero transition POSTs); an inconclusive pre-check // still posts and a 403 on the POST surfaces as an error string, never a throw. [Fact] public async Task EndBroadcast_Verifies_LifeCycle_Then_Transitions_Complete_Never_Throws() { var handler = new RecordingHandler { ResponseSelector = request => request.Method == HttpMethod.Get ? """{"items":[{"id":"BC-42","status":{"lifeCycleStatus":"live"}}]}""" : """{"id":"BC-42","status":{"lifeCycleStatus":"live"}}""", }; var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler)); // live → pre-check passes → POST complete with the documented URL shape. var error = await service.EndBroadcastAsync("BC-42"); Assert.Null(error); Assert.Equal(2, handler.Requests.Count); Assert.StartsWith("GET ", handler.Requests[0]); Assert.Contains("/liveBroadcasts?", handler.Requests[0]); Assert.Contains("part=status", handler.Requests[0]); Assert.StartsWith("POST ", handler.Requests[1]); Assert.Contains("/liveBroadcasts/transition?", handler.Requests[1]); Assert.Contains("broadcastStatus=complete", handler.Requests[1]); Assert.Contains("id=BC-42", handler.Requests[1]); // Already complete → skip; no transition POST is ever sent, no error. handler.ResponseSelector = request => request.Method == HttpMethod.Get ? """{"items":[{"id":"BC-42","status":{"lifeCycleStatus":"complete"}}]}""" : """{"id":"BC-42"}"""; var skipped = await service.EndBroadcastAsync("BC-42"); Assert.Null(skipped); Assert.Equal(3, handler.Requests.Count); Assert.StartsWith("GET ", handler.Requests[2]); // Inconclusive pre-check (list 403) still posts; a 403 on the POST itself // surfaces as an error string, never an exception (autoStop backstop) — the // stop path must complete regardless. handler.StatusCode = HttpStatusCode.Forbidden; handler.ResponseSelector = null; var closeOutError = await service.EndBroadcastAsync("BC-42"); Assert.NotNull(closeOutError); Assert.Contains("403", closeOutError); Assert.StartsWith("POST ", handler.Requests[^1]); } /// The ONE integration test for the 2026-09-25 TEST-tab chat fix: /// "Chat polling couldn't start — Mock Chat Input is disabled" because the /// liveChatId never resolved. Two facts pin it: (1) the id lives in /// snippet.liveChatId — part=contentDetails could NEVER return it, and /// (2) YouTube only populates it once the broadcast is live, so a single fetch /// right after insert (lifecycleStatus ready) comes back empty. The fix reads /// part=snippet AND polls with a bounded retry — this test drives a fake /// broadcast that starts WITHOUT the id (broadcast not yet live) and gains it on /// a later poll, proving the retry resolves it and that every request asked for /// snippet (never contentDetails). [Fact] public async Task GetBroadcastLiveChatIdAsync_Polls_Snippet_Until_Id_Appears() { var polls = 0; var handler = new RecordingHandler { ResponseSelector = request => { polls++; // First polls: broadcast exists but not yet live → no liveChatId. return polls < 3 ? """{"items":[{"id":"BC-42","snippet":{"title":"t"}}]}""" : """{"items":[{"id":"BC-42","snippet":{"title":"t","liveChatId":"Cg0LIVECHAT42"}}]}"""; } }; var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler)); var chatId = await service.GetBroadcastLiveChatIdAsync("BC-42", maxAttempts: 5, delayMs: 25); Assert.Equal("Cg0LIVECHAT42", chatId); Assert.True(polls >= 3, $"expected the retry to poll until the id appears (polled {polls}x)"); Assert.All(handler.Requests, r => { Assert.Contains("part=snippet", r); Assert.DoesNotContain("part=contentDetails", r); }); } }