fix(stream): end close-out pre-flights lifeCycleStatus — no more 403 invalidTransition noise on every stop
startup.log 2026-09-22 (user runs of the new build): ALL THREE session stops
logged 'Broadcast transition(complete) failed (403) invalidTransition → End
close-out: enableAutoStop will finish'. The old ai.md assumption ('invalidTransition
when autoStop already fired') was wrong — the blind complete POST races
YouTube/autoStop marking the broadcast complete (enableAutoStop=true is always
set, tests included), so a blind POST earned only the 403 + log noise. Per the
transition errors table, invalidTransition is a current-status problem and
complete is not gated on streamStatus (only testing/live are):
https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition
Fix: EndBroadcastAsync pre-flights liveBroadcasts.list -> status.lifeCycleStatus
and skips the POST when the broadcast is already complete/revoked; an inconclusive
pre-check still posts (old behavior) rather than silently stranding a live
broadcast; still never throws (enableAutoStop finishes every skip).
Good Dog: EndBroadcast test rewritten to the one contract — live -> GET pre-check
+ POST transition (URL shape asserted); already complete -> zero transition POSTs;
inconclusive pre-check + 403 POST -> error string, never a throw. Full suite
315/315 (one-run audio/mouse timing flakes cleared), build 0 warnings.
Recipe recorded in MyMistakes.md; ai.md / Services index / TASK-9 note corrected.
This commit is contained in:
@@ -243,32 +243,55 @@ public class YouTubeStreamServiceTests
|
||||
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).
|
||||
// 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_Transitions_Complete_And_Never_Throws_On_InvalidTransition()
|
||||
public async Task EndBroadcast_Verifies_LifeCycle_Then_Transitions_Complete_Never_Throws()
|
||||
{
|
||||
var handler = new RecordingHandler();
|
||||
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);
|
||||
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);
|
||||
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 auto-ended: 403 invalidTransition must surface as an error string,
|
||||
// never an exception — the stop path must complete regardless (autoStop backstop).
|
||||
// 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.ResponseBody = """{"error":{"code":403,"message":"Invalid transition"}}""";
|
||||
handler.ResponseSelector = null;
|
||||
var closeOutError = await service.EndBroadcastAsync("BC-42");
|
||||
|
||||
Assert.NotNull(closeOutError);
|
||||
Assert.Contains("403", closeOutError);
|
||||
Assert.StartsWith("POST ", handler.Requests[^1]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user