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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user