Files
LlamaCasty/ytLive.Tests/TestStreamTests.cs
T
gramps 0981a72eea fix(stream): chat insert 400 — insert body must declare snippet.type
The liveChatId fix (TASK 44) worked on the next Test Stream, but every Mock
Chat Input send returned 'YouTube rejected the message (error 400)'. The
runtime log's body: 400 MISSING_REQUIRED_FIELD, domain
youtube.api.v3.LiveChatMessageInsertResponse.Error.

The liveChat/messages.insert snippet requires type ('textMessageEvent' or
'pollEvent') alongside liveChatId and textMessageDetails.messageText; the
TASK 41 body omitted it, and the Good Dog test asserted only liveChatId +
messageText were present — false-green while real YouTube rejected every
send. Fix body + assert type in the same test so the field can never drop
silently again.

Reference: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/insert

Good Dog: ONE integration test (strengthened TestStream_DockTooling...).
Gate: clean build 0 warnings; full vstest 317/318 (the 1 failure is the
known environmental RealMouseDrag flake — passes 3/3 in isolation).
2026-09-25 08:12:05 -07:00

195 lines
8.0 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TASK 41 Good Dog: Test Stream mode. Two sections under one [Fact]:
/// (1) the Test drawer's chat tooling through the coordinator VM (pure services, fake
/// HTTP — no network, no WPF): BeginTest opens the dock, Mock Chat Input emits a real
/// liveChat/messages.insert (POST body asserted), simulated member / SuperChat /
/// subscriber events arrive through the MessageReceived seam marked IsSimulated,
/// End asks the app to stop, EndTestSilently clears the session; (2) a real MainWindow
/// (temp-DB + RealAppHost, mirrors YppPullOutTests) enforces three-way one-open-at-a-time
/// drawer exclusivity and the Test-button sign-in gate.
/// </summary>
[Collection("RealApp")]
public sealed class TestStreamTests
{
private readonly RealAppHost _app;
public TestStreamTests(RealAppHost app) => _app = app;
private sealed class RecordingHandler : HttpMessageHandler
{
public string? LastBody;
public string ResponseBody = """{"id":"msg-1"}""";
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();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"),
});
}
}
private static YouTubeAuthService CreateAuthed()
{
var auth = new YouTubeAuthService("test-id", "test-secret");
auth.SetSession(new YouTubeChannel
{
AccessToken = "acc-123",
TokenExpiry = DateTime.UtcNow.AddHours(1),
});
return auth;
}
[Fact]
public void TestStream_DockTooling_And_ThreeWayDrawerExclusivity()
{
RunCoordinator();
_app.Run(RunWindow);
}
private void RunCoordinator()
{
var handler = new RecordingHandler();
var stream = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var chat = new YouTubeChatService(CreateAuthed());
var received = new List<ChatMessage>();
chat.MessageReceived += received.Add;
var ended = false;
var vm = new TestSessionViewModel(stream, chat, onEndStream: () => ended = true);
Assert.False(vm.IsTestSession, "idle until a test broadcast hands in");
Assert.False(vm.IsDrawerOpen);
Assert.False(vm.CanSendTestMessage);
// The boot path: MainViewModel opens the dock once the liveChatId resolves.
vm.BeginTest("BC-TEST", "Cg0KC0NOTIFTEST");
Assert.True(vm.IsTestSession);
Assert.True(vm.IsDrawerOpen, "BeginTest opens the dock so it's immediately usable");
Assert.Contains("Cg0KC0NOTIFTEST", vm.ChatIdLine);
Assert.False(vm.CanSendTestMessage, "no draft text yet — an empty send is disabled");
// Mock Chat Input → a REAL liveChat/messages.insert (POST body asserted).
vm.SendMessageText = "hello test chat";
Assert.True(vm.CanSendTestMessage);
vm.SendTestMessageAsync().GetAwaiter().GetResult();
Assert.NotNull(handler.LastBody);
Assert.Contains("\"liveChatId\":\"Cg0KC0NOTIFTEST\"", handler.LastBody);
Assert.Contains("\"messageText\":\"hello test chat\"", handler.LastBody);
Assert.Contains("\"type\":\"textMessageEvent\"", handler.LastBody);
Assert.Empty(received);
Assert.Contains("hello test chat", vm.LastEventText);
// Simulated events arrive through the poller's MessageReceived seam,
// marked simulated so the buffer stays honest (they never reach YouTube).
vm.SimulateMemberCommand.Execute(null);
vm.SimulateSuperChatCommand.Execute(null);
vm.SimulateSubscriberCommand.Execute(null);
Assert.Equal(3, received.Count);
Assert.All(received, m => Assert.True(m.IsSimulated));
var member = received[0];
Assert.True(member.IsMember);
Assert.Equal("New member", member.MembershipLevel);
var superChat = received[1];
Assert.True(superChat.IsSuperChat);
Assert.Equal(5.00, superChat.SuperChatAmount, 2);
Assert.Equal("$", superChat.SuperChatCurrency);
var subscriber = received[2];
Assert.False(subscriber.IsMember);
Assert.False(subscriber.IsSuperChat);
Assert.Contains("subscriber", subscriber.Message, StringComparison.OrdinalIgnoreCase);
// The End Test button hands back to the app (MainViewModel.StopStream).
vm.EndTestCommand.Execute(null);
Assert.True(ended);
// The app's stop path forgets the session — idempotent.
vm.EndTestSilently();
vm.EndTestSilently();
Assert.False(vm.IsTestSession);
Assert.False(vm.CanSendTestMessage);
}
private void RunWindow()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
MainWindow? window = default;
try
{
using (var schema = new LayoutStore(tempDb)) { }
SqliteConnection.ClearAllPools();
// ─── Section 2: real window — three-way exclusivity + gate ───
window = new MainWindow();
var model = (MainViewModel)window.DataContext;
Assert.False(model.BroadcastForm.IsDrawerOpen);
Assert.False(model.Ypp.IsDrawerOpen);
Assert.False(model.TestSession.IsDrawerOpen);
// Test-button gate: Test is a child of Stream — it procs ONLY when the
// ON-AIR world is armed AND signed in (title-gating ruling). The initial
// sign-in state is environment-dependent (a dev machine may hold a real
// saved OAuth session), so it is forced deterministically.
model.IsConnected = false;
Assert.False(model.CanStartTest);
Assert.False(model.ShowTestButton);
model.OnAirPillOn = true; // stream world
model.IsConnected = true;
Assert.True(model.CanStartTest);
Assert.True(model.ShowTestButton);
model.RecordPillOn = true; // back to the record world → Test retires
Assert.False(model.CanStartTest);
Assert.False(model.ShowTestButton);
model.TestSession.ToggleDrawerCommand.Execute(null);
Assert.True(model.TestSession.IsDrawerOpen);
Assert.False(model.BroadcastForm.IsDrawerOpen, "opening TEST must leave Stream Settings closed");
Assert.False(model.Ypp.IsDrawerOpen, "opening TEST must leave YPP closed");
model.BroadcastForm.ToggleDrawerCommand.Execute(null);
Assert.True(model.BroadcastForm.IsDrawerOpen);
Assert.False(model.TestSession.IsDrawerOpen, "opening Stream Settings must close TEST");
model.Ypp.ToggleDrawerCommand.Execute(null);
Assert.True(model.Ypp.IsDrawerOpen);
Assert.False(model.TestSession.IsDrawerOpen, "opening YPP must leave TEST closed");
Assert.False(model.BroadcastForm.IsDrawerOpen);
model.TestSession.ToggleDrawerCommand.Execute(null);
Assert.True(model.TestSession.IsDrawerOpen);
model.TestSession.CloseDrawerCommand.Execute(null);
Assert.False(model.TestSession.IsDrawerOpen);
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
}