Files
LlamaCasty/ytLive.Tests/YppPullOutTests.cs
T
gramps cb750660c3 fix(ypp): refresh 403'd on every real call — drop the auditDetails part (needs a partner scope)
Creator: 'when I attempt to refresh my YPP page, I get an error about not being
able to reach YouTube. Seriously?' Real log: channels.list failed (403) x3.

Root cause #1 (the 403): channels.list?mine=true&part=statistics,auditDetails,
contentDetails returns 403 insufficientPermissions when the token lacks the
youtubepartner-channel-audit scope — which the auditDetails part ALONE requires,
per the docs ('A request that retrieves the auditDetails part ... must provide an
authorization token that contains the youtubepartner-channel-audit scope'). That
scope is MCN partner tooling with a 2-week token-revocation rule; the app must not
hold it. TASK-39's 'current scopes suffice, no re-consent' slice-1 claim was wrong
for this part; mock-fake tests never touched the real API, so it shipped green and
403'd every refresh since 2026-09-22.
https://developers.google.com/youtube/v3/docs/channels/list

Fix: part=statistics,contentDetails only; standing flags removed from
ChannelStatsService -> YppStatSnapshot surface -> YppTrackerViewModel -> drawer,
replaced by an honest deep-link row ('Channel standing isn't exposed to YouTube
apps — check the Earn page'). YppSnapshot standing columns stay (schema-stable,
always false). channels.list failures now log the response BODY — the bare code
could not name insufficientPermissions, which is what made this undiagnosable.

Root cause #2 (found by the new Good Dog, masked by the 403): statistics come back
as JSON STRINGS ('350'); raw GetInt64() throws. Tolerant ReadInt64 (ValueKind-first;
JsonElement.TryGetInt64 THROWS on strings — type-in, not try-type).

Good Dog: ChannelStatsServiceTests.CaptureCurrent_RequestsNoAuditDetails_AndStillParsesTheSnapshot
(URL asserts no auditDetails + snapshot parses); YppPullOutTests fixture updated.
Recipes for both 403/scope and statistics-strings entered in MyMistakes.md.

Also shipped in the same commit (shared PreviewPane.xaml + ai.md): the audio-sync
status dot removal from the #77 feedback round (creator: 'what is the point of the
status light? Lose it') — IntToSyncBrushConverter deleted with it.

verify.sh gate: 0 warnings, 316/316 pass, scope-check clean.
2026-09-23 16:45:56 -07:00

137 lines
5.2 KiB
C#

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TASK 39 Good Dog: the YPP pull-out, one integration test, real-app + temp-DB
/// pattern (mirrors BroadcastPullOutTests / WindowBackdropIntegrationTests).
/// Two sections under one [Fact]: (1) a connected channel captures a snapshot
/// through the VM — progress props populate, the snapshot persists to SQLite,
/// the manual checklist round-trips the Settings store, and the drawer commands
/// toggle; (2) a real MainWindow enforces one-open-at-a-time exclusivity between
/// the Stream Settings and YPP drawers.
/// </summary>
[Collection("RealApp")]
public sealed class YppPullOutTests
{
private readonly RealAppHost _app;
public YppPullOutTests(RealAppHost app) => _app = app;
/// <summary>Returns canned data with no network — the fake bypasses every API call.</summary>
private sealed class FakeChannelStats : ChannelStatsService
{
public FakeChannelStats(YppStatSnapshot snapshot)
: base(new YouTubeAuthService("test-client", "test-secret")) => _snapshot = snapshot;
private readonly YppStatSnapshot _snapshot;
public override Task<YppStatSnapshot?> CaptureCurrentAsync()
=> Task.FromResult<YppStatSnapshot?>(_snapshot);
}
[Fact]
public void Ypp_LoadsSnapshot_PersistsChecklist_And_DrawerExcludesBroadcast()
{
_app.Run(Run);
}
private void Run()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-ypp-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
MainWindow? window = default;
try
{
using (var schema = new LayoutStore(tempDb)) { }
SqliteConnection.ClearAllPools();
// ─── Section 1: connected VM captures + persists ───
var snapshot = new YppStatSnapshot
{
CapturedAtUtc = DateTime.UtcNow,
SubscriberCount = 350,
VideoCount = 42,
TotalViews = 12_345,
UploadsLast90Days = 2,
};
var vm = new YppTrackerViewModel(
() => new LayoutStore(tempDb),
() => new FakeChannelStats(snapshot),
() => true,
() => "Test Channel");
vm.IsDrawerOpen = true;
Assert.True(vm.IsDrawerOpen);
vm.IsDrawerOpen = false;
vm.RefreshAsync().GetAwaiter().GetResult();
Assert.True(vm.HasSnapshot);
Assert.Equal(350, vm.SubscriberCount);
Assert.Equal(2, vm.UploadsLast90Days);
Assert.Equal("Test Channel", vm.DisplayName);
Assert.Equal("350 / 500", vm.Tier1SubsText);
Assert.Equal("350 / 1,000", vm.Tier2SubsText);
Assert.Equal(0.7, vm.Tier1SubsProgress, 3);
Assert.Equal(154, vm.Tier1SubsBarWidth, 0);
Assert.True(vm.Tier2SubsBarWidth < vm.Tier1SubsBarWidth);
Assert.StartsWith("Updated today", vm.LastUpdatedText);
// The snapshot persisted so a later analytics slice has history.
using (var reader = new LayoutStore(tempDb))
{
var history = reader.LoadYppSnapshots();
Assert.Single(history);
Assert.Equal(350, history[0].SubscriberCount);
Assert.Equal(2, history[0].UploadsLast90Days);
}
// The self-reported checklist round-trips the Settings store.
vm.TwoFactorEnabled = true;
vm.AdSenseLinked = false;
using (var reader = new LayoutStore(tempDb))
{
var (twoFactor, adsense) = reader.LoadYppChecklist();
Assert.True(twoFactor);
Assert.False(adsense);
}
// ─── Section 2: real window enforces one-open-at-a-time ───
window = new MainWindow();
var model = (MainViewModel)window.DataContext;
Assert.False(model.BroadcastForm.IsDrawerOpen);
Assert.False(model.Ypp.IsDrawerOpen);
model.Ypp.ToggleDrawerCommand.Execute(null);
Assert.True(model.Ypp.IsDrawerOpen);
Assert.False(model.BroadcastForm.IsDrawerOpen, "opening YPP must leave Stream Settings closed");
model.BroadcastForm.ToggleDrawerCommand.Execute(null);
Assert.True(model.BroadcastForm.IsDrawerOpen);
Assert.False(model.Ypp.IsDrawerOpen, "opening Stream Settings must close YPP");
model.BroadcastForm.ToggleDrawerCommand.Execute(null);
Assert.False(model.BroadcastForm.IsDrawerOpen);
model.Ypp.ToggleDrawerCommand.Execute(null);
Assert.True(model.Ypp.IsDrawerOpen);
model.Ypp.CloseDrawerCommand.Execute(null);
Assert.False(model.Ypp.IsDrawerOpen);
}
finally
{
window?.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
}