Files
deepdrft/DeepDrftTests/PlayEventQueryTests.cs
daniel-c-harvey be1a55fd37 feat(stats): flip home Plays card live (Phase 16.5)
Add TotalPlays + UniqueListeners to HomeStatsDto, composed at
StatsController from IEventService (no migration). Card reads via
existing persistent-state-bridged round-trip.
2026-06-19 15:26:07 -04:00

214 lines
9.2 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);
}
// --- Site-wide total plays (§5 — the home Plays card's primary figure) ---
// An empty counter table sums to zero rather than throwing — the card's reading until the
// telemetry migration is applied and the first play lands.
[Test]
public async Task CountTotalPlaysAsync_NoCounters_IsZero()
{
Assert.That(await CreateRepository().CountTotalPlaysAsync(), Is.EqualTo(0L));
}
// Total plays sums all three bucket columns of a single track's counter.
[Test]
public async Task CountTotalPlaysAsync_SumsAllBucketsOfOneCounter()
{
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);
Assert.That(await repo.CountTotalPlaysAsync(), Is.EqualTo(4L));
}
// Total plays is site-wide: it sums across every track's counter, not one track's.
[Test]
public async Task CountTotalPlaysAsync_SumsAcrossAllTracks()
{
await SeedTrackAsync("track-1");
await SeedTrackAsync("track-2");
var repo = CreateRepository();
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null);
await repo.RecordPlayAsync("track-1", PlayBucket.Partial, null);
await repo.RecordPlayAsync("track-2", PlayBucket.Sampled, null);
Assert.That(await repo.CountTotalPlaysAsync(), Is.EqualTo(3L),
"site-wide total spans every track's counter");
}
// 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);
}
}