Files
LlamaCasty/ytLive.Tests/YouTubeStreamServiceTests.cs
T
gramps 07a088a23f Bind broadcasts explicitly; the week-long RTMP dead end was never a bind
root cause (proven, not inferred): contentDetails.boundStreamId on
liveBroadcasts.insert does NOT bind the broadcast. Our diagnostic reads
lifeCycle=created privacy=private autoStart=true bound=NONE
boundMatches=NO-BINDING | streamStatus=active

created is Google's "incomplete settings" state, so enableAutoStart has no
bound stream to fire from; close-out then 403s invalidTransition because
created -> complete is not a legal edge. A reusable stream accepts bytes for
a broadcast that isn't watching it, so a perfectly healthy 99MB RTMP session
is exactly what an unbound broadcast looks like -- every observable pointed
away from the binding.

Google's migration guide states the rule outright: a liveBroadcast must be
bound to exactly one stream before it can start, and is not bound at creation.
That was in the docs read during the original design and was not applied.

Shipped:
- BindBroadcastAsync -> POST liveBroadcasts/bind?part=id&id&streamId (no body),
  called from PrepareAndStartLiveAsync right after CreateBroadcast, before the pump.
  Failure is fatal to the session: an unbound broadcast can never go live, so a
  live-looking UI over one is a lie.
- boundStreamId removed from the insert body, and the streamId parameter removed
  from CreateBroadcast. A parameter that looks meaningful but cannot affect the
  outcome is what made the misdiagnosis possible.
- Replaced CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert, which
  asserted the disproven behaviour and therefore laundered a guess into a
  guarantee, with tests for the omitted field and the real bind endpoint.

Error messages (creator ruling 2026-10-04): bind failures are not all "check the
sign-in". Six documented reasons exist and only one is auth; BindFailureMessage
gives each its own text, and the fallback passes the raw reason through verbatim
so an unmapped reason stays greppable. liveBroadcastBindingNotAllowed matters
most -- it means our ordering is wrong, and a generic re-auth message would hide
a bug in our own code behind a plausible excuse. See MyMistakes.md.

ai.md: also corrects the TASK 44 claim that liveChatId is only populated once the
broadcast is live. The diagnostic shows it present while lifecycle is created.
That claim was inferred from a sample's active-status guard, which is not a spec.

Suite 395/395, 0 warnings.
2026-10-04 15:27:01 -07:00

371 lines
17 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: one-click go-live completes the v3 flags (no monitor stream, low latency).
//
// ⛔ REWRITTEN 2026-10-04. This used to be
// `CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert`, and it asserted
// `"boundStreamId":"S456"` in the insert body — i.e. it encoded, as a passing test,
// the exact assumption that cost a week of misdiagnosis. The assumption is now
// DISPROVEN: the insert is accepted, its sibling fields take effect, and the created
// broadcast still comes back `bound=NONE`, stuck in lifeCycleStatus `created`, never
// live, with close-out 403ing `invalidTransition`. A test that pins disproven
// behaviour is worse than no test — it launders a guess into a guarantee.
//
// So the guarantee is inverted: assert the insert does NOT claim to bind, and assert
// the real binding call targets the documented bind endpoint with both ids.
[Fact]
public async Task CreateBroadcast_Does_Not_Claim_To_Bind_And_Omits_BoundStreamId()
{
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);
Assert.Equal("BC123", id);
Assert.NotNull(handler.LastBody);
// The trap: boundStreamId reads as a binding and is not one.
Assert.DoesNotContain("boundStreamId", handler.LastBody);
// The flags that DO take effect must still be sent.
Assert.Contains("\"enableMonitorStream\":false", handler.LastBody);
Assert.Contains("\"latencyPreference\":\"low\"", handler.LastBody);
Assert.Contains("\"privacyStatus\":\"private\"", handler.LastBody);
}
/// <summary>The call that actually binds — the one whose absence is why nothing ever
/// went live. Google's documented shape is
/// `POST liveBroadcasts/bind?part=id&id=…&streamId=…` with NO request body (an empty
/// body would be rejected). The parameter is `id`, not `broadcastId` — a plausible
/// near-miss that returns 400 `idRequired`.</summary>
[Fact]
public async Task BindBroadcastAsync_Posts_To_Bind_Endpoint_With_Both_Ids_And_No_Body()
{
var handler = new RecordingHandler { ResponseBody = """{"id":"BC123"}""" };
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var error = await service.BindBroadcastAsync("BC123", "S456");
Assert.Null(error);
var request = Assert.Single(handler.Requests);
Assert.StartsWith("POST ", request);
Assert.Contains("/liveBroadcasts/bind", request);
Assert.Contains("part=id", request);
Assert.Contains("id=BC123", request);
Assert.Contains("streamId=S456", request);
Assert.True(string.IsNullOrEmpty(handler.LastBody), "bind takes no request body");
}
// 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);
});
}
}