94f015044a
The open creator report ('I still cannot post a chat message in the TEST tab',
feedback 'Chat polling couldn't start — Mock Chat Input is disabled') was a single
cause: the liveChatId never resolved. Two YouTube API facts:
1. The id lives in snippet.liveChatId — contentDetails has no such property
(TASK 44 read part=contentDetails: could never resolve).
https://developers.google.com/youtube/v3/live/docs/liveBroadcasts
2. It only exists once the broadcast is LIVE — the official sample lists
broadcastStatus=active, and our fetch ran before the frame pump pushed RTMP
(enableAutoStart flips ready→live). A ready-state list legitimately returns
no id.
https://github.com/youtube/api-samples/blob/master/java/src/main/java/com/google/api/services/samples/youtube/cmdline/live/GetLiveChatId.java
Fix: GetBroadcastLiveChatIdAsync reads part=snippet and polls with a bounded
retry (10x/2s); PrepareAndStartLiveAsync starts the frame pump FIRST, then
resolves the id. Chat stays non-fatal. Docs ai.md/TASKS.md/HANDOFF.md +
MyMistakes recipe updated same commit. TASK 44.
Good Dog: ONE integration test (GetBroadcastLiveChatIdAsync_Polls_Snippet_...
) drives a broadcast that gains its id mid-retry and asserts every request used
part=snippet. Gate: clean build 0 warnings, vstest 318/318.
336 lines
15 KiB
C#
336 lines
15 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 — 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
|
|
}
|
|
|
|
/// <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":{"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]);
|
|
}
|
|
|
|
/// <summary>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
|
|
/// <c>snippet.liveChatId</c> — 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).</summary>
|
|
[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);
|
|
});
|
|
}
|
|
}
|