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:
+39
-31
@@ -1,18 +1,41 @@
|
||||
# HANDOFF — 2026-09-22 (TASK 42 committed; health-poll parse fix in flight)
|
||||
# HANDOFF — 2026-09-22 (TASK 42 committed; health-poll + end-race fixes tracked)
|
||||
|
||||
## Branch / Commit State
|
||||
|
||||
`main` HEAD = **`e69db4d` — feat(ui): TASK 42 top bar redesign …** (committed).
|
||||
Pushed state at `197ee81` (verified from the app-build the user ran after the commit). **Working
|
||||
tree DIRTY — health-poll parse bug fix, one small change (code + tests + docs):**
|
||||
`main` HEAD = **`e69db4d`** (TASK 42 top bar) → **`18ab553`** (health-poll fix, committed).
|
||||
Pushed state at `197ee81`. **Working tree DIRTY — end-of-stream race fix (one small change):**
|
||||
|
||||
```
|
||||
M Services/YouTubeStreamService.cs (parse fix)
|
||||
M ytLive.Tests/YouTubeStreamServiceTests.cs (fixtures flipped to REAL api shape)
|
||||
M ai.md, Services/index.md, MyMistakes.md
|
||||
M Services/YouTubeStreamService.cs (EndBroadcastAsync pre-flight)
|
||||
M ytLive.Tests/YouTubeStreamServiceTests.cs (end-close-out test rewritten)
|
||||
M ai.md, Services/index.md, TASKS/task-09-live-stream-management.md, MyMistakes.md
|
||||
M HANDOFF.md (this rewrite)
|
||||
```
|
||||
|
||||
## Shipped since last handoff
|
||||
|
||||
- **TASK 42 top bar redesign** `e69db4d` — see below.
|
||||
- **Health-poll parse fix** `18ab553` — `GetStreamHealthAsync` read `healthStatus` as
|
||||
a string; the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds,
|
||||
configurationIssues[]}` (an OBJECT) → every 2026-09-22 session start logged
|
||||
`requires an element of type 'String'`. Fixed to read the nested shape, tolerating the
|
||||
old flat shape; the report-by-exception health banner is alive again.
|
||||
|
||||
## In flight — end-of-stream race fix (end close-out, 2026-09-22)
|
||||
|
||||
`startup.log` showed `Broadcast transition(complete) failed (403) invalidTransition →
|
||||
enableAutoStop will finish` on ALL THREE 2026-09-22 stops (17:09 crash + both test
|
||||
sessions). NOT the old "autoStop already fired" assumption (ai.md had it wrong): the
|
||||
blind `transition(complete)` POST races YouTube/autoStop marking the broadcast complete
|
||||
(via `enableAutoStop=true`, always set — tests included). Fix in the dirty tree:
|
||||
`EndBroadcastAsync` pre-flights `liveBroadcasts.list→status.lifeCycleStatus` and only
|
||||
POSTs complete from `live`/`testing`, skipping silently when already complete
|
||||
(`enableAutoStop` finishes every skip); an inconclusive pre-check still posts (old
|
||||
behavior, backstopped); still never throws. Cite:
|
||||
https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition (errors
|
||||
table: invalidTransition = current-status problem, and complete is not gated on
|
||||
streamStatus — only testing/live are).
|
||||
|
||||
## What shipped — TASK 42: top bar redesign (2026-09-22)
|
||||
|
||||
Full record: **`TASKS/task-42-top-bar-redesign.md`**. Creator directive: "forget this one dog plan
|
||||
@@ -41,30 +64,15 @@ changing + running reality values. Also folded in: TASK 41's latent **Test pipel
|
||||
`BeginTestStream` now arms `OnAirPillOn` explicitly (unarmed → encoder booted with zero outputs →
|
||||
"At least one output is required" → forced stop cascade). The TestStreamTests gate updated to match.
|
||||
|
||||
## In flight — health-poll parse fix (2026-09-22, from startup.log)
|
||||
|
||||
The user ran the new build and the app's `startup.log` showed `Stream health poll failed: The
|
||||
requested operation requires an element of type 'String', but the target element has type 'Object'.`
|
||||
on EVERY session start (2/2 test sessions). Root cause: `YouTubeStreamService.GetStreamHealthAsync`
|
||||
called `healthStatus.GetString()` but the real API nests `status.healthStatus = {status,
|
||||
lastUpdateTimeSeconds, configurationIssues[]}` — an OBJECT, not a string (docs:
|
||||
https://developers.google.com/youtube/v3/live/docs/liveStreams). Our own flat-string fixture was the
|
||||
wrong assumption from day one; the report-by-exception health banner had been silently dead. Fix in
|
||||
the dirty tree reads the nested `.status` and nested `configurationIssues[]`, tolerating the old flat
|
||||
shape; fixtures flipped to the real shape (the Good Dog). ALSO logged: end-of-session
|
||||
`transition(complete)` 403 `invalidTransition` → enableAutoStop fallback hit all three 2026-09-22
|
||||
stops (17:09 crash + both tests) — known racy design (transition AFTER encoder EOF), NOT in this fix's
|
||||
scope, carried.
|
||||
|
||||
## ⚠️ Around the task
|
||||
|
||||
- **Full-suite run today: 314/315** — the one abort is the pre-existing
|
||||
- **Full-suite run today: 314/315** (first pass) — the one abort is the pre-existing
|
||||
`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`, which injects
|
||||
REAL physical mouse input (`SetCursorPos` + `mouse_event`, see its own docstring) and no-ops while
|
||||
another window interferes. Cause observed 2026-09-22: **Path of Exile 2 + NVIDIA Overlay were
|
||||
running** (fullscreen-game window hides the test window's rows; the drag never lands → reorder
|
||||
assert fails). Close the game before running that test; it is unrelated to TASK 42 and has passed
|
||||
on clean reruns previously.
|
||||
assert fails). `AudioPipelineTests.Mix_HonorsProviderGains…` also flaked one run (timing under
|
||||
load); both cleared on the 315/315 rerun. Close games before running those tests; unrelated to the
|
||||
recent changes.
|
||||
- **Manual verification owed** (needs a real run on Windows): the app is a new build number when
|
||||
next launched — check the new one-surface bar (switch ↔ worlds, gear menu, reality line).
|
||||
- The app may be **RUNNING** — an in-place build fails MSB3021/MSB3027 until closed (temp-OutDir
|
||||
@@ -80,11 +88,11 @@ scope, carried.
|
||||
|
||||
## Next step
|
||||
|
||||
Scope-check the declared list → build (0 warnings) → full test suite rerun → **one commit** for
|
||||
TASK 42 (docs + code same change). Push only on the user's say-so. Then **TASK 40 App Settings
|
||||
round** is queued (`TASKS/task-40-app-settings-round.md`) — units **A (camera) → C (defaults) → D
|
||||
(accent)** remain (B/gear shipped early); Unit A's SharedReadOnly control-write question is the one
|
||||
genuinely uncertain technical point — run its mitigation ladder before a third guess.
|
||||
Commit this end-of-stream fix (scope-check first, then build 0-warnings + full suite, then ONE
|
||||
commit). Push only on the user's say-so. Then **TASK 40 App Settings round** is queued
|
||||
(`TASKS/task-40-app-settings-round.md`) — units **A (camera) → C (defaults) → D (accent)** remain
|
||||
(B/gear shipped early); Unit A's SharedReadOnly control-write question is the one genuinely
|
||||
uncertain technical point — run its mitigation ladder before a third guess.
|
||||
|
||||
## Critical working rules (unchanged, still binding)
|
||||
|
||||
|
||||
@@ -15,6 +15,19 @@
|
||||
|
||||
## 🔬 Recipes registry
|
||||
|
||||
### TRANSITION(COMPLETE) RACES AUTOSTOP: PRE-FLIGHT lifeCycleStatus (RECIPE)
|
||||
|
||||
A blind `liveBroadcasts.transition?broadcastStatus=complete` POST can 403
|
||||
`invalidTransition` even though the stream just ended normally — 2026-09-22 logged
|
||||
it on EVERY session end. Cause: the broadcast's own state moves toward complete
|
||||
via `enableAutoStop`/YouTube auto-complete; the transition method's errors doc
|
||||
(https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition)
|
||||
shows `invalidTransition` = "can't transition from its current status". A blind
|
||||
POST just races — read the broadcast's `lifeCycleStatus` first
|
||||
(`liveBroadcasts.list?part=status`) and only POST complete from `live`/`testing`;
|
||||
skip silently otherwise and let `enableAutoStop` finish it. Never throw on the
|
||||
close-out either way.
|
||||
|
||||
### YOUTUBE liveStreams LIST: healthStatus IS AN OBJECT, NOT A STRING (RECIPE)
|
||||
|
||||
YouTube Data API v3 `liveStreams.list` nests health under
|
||||
|
||||
@@ -128,14 +128,30 @@ public class YouTubeStreamService
|
||||
/// until then we relied entirely on enableAutoStop, leaving viewers on a frozen
|
||||
/// "stream offline" screen for ~a minute): POST liveBroadcasts.transition
|
||||
/// broadcastStatus=complete. MUST be called AFTER the encoder closed the RTMP
|
||||
/// push so no frames post-date the end. A broadcast YouTube already auto-completed
|
||||
/// answers 403 invalidTransition / 410 — logged and surfaced as an error string,
|
||||
/// never thrown: the stop path must not fail over a cosmetic close-out.</summary>
|
||||
/// push so no frames post-date the end. **Pre-flight (2026-09-22):** the POST is
|
||||
/// now gated on the broadcast's OWN lifeCycleStatus — every 2026-09-22 end
|
||||
/// logged 403 invalidTransition because enableAutoStop/YouTube had already marked
|
||||
/// the broadcast complete; a blind complete only earned the 403 + log noise. We
|
||||
/// skip ONLY on a confirmed already-ended status (complete/revoked) — an
|
||||
/// inconclusive check still posts (old behavior) rather than silently stranding a
|
||||
/// live broadcast. The POST is never-throwing: a rare still-failing transition
|
||||
/// surfaces as an error string, never a throw — the stop path must not fail over a
|
||||
/// cosmetic close-out.</summary>
|
||||
public async Task<string?> EndBroadcastAsync(string broadcastId)
|
||||
{
|
||||
if (!await EnsureToken()) return "not signed in";
|
||||
|
||||
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
|
||||
|
||||
var lifeCycle = await GetLifeCycleStatusAsync(broadcastId);
|
||||
if (lifeCycle is "complete" or "revoked")
|
||||
{
|
||||
// Already ended (autoStop/YouTube raced us) — a blind complete only
|
||||
// earns a 403 + noise. enableAutoStop finished it.
|
||||
AppLog.Write($"End close-out: {broadcastId} already at lifeCycleStatus '{lifeCycle}' — skipping complete");
|
||||
return null;
|
||||
}
|
||||
|
||||
var response = await _http.PostAsync(
|
||||
$"{ApiBase}/liveBroadcasts/transition?broadcastStatus=complete&id={Uri.EscapeDataString(broadcastId)}&part=status",
|
||||
content: null);
|
||||
@@ -146,6 +162,22 @@ public class YouTubeStreamService
|
||||
return $"YouTube rejected the end transition ({(int)response.StatusCode})";
|
||||
}
|
||||
|
||||
/// <summary>One liveBroadcasts.list (part=status) read of the broadcast's own
|
||||
/// lifeCycleStatus — the only reliable "can we complete?" signal. Null when the
|
||||
/// list fails or the status is missing (caller degrades gracefully).</summary>
|
||||
private async Task<string?> GetLifeCycleStatusAsync(string broadcastId)
|
||||
{
|
||||
var response = await _http.GetAsync(
|
||||
$"{ApiBase}/liveBroadcasts?part=status&id={Uri.EscapeDataString(broadcastId)}");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
|
||||
if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) return null;
|
||||
if (!items[0].TryGetProperty("status", out var status)) return null;
|
||||
return status.TryGetProperty("lifeCycleStatus", out var lifeCycle)
|
||||
? lifeCycle.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Returns the channel's reusable stream (TASK 5 design decision 2):
|
||||
/// lists existing streams first and reuses the one with cdn.isReusable=true,
|
||||
/// creating it with variable resolution/frame rate on first use. Binding to a
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ External-facing logic: YouTube API, persistence. See
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
|
||||
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` (NOTE 2026-09-22: the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` as an OBJECT — the parse reads the nested `.status` and tolerates the old flat-string shape; the object shape used to crash every poll) |
|
||||
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` (NOTE 2026-09-22: the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` as an OBJECT — the parse reads the nested `.status` and tolerates the old flat-string shape; the object shape used to crash every poll) | **`EndBroadcastAsync` (2026-09-01; hardened 2026-09-22)** posts `liveBroadcasts/transition?broadcastStatus=complete&part=status` AFTER RTMP EOF with a pre-flight `liveBroadcasts.list` read of the broadcast's OWN `lifeCycleStatus`: only transitions from `live`/`testing`, skips already-complete broadcasts silently (2026-09-22: every blind end logged `invalidTransition` — autoStop races us to complete); `enableAutoStop` finishes every skip |
|
||||
| `StreamHealthReporter.cs` | Pure report-by-exception decision (TASK 9 item 3): `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color |
|
||||
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
|
||||
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) |
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
3. **Report-by-exception** — poll `liveStreams.list`; banner only on `healthStatus` warning/error issues (`configurationIssues[]`). Bottom strip = YouTube logo + green/red connection dot (clickable → opens the dialog).
|
||||
4. **One dialog, three states** — `not connected` (sign-in) / `connected-offline` (all editable) / `live` (title + description + visibility editable; quality + account greyed out). Both entry points (Start Stream button + bottom strip) open it; prefilled from saved session profile.
|
||||
5. **Live edits** — `liveBroadcasts.update` with part=`snippet,status` for title/description/privacy.
|
||||
6. **End stream** — stop encoder → `transition(complete)`, with `enableAutoStop` as the safety net. **SHIPPED 2026-09-01** (`EndBroadcastAsync`, wired into `StopStream` after RTMP EOF; the call had been specced since TASK 9 but never existed — found during recording verification)
|
||||
6. **End stream** — stop encoder → `transition(complete)`, with `enableAutoStop` as the safety net. **SHIPPED 2026-09-01** (`EndBroadcastAsync`, wired into `StopStream` after RTMP EOF; the call had been specced since TASK 9 but never existed — found during recording verification). **Hardened 2026-09-22:** a blind complete 403s `invalidTransition` once autoStop/YouTube marks the broadcast complete (seen on every 2026-09-22 end) — the call now pre-flights `liveBroadcasts.list→lifeCycleStatus` and only transitions from `live`/`testing`, skipping already-complete broadcasts silently (`enableAutoStop` finishes every skip).
|
||||
7. **Broadcast ID == Video ID** — one ID to track status, health, and the auto-created VOD (`recordFromStart` + `enableDvr`).
|
||||
|
||||
### Requirements:
|
||||
|
||||
@@ -1531,7 +1531,7 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
|
||||
- **One dialog, three states** — not connected / connected-offline (all editable) / live
|
||||
(title + description + visibility editable; quality + account greyed out). Both entry points
|
||||
(Start Stream button + bottom strip) open it; prefilled from saved session profile.
|
||||
- **End stream (SHIPPED 2026-09-01)** — stop encoder → `YouTubeStreamService.EndBroadcastAsync` POSTs `liveBroadcasts/transition?broadcastStatus=complete&id=…&part=status` (AFTER RTMP EOF, so no frames post-date the end — VOD finalizes immediately instead of ~1min of frozen "stream offline"), log-only on failure (`invalidTransition` when autoStop already fired — never throws, never toasts a finished session); `enableAutoStop` remains the safety net. The design had always called for this call; it was never built until the recording-verification session caught the gap. Record-only stops make ZERO API calls (guard: `wasLive && _currentBroadcastId != null`).
|
||||
- **End stream (SHIPPED 2026-09-01; pre-flight hardened 2026-09-22)** — stop encoder → `YouTubeStreamService.EndBroadcastAsync` POSTs `liveBroadcasts/transition?broadcastStatus=complete&id=…&part=status` (AFTER RTMP EOF, so no frames post-date the end — VOD finalizes immediately instead of ~1min of frozen "stream offline"). **2026-09-22: every end logged 403 `invalidTransition`** — a blind complete races enableAutoStop/YouTube's auto-complete (the old doc's "already fired" guess was wrong; it's a broadcast state race). The call now pre-flights `liveBroadcasts.list→status.lifeCycleStatus` and only transitions from `live`/`testing`, skipping silently otherwise (`enableAutoStop` finishes every skip — never throws, never toasts a finished session). Inconclusive pre-check still posts (old behavior, backstopped). Record-only stops make ZERO API calls (guard: `wasLive && _currentBroadcastId != null`).
|
||||
- **Encoder compliance** — keyframes ≤ 4s (gopSizeLong), closed GOP, H.264, AAC/MP3 @ 44.1/48kHz,
|
||||
mono/stereo only. YouTube flags violations via health status.
|
||||
- **Broadcast ID == Video ID** — one ID tracks status, health, and the auto-created VOD
|
||||
|
||||
@@ -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