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