dbd90ee52a
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.
175 lines
7.5 KiB
C#
175 lines
7.5 KiB
C#
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);
|
|
}
|
|
}
|