feat(ypp): session report derived from YouTube — no ledger, money off by default (TASK 3·20)

The task asked for a `RewardEvents` table plus a backfill. That would have contradicted the published
promise that no viewer, member or donor information is retained after the session ends, and it would
have meant owning a ledger that YouTube already keeps.

So the report is DERIVED on demand instead:

- `Services/SuperChatEventService.cs` — `superChatEvents.list`, parsed for both event types (a sticker
  must not be dropped), authorised by the existing `auth/youtube` scope, so no re-consent.
- `Models/SessionRewardSummary.cs` — rolls events up for the session window with **per-currency totals
  that are never summed together** (a $5 and a €5 are not $10). Nothing is written down.
- The YPP drawer gains a **THIS SESSION** section: counts are neutral and always shown when there were
  events; money appears only when the creator asks for it. `Show monetary metrics` is a drawer toggle,
  persisted, **off by default** — a creator who never monetized is never shown a $0.00.

Also: money is labelled as estimates (removed/refunded events are excluded and totals can be restated
for weeks, not days).

Tests: `SessionRewardReportTests` — both reward types parse, the window makes it a session rather than
a channel report, currencies stay separate, an empty session shows nothing, a sticker-only session
still counts. `BackOnAirTests` moves into the serialised `MainViewModelLayoutPath` collection: it builds
a MainViewModel directly and was flaking against parallel tests that swap the static layout path.

FAST gate: 557 passed, 0 failed, 0 warnings.
This commit is contained in:
2026-11-05 15:38:32 -08:00
parent 27f655c8de
commit 0fade9702b
13 changed files with 488 additions and 7 deletions
+104
View File
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// TASK 3·20 — the session report is DERIVED from YouTube, never stored: no ledger, no migration, and the
/// published promise ("no viewer, member or donor information is retained after the session ends") stays
/// literally true.
/// <para>Two rules the creator set (2026-11-05): money is hidden unless asked for, and a creator who never
/// monetized is never shown a zero. Counts stay neutral so a normal session still has something to read.</para>
/// </summary>
public class SessionRewardReportTests
{
private static readonly DateTime SessionStart = new(2026, 11, 5, 20, 0, 0, DateTimeKind.Utc);
[Fact]
public void ParsesBothRewardTypes_WithoutDroppingStickers()
{
const string json = """
{
"items": [
{"id":"sc1","snippet":{"publishedAt":"2026-11-05T20:05:00Z","superChatDetails":
{"amountMicros":5000000,"currency":"USD","amountDisplayString":"$5.00","userComment":"hi"}}},
{"id":"ss1","snippet":{"publishedAt":"2026-11-05T20:07:00Z","superStickerDetails":
{"amountMicros":2000000,"currency":"EUR","amountDisplayString":"€2.00"}}}
]
}
""";
var events = SuperChatEventService.Parse(json);
Assert.Equal(2, events.Count);
Assert.Equal(ChatEventKind.SuperChat, events[0].Kind);
Assert.Equal(5_000_000, events[0].AmountMicros);
Assert.Equal("USD", events[0].Currency);
Assert.Equal(ChatEventKind.SuperSticker, events[1].Kind);
Assert.Equal("EUR", events[1].Currency);
}
[Fact]
public void TheWindowMakesItASessionReport_NotAChannelReport()
{
var events = new[]
{
Event(ChatEventKind.SuperChat, 5_000_000, "USD", SessionStart.AddMinutes(-30)), // before
Event(ChatEventKind.SuperChat, 3_000_000, "USD", SessionStart.AddMinutes(10)),
Event(ChatEventKind.SuperSticker, 2_000_000, "USD", SessionStart.AddMinutes(20)),
};
var summary = SessionRewardSummary.From(events, SessionStart);
Assert.Equal(1, summary.SuperChatCount);
Assert.Equal(1, summary.SuperStickerCount);
Assert.Equal(5_000_000, summary.MicrosByCurrency["USD"]);
}
[Fact]
public void CurrenciesAreNeverSummedTogether()
{
var events = new[]
{
Event(ChatEventKind.SuperChat, 5_000_000, "USD", SessionStart.AddMinutes(1)),
Event(ChatEventKind.SuperChat, 5_000_000, "EUR", SessionStart.AddMinutes(2)),
};
var summary = SessionRewardSummary.From(events, SessionStart);
Assert.Equal(5_000_000, summary.MicrosByCurrency["USD"]);
Assert.Equal(5_000_000, summary.MicrosByCurrency["EUR"]);
Assert.Equal(2, summary.MoneyLines().Count);
Assert.Contains(summary.MoneyLines(), line => line.StartsWith("USD $5.00"));
Assert.Contains(summary.MoneyLines(), line => line.StartsWith("EUR €5.00"));
}
[Fact]
public void AnEmptySession_HasNoCountsAndNoMoney()
{
var summary = SessionRewardSummary.From(Array.Empty<SuperChatEvent>(), SessionStart);
Assert.False(summary.HasEvents);
Assert.Empty(summary.MoneyLines());
Assert.Equal(0, summary.TotalEvents);
}
[Fact]
public void AStickerOnlySession_StillCounts()
{
var summary = SessionRewardSummary.From(
new[] { Event(ChatEventKind.SuperSticker, 1_000_000, "USD", SessionStart.AddMinutes(1)) },
SessionStart);
Assert.True(summary.HasEvents);
Assert.Equal(0, summary.SuperChatCount);
Assert.Equal(1, summary.SuperStickerCount);
}
private static SuperChatEvent Event(ChatEventKind kind, long micros, string currency, DateTime at)
=> new($"id-{Guid.NewGuid():N}", kind, micros, currency, null, at);
}