using DeepDrftData.Data; using DeepDrftData.Repositories; using DeepDrftModels.Entities; using DeepDrftModels.Enums; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; namespace DeepDrftTests; /// /// Storage-layer tests for the Phase 16 telemetry writes (): 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 . 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. /// [TestFixture] public class PlayEventQueryTests { private DeepDrftContext _context = null!; [SetUp] public void SetUp() { var options = new DbContextOptionsBuilder() .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); } }