feat(phase-16): anonymous play & share telemetry substrate (wave 16.1)

Player-service play-session tracker (floor + 3-bucket classify), SharePopover share tracker with debounce, sendBeacon interop, proxied rate-limited POST api/event/{play,share}, append-only event logs + incremental play_counter with server-side release resolution. Migration authored, not applied. No anonId, no read surface.
This commit is contained in:
daniel-c-harvey
2026-06-19 12:59:00 -04:00
parent 1931574ad4
commit dbd90ee52a
35 changed files with 2460 additions and 2 deletions
+174
View File
@@ -0,0 +1,174 @@
using DeepDrftData.Data;
using DeepDrftData.Repositories;
using DeepDrftModels.Entities;
using DeepDrftModels.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace DeepDrftTests;
/// <summary>
/// Storage-layer tests for the Phase 16 telemetry writes (<see cref="EventRepository"/>): server-side
/// release resolution (§2.3 / D4), the incremental play-counter bump in the same write (D6), the
/// derived-release-total shape (a release's plays are the sum of its tracks'), and the share append.
/// Runs on the EF in-memory provider like <see cref="HomeStatsQueryTests"/>. In-memory does not support
/// real transactions, so the transaction-ignored warning is suppressed — the production Postgres path
/// wraps the append + bump in one transaction, which the warning would otherwise turn into an error here.
/// </summary>
[TestFixture]
public class PlayEventQueryTests
{
private DeepDrftContext _context = null!;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<DeepDrftContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.Options;
_context = new DeepDrftContext(options);
}
[TearDown]
public void TearDown() => _context.Dispose();
private EventRepository CreateRepository() => new(_context);
private async Task<(ReleaseEntity Release, TrackEntity Track)> SeedTrackAsync(string trackKey)
{
var release = new ReleaseEntity
{
EntryKey = Guid.NewGuid().ToString("N"),
Title = "R",
Artist = "A",
Medium = ReleaseMedium.Cut,
};
var track = new TrackEntity { EntryKey = trackKey, TrackName = "T", Release = release };
_context.Releases.Add(release);
_context.Tracks.Add(track);
await _context.SaveChangesAsync();
return (release, track);
}
// A play that reaches the repository (the floor is the tracker's job) writes exactly one play_event
// row with the release id resolved server-side, and bumps the matching bucket on the track's counter.
[Test]
public async Task RecordPlayAsync_ResolvesReleaseAndBumpsCounter()
{
var (release, track) = await SeedTrackAsync("track-1");
await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, anonId: null);
var ev = await _context.PlayEvents.SingleAsync();
Assert.That(ev.TrackEntryKey, Is.EqualTo("track-1"));
Assert.That(ev.ReleaseId, Is.EqualTo(release.Id), "release resolved server-side from the track key");
Assert.That(ev.Bucket, Is.EqualTo(PlayBucket.Complete));
Assert.That(ev.AnonId, Is.Null, "no anonId is written in wave 16.1");
var counter = await _context.PlayCounters.SingleAsync(c => c.TrackId == track.Id);
Assert.That(counter.CompleteCount, Is.EqualTo(1));
Assert.That(counter.TotalPlays, Is.EqualTo(1));
}
// Each bucket bumps its own column; total plays is the sum across buckets.
[Test]
public async Task RecordPlayAsync_BucketsAccumulateIndependently()
{
var (_, track) = await SeedTrackAsync("track-1");
var repo = CreateRepository();
await repo.RecordPlayAsync("track-1", PlayBucket.Partial, null);
await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, null);
await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, null);
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null);
var counter = await _context.PlayCounters.SingleAsync(c => c.TrackId == track.Id);
Assert.That(counter.PartialCount, Is.EqualTo(1));
Assert.That(counter.SampledCount, Is.EqualTo(2));
Assert.That(counter.CompleteCount, Is.EqualTo(1));
Assert.That(counter.TotalPlays, Is.EqualTo(4));
Assert.That(await _context.PlayEvents.CountAsync(), Is.EqualTo(4));
}
// Release totals are derived (D4): summing the counters of the release's tracks gives release plays;
// there is no separate release-counter row.
[Test]
public async Task RecordPlayAsync_ReleaseTotalIsSumOfTrackCounters()
{
var release = new ReleaseEntity
{
EntryKey = Guid.NewGuid().ToString("N"), Title = "R", Artist = "A", Medium = ReleaseMedium.Cut,
};
var t1 = new TrackEntity { EntryKey = "t1", TrackName = "T1", Release = release };
var t2 = new TrackEntity { EntryKey = "t2", TrackName = "T2", Release = release };
_context.Releases.Add(release);
_context.Tracks.AddRange(t1, t2);
await _context.SaveChangesAsync();
var repo = CreateRepository();
await repo.RecordPlayAsync("t1", PlayBucket.Complete, null);
await repo.RecordPlayAsync("t1", PlayBucket.Partial, null);
await repo.RecordPlayAsync("t2", PlayBucket.Sampled, null);
var releaseTotal = await _context.PlayCounters
.Where(c => _context.Tracks.Any(t => t.Id == c.TrackId && t.ReleaseId == release.Id))
.SumAsync(c => c.PartialCount + c.SampledCount + c.CompleteCount);
Assert.That(releaseTotal, Is.EqualTo(3));
}
// A loose track (no release) logs the event with a null release id and still bumps its own counter.
[Test]
public async Task RecordPlayAsync_LooseTrack_NullReleaseStillCounts()
{
var track = new TrackEntity { EntryKey = "loose", TrackName = "T" };
_context.Tracks.Add(track);
await _context.SaveChangesAsync();
await CreateRepository().RecordPlayAsync("loose", PlayBucket.Sampled, null);
var ev = await _context.PlayEvents.SingleAsync();
Assert.That(ev.ReleaseId, Is.Null);
Assert.That((await _context.PlayCounters.SingleAsync(c => c.TrackId == track.Id)).SampledCount, Is.EqualTo(1));
}
// A play of an unknown/removed track key still logs (null release, no counter bump) rather than failing.
[Test]
public async Task RecordPlayAsync_UnknownTrackKey_LogsEventWithoutCounter()
{
await CreateRepository().RecordPlayAsync("does-not-exist", PlayBucket.Partial, null);
var ev = await _context.PlayEvents.SingleAsync();
Assert.That(ev.ReleaseId, Is.Null);
Assert.That(await _context.PlayCounters.AnyAsync(), Is.False, "no track to roll up against");
}
// A soft-deleted track resolves to null (the !IsDeleted guard) — the play still logs, no counter bump.
[Test]
public async Task RecordPlayAsync_SoftDeletedTrack_DoesNotResolveRelease()
{
var (_, track) = await SeedTrackAsync("gone");
track.IsDeleted = true;
await _context.SaveChangesAsync();
await CreateRepository().RecordPlayAsync("gone", PlayBucket.Complete, null);
var ev = await _context.PlayEvents.SingleAsync();
Assert.That(ev.ReleaseId, Is.Null);
Assert.That(await _context.PlayCounters.AnyAsync(c => c.TrackId == track.Id), Is.False);
}
// A share append writes one row with the target, channel, and a null anonId.
[Test]
public async Task RecordShareAsync_AppendsRow()
{
await CreateRepository().RecordShareAsync(ShareTargetType.Release, "rel-key", ShareChannel.Embed, anonId: null);
var ev = await _context.ShareEvents.SingleAsync();
Assert.That(ev.TargetType, Is.EqualTo(ShareTargetType.Release));
Assert.That(ev.TargetKey, Is.EqualTo("rel-key"));
Assert.That(ev.Channel, Is.EqualTo(ShareChannel.Embed));
Assert.That(ev.AnonId, Is.Null);
}
}
+205
View File
@@ -0,0 +1,205 @@
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the Phase 16 play-session tracker (<see cref="PlayTracker"/>): the engagement-floor
/// gate (§1d / D2 — ≥3s OR ≥5% of duration, whichever smaller) and the three-bucket completion
/// classification (§1a / D1 — partial &lt;30%, sampled 3080%, complete &gt;80%), exercised behind a
/// fake sink so the logic is tested with no player or JS interop — the seam the spec calls out as
/// testable. Also covers the high-water (seek-backward) and idempotent-close invariants.
/// </summary>
[TestFixture]
public class PlayTrackerTests
{
// Captures emitted plays so assertions read the (key, bucket) the tracker classified.
private sealed class FakeSink : IPlayEventSink
{
public List<(string Key, PlayBucket Bucket)> Emitted { get; } = new();
public void EmitPlay(string trackEntryKey, PlayBucket bucket) => Emitted.Add((trackEntryKey, bucket));
}
private FakeSink _sink = null!;
private PlayTracker _tracker = null!;
[SetUp]
public void SetUp()
{
_sink = new FakeSink();
_tracker = new PlayTracker(_sink);
}
// Drive a full session: open, set duration, advance to a high-water position, close.
private void PlaySession(string key, double duration, double highWater)
{
_tracker.OnPlaybackStarted(key);
_tracker.SetDuration(duration);
_tracker.OnProgress(highWater);
_tracker.Close();
}
// --- Engagement floor (§1d / D2) ---
// A long track floors on the 3-second wall (3s < 5% of 200s = 10s): under 3s sends nothing.
[Test]
public void Close_LongTrackUnderThreeSeconds_SendsNothing()
{
PlaySession("t", duration: 200, highWater: 2.5);
Assert.That(_sink.Emitted, Is.Empty);
}
// The same long track at exactly the 3-second floor crosses it and records a (partial) play.
[Test]
public void Close_LongTrackAtThreeSecondFloor_RecordsPlay()
{
PlaySession("t", duration: 200, highWater: 3.0);
Assert.That(_sink.Emitted, Has.Count.EqualTo(1));
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial));
}
// A short clip floors on the percentage (5% of 40s = 2s < 3s): 1.5s is below 2s → nothing.
[Test]
public void Close_ShortClipUnderFivePercent_SendsNothing()
{
PlaySession("t", duration: 40, highWater: 1.5);
Assert.That(_sink.Emitted, Is.Empty);
}
// The same short clip at the 5%-of-duration floor (2s) crosses it and records.
[Test]
public void Close_ShortClipAtFivePercentFloor_RecordsPlay()
{
PlaySession("t", duration: 40, highWater: 2.0);
Assert.That(_sink.Emitted, Has.Count.EqualTo(1));
}
// --- Bucket classification (§1a / D1) ---
// [floor, 30%) → partial. 60/200 = 30% is the boundary, so 59.9/200 (just under) is partial.
[Test]
public void Close_UnderThirtyPercent_ClassifiesPartial()
{
PlaySession("t", duration: 200, highWater: 50); // 25%
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial));
}
// Exactly 30% is the start of the sampled band [30%, 80%].
[Test]
public void Close_AtThirtyPercent_ClassifiesSampled()
{
PlaySession("t", duration: 200, highWater: 60); // 30%
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled));
}
// Mid-band is sampled.
[Test]
public void Close_MidBand_ClassifiesSampled()
{
PlaySession("t", duration: 200, highWater: 120); // 60%
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled));
}
// Exactly 80% is the inclusive top of the sampled band — still sampled, not complete.
[Test]
public void Close_AtEightyPercent_ClassifiesSampled()
{
PlaySession("t", duration: 200, highWater: 160); // 80%
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled));
}
// Past 80% is complete.
[Test]
public void Close_OverEightyPercent_ClassifiesComplete()
{
PlaySession("t", duration: 200, highWater: 190); // 95%
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete));
}
// A full listen classifies complete, and the high-water is clamped (never over 100%).
[Test]
public void Close_FullListen_ClassifiesComplete()
{
PlaySession("t", duration: 200, highWater: 200);
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete));
}
// --- High-water invariant (§1d: seeks never lower the mark) ---
// Seeking backward after reaching the end still classifies complete — the max position wins.
[Test]
public void Close_SeekBackwardAfterEnd_StaysComplete()
{
_tracker.OnPlaybackStarted("t");
_tracker.SetDuration(200);
_tracker.OnProgress(190); // reached 95%
_tracker.OnProgress(20); // seek back to 10% — must NOT lower the high-water
_tracker.Close();
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete));
}
// --- Session lifecycle ---
// Carries the track key the session opened with through to the emitted event.
[Test]
public void Close_RecordsTheOpenedTrackKey()
{
PlaySession("the-entry-key", duration: 100, highWater: 90);
Assert.That(_sink.Emitted[0].Key, Is.EqualTo("the-entry-key"));
}
// Close is idempotent — a second close (e.g. organic end after the unload beacon already fired) emits nothing further.
[Test]
public void Close_CalledTwice_EmitsOnce()
{
PlaySession("t", duration: 100, highWater: 90);
_tracker.Close();
Assert.That(_sink.Emitted, Has.Count.EqualTo(1));
}
// A session with no duration ever set (header never parsed) has no fraction to classify → nothing.
[Test]
public void Close_WithoutDuration_SendsNothing()
{
_tracker.OnPlaybackStarted("t");
_tracker.OnProgress(30);
_tracker.Close();
Assert.That(_sink.Emitted, Is.Empty);
}
// Progress before any session opens is ignored (no open session to advance), so a later open+close
// starts the high-water from zero.
[Test]
public void OnProgress_BeforeOpen_IsIgnored()
{
_tracker.OnProgress(150);
PlaySession("t", duration: 200, highWater: 10); // 5% — partial
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial));
}
// Opening a new session while one is still open closes (and records) the prior one — a track-switch
// that did not route through Close still records the superseded listen. Two complete plays result.
[Test]
public void OnPlaybackStarted_WhileOpen_ClosesPriorSession()
{
_tracker.OnPlaybackStarted("first");
_tracker.SetDuration(100);
_tracker.OnProgress(95); // first: complete
_tracker.OnPlaybackStarted("second"); // supersedes — records first
_tracker.SetDuration(100);
_tracker.OnProgress(95); // second: complete
_tracker.Close();
Assert.That(_sink.Emitted.Select(e => e.Key), Is.EqualTo(new[] { "first", "second" }));
Assert.That(_sink.Emitted.All(e => e.Bucket == PlayBucket.Complete), Is.True);
}
// A replay (open the same key again after closing) is a second, independent play (§1d).
[Test]
public void Replay_RecordsTwoPlays()
{
PlaySession("t", duration: 100, highWater: 95);
PlaySession("t", duration: 100, highWater: 95);
Assert.That(_sink.Emitted, Has.Count.EqualTo(2));
}
}
+94
View File
@@ -0,0 +1,94 @@
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using Microsoft.JSInterop.Infrastructure;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the Phase 16 share tracker (<see cref="ShareTracker"/>): the per-(target,channel)
/// debounce (§1b — at most one event per target+channel per 60s window per session). The tracker fires
/// through a beacon that wraps <see cref="IJSRuntime"/>; the tests use a no-op JS runtime (the send is
/// fire-and-forget and its outcome is irrelevant) and assert on the debounce decision via the bool the
/// recorder returns — true when an event fired, false when debounced.
/// </summary>
[TestFixture]
public class ShareTrackerTests
{
// sendBeacon interop is fire-and-forget; the tracker never reads the result, so a no-op runtime that
// returns default for any invocation is sufficient to exercise the debounce logic.
private sealed class NoopJsRuntime : IJSRuntime
{
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
=> ValueTask.FromResult<TValue>(default!);
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
=> ValueTask.FromResult<TValue>(default!);
}
// Minimal NavigationManager so the tracker can compose the (unused-in-test) beacon URL.
private sealed class TestNavigationManager : NavigationManager
{
public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/");
protected override void NavigateToCore(string uri, bool forceLoad) { }
}
private ShareTracker _tracker = null!;
private readonly DateTimeOffset _t0 = new(2026, 6, 19, 12, 0, 0, TimeSpan.Zero);
[SetUp]
public void SetUp()
=> _tracker = new ShareTracker(new BeaconInterop(new NoopJsRuntime()), new TestNavigationManager());
// A copy-link records one share with channel = link.
[Test]
public void RecordShare_CopyLink_FiresOnce()
=> Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
// A copy-embed records one share with channel = embed — distinct (target,channel) from the link copy.
[Test]
public void RecordShare_CopyEmbedAfterLink_FiresSeparately()
{
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Embed, _t0), Is.True,
"embed is a different channel from link — not debounced against it");
}
// An immediate repeat copy of the same (target, channel) within the window is debounced.
[Test]
public void RecordShare_ImmediateRepeat_IsDebounced()
{
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0.AddSeconds(5)), Is.False);
}
// After the 60s window elapses, the same (target, channel) fires again.
[Test]
public void RecordShare_AfterWindow_FiresAgain()
{
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0.AddSeconds(61)), Is.True);
}
// Different targets debounce independently — sharing track A then track B both fire.
[Test]
public void RecordShare_DifferentTargets_FireIndependently()
{
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "a", ShareChannel.Link, _t0), Is.True);
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "b", ShareChannel.Link, _t0), Is.True);
}
// A track key and a release key are distinct targets even if the key string collides.
[Test]
public void RecordShare_TrackVsRelease_AreDistinctTargets()
{
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "x", ShareChannel.Link, _t0), Is.True);
Assert.That(_tracker.RecordShare(ShareTargetType.Release, "x", ShareChannel.Link, _t0), Is.True);
}
// A blank target key never fires (defensive — the popover guards too).
[Test]
public void RecordShare_BlankKey_DoesNotFire()
=> Assert.That(_tracker.RecordShare(ShareTargetType.Track, " ", ShareChannel.Link, _t0), Is.False);
}