TASK 5 items 1-3: reusable stream, health banner, docs refresh
- Reusable stream: GetOrCreateReusableStreamAsync, cached via LayoutStore, bound at broadcast insert - Health monitoring: GetStreamHealthAsync polls liveStreams.status, report-by-exception banner - Go-live order: ensure stream → create broadcast → then start pump (URL must exist first) - Full broadcast form scoped (TASK 5 items 6-7) in TASKS.md + ai.md - TASK 23 trimmed to technical scope, TASK 26 added (marcom/launch kit) - Confidential files: MONETIZATION.md + MARCOM.md (gitignored, not committed) - .gitignore updated for confidential business files - HANDOFF.md updated with session state and next steps
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
@@ -99,6 +101,65 @@ public class FramePumpTests
|
||||
$"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({br},{bg},{bb})");
|
||||
}
|
||||
|
||||
private sealed class FakeHttpHandler : HttpMessageHandler
|
||||
{
|
||||
public FakeHttpHandler(string body) => Body = body;
|
||||
|
||||
public string Body;
|
||||
public string? LastUri;
|
||||
public int RequestCount;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
RequestCount++;
|
||||
LastUri = request.RequestUri?.ToString();
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(Body, Encoding.UTF8, "application/json"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The ONE integration test for TASK 5: the real service (hermetic
|
||||
/// HTTP) returns the reusable stream's RTMP URL, the provider seam hands it
|
||||
/// to the pump, and the pump starts the encoder with that exact URL — the
|
||||
/// chain that makes go-live actually push. The stream is reused (listed),
|
||||
/// never recreated (no POST).</summary>
|
||||
[Fact]
|
||||
public async Task ReusableStream_Url_From_Service_Feeds_Encoder_Startup()
|
||||
{
|
||||
var handler = new FakeHttpHandler(
|
||||
"""
|
||||
{"items":[{"id":"S456","cdn":{"isReusable":true,"ingestionInfo":{"ingestionAddress":"rtmp://a.rtmp.youtube.com/live2","streamName":"KEY123"}}}]}
|
||||
""");
|
||||
var auth = new YouTubeAuthService("test-id", "test-secret");
|
||||
auth.SetSession(new YouTubeChannel
|
||||
{
|
||||
AccessToken = "acc-123",
|
||||
TokenExpiry = DateTime.UtcNow.AddHours(1),
|
||||
});
|
||||
var service = new YouTubeStreamService(auth, new HttpClient(handler));
|
||||
|
||||
var url = (await service.GetOrCreateReusableStreamAsync())?.RtmpUrl;
|
||||
var encoder = new FakeEncoder();
|
||||
using var pump = NewPump(encoder, options: () => url == null
|
||||
? null
|
||||
: new EncoderOptions { RtmpUrl = url, Width = 64, Height = 48, Fps = 60 });
|
||||
|
||||
await pump.StartAsync();
|
||||
|
||||
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", url);
|
||||
Assert.Equal(1, encoder.StartCount);
|
||||
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", encoder.LastOptions?.RtmpUrl);
|
||||
Assert.Equal(1, handler.RequestCount); // reuse path: list only, no insert
|
||||
Assert.NotNull(handler.LastUri);
|
||||
Assert.Contains("liveStreams", handler.LastUri);
|
||||
Assert.Contains("mine=true", handler.LastUri);
|
||||
|
||||
await pump.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_CompositesScene_FeedsEncoder_StopsCleanly()
|
||||
{
|
||||
|
||||
@@ -14,6 +14,35 @@ namespace ytLive.Tests;
|
||||
/// </summary>
|
||||
public class LayoutStorePersistenceTests
|
||||
{
|
||||
// The reusable stream cache (TASK 5): the RTMP URL must survive restarts so
|
||||
// the frame pump has it the moment go-live starts, without a YouTube round-trip.
|
||||
[Fact]
|
||||
public void ReusableStream_Cache_RoundTrips()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
|
||||
try
|
||||
{
|
||||
using (var store = new LayoutStore(path))
|
||||
{
|
||||
store.SaveReusableStream(new ReusableStream("S456", "rtmp://a.rtmp.youtube.com/live2", "KEY123"));
|
||||
var loaded = store.LoadReusableStream();
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("S456", loaded.Id);
|
||||
Assert.Equal("rtmp://a.rtmp.youtube.com/live2", loaded.IngestionAddress);
|
||||
Assert.Equal("KEY123", loaded.StreamName);
|
||||
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", loaded.RtmpUrl);
|
||||
}
|
||||
|
||||
using (var reopened = new LayoutStore(path))
|
||||
Assert.Equal("KEY123", reopened.LoadReusableStream()?.StreamName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(path); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deleted_Webcam_Config_Does_Not_Return_After_Save_And_Reload()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Report-by-exception (TASK 5 item 3): the banner decision is pure so the
|
||||
/// severity/threshold rules are pinned without any HTTP or UI.
|
||||
/// </summary>
|
||||
public class StreamHealthReporterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Banner_Is_Null_For_Empty_Or_Info_Only_Issues()
|
||||
{
|
||||
Assert.Null(StreamHealthReporter.BannerFor([]).Text);
|
||||
|
||||
var report = StreamHealthReporter.BannerFor(new[]
|
||||
{
|
||||
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Info, Type = "lowLatency" },
|
||||
});
|
||||
Assert.Null(report.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Warning_Yields_Text_Without_Error_Color()
|
||||
{
|
||||
var report = StreamHealthReporter.BannerFor(new[]
|
||||
{
|
||||
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Warning, Type = "reconnect" },
|
||||
});
|
||||
Assert.Equal("reconnect", report.Text);
|
||||
Assert.False(report.IsError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_Issue_Yields_Text_And_Error_Color_Even_With_Warnings()
|
||||
{
|
||||
var report = StreamHealthReporter.BannerFor(new[]
|
||||
{
|
||||
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Warning, Type = "reconnect" },
|
||||
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Error, Type = "bitrateHigh" },
|
||||
});
|
||||
Assert.Equal("reconnect, bitrateHigh", report.Text);
|
||||
Assert.True(report.IsError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blank_Issue_Types_Are_Dropped_From_The_Banner()
|
||||
{
|
||||
var report = StreamHealthReporter.BannerFor(new[]
|
||||
{
|
||||
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Error, Type = null },
|
||||
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Error, Type = " " },
|
||||
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Error, Type = "bitrateLow" },
|
||||
});
|
||||
Assert.Equal("bitrateLow", report.Text);
|
||||
Assert.True(report.IsError);
|
||||
}
|
||||
}
|
||||
@@ -17,20 +17,35 @@ public class YouTubeStreamServiceTests
|
||||
{
|
||||
public string? LastBody;
|
||||
public string ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}""";
|
||||
public Func<HttpRequestMessage, string?>? ResponseSelector;
|
||||
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(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"),
|
||||
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()
|
||||
{
|
||||
@@ -62,4 +77,163 @@ public class YouTubeStreamServiceTests
|
||||
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 — healthStatus + configurationIssues[]
|
||||
// parsed into the report-by-exception surface.
|
||||
[Fact]
|
||||
public async Task GetStreamHealthAsync_Parses_Status_And_Issue_Severities()
|
||||
{
|
||||
var handler = new RecordingHandler
|
||||
{
|
||||
ResponseBody = """
|
||||
{"items":[{"status":{"healthStatus":"bad","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":"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":"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":"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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user