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 wave-16.3 anon-id layer (): the /// anon id persists to the anon_id column on play and share writes (and a null persists null), /// and the all-time distinct-listener aggregation (§3 / D3) is correct site-wide, per-track, and /// per-release (derived), with null anon ids excluded from every distinct count. Runs on the EF /// in-memory provider like ; the transaction-ignored warning is /// suppressed because in-memory has no real transactions (the play write wraps append + bump in one). /// [TestFixture] public class AnonIdQueryTests { 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); } // --- Persistence of the anon id --- // A play carrying an anon id writes it to the column. [Test] public async Task RecordPlayAsync_WithAnonId_PersistsIt() { await SeedTrackAsync("track-1"); await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, anonId: "anon-abc"); var ev = await _context.PlayEvents.SingleAsync(); Assert.That(ev.AnonId, Is.EqualTo("anon-abc")); } // A play with no anon id (the provider returned null) persists a null column — both paths covered. [Test] public async Task RecordPlayAsync_WithoutAnonId_PersistsNull() { await SeedTrackAsync("track-1"); await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, anonId: null); var ev = await _context.PlayEvents.SingleAsync(); Assert.That(ev.AnonId, Is.Null); } // A share carrying an anon id writes it to the column; a null share persists null. [Test] public async Task RecordShareAsync_PersistsAnonId() { var repo = CreateRepository(); await repo.RecordShareAsync(ShareTargetType.Track, "k", ShareChannel.Link, anonId: "anon-xyz"); await repo.RecordShareAsync(ShareTargetType.Track, "k", ShareChannel.Embed, anonId: null); var withId = await _context.ShareEvents.SingleAsync(e => e.Channel == ShareChannel.Link); var without = await _context.ShareEvents.SingleAsync(e => e.Channel == ShareChannel.Embed); Assert.That(withId.AnonId, Is.EqualTo("anon-xyz")); Assert.That(without.AnonId, Is.Null); } // --- Site-wide distinct listeners (§3 / D3, all-time) --- // Distinct anon ids are counted once each; a listener who plays many times counts once. [Test] public async Task CountDistinctListeners_CountsEachAnonOnce() { await SeedTrackAsync("track-1"); var repo = CreateRepository(); await repo.RecordPlayAsync("track-1", PlayBucket.Complete, "anon-1"); await repo.RecordPlayAsync("track-1", PlayBucket.Partial, "anon-1"); // same listener, replay await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, "anon-2"); Assert.That(await repo.CountDistinctListenersAsync(), Is.EqualTo(2)); } // Null anon ids are excluded from the distinct count — an anonId-less play is not a known listener. [Test] public async Task CountDistinctListeners_ExcludesNullAnonIds() { await SeedTrackAsync("track-1"); var repo = CreateRepository(); await repo.RecordPlayAsync("track-1", PlayBucket.Complete, "anon-1"); await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null); await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null); Assert.That(await repo.CountDistinctListenersAsync(), Is.EqualTo(1), "null anonIds must not inflate the listener count"); } // With no anon ids at all, the count is zero (not an error). [Test] public async Task CountDistinctListeners_AllNull_IsZero() { await SeedTrackAsync("track-1"); await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, null); Assert.That(await CreateRepository().CountDistinctListenersAsync(), Is.EqualTo(0)); } // --- Per-track distinct listeners --- // The per-track count scopes to the track key and counts distinct non-null anon ids. [Test] public async Task CountDistinctListenersForTrack_ScopesToTrack() { await SeedTrackAsync("track-1"); await SeedTrackAsync("track-2"); var repo = CreateRepository(); await repo.RecordPlayAsync("track-1", PlayBucket.Complete, "anon-1"); await repo.RecordPlayAsync("track-1", PlayBucket.Complete, "anon-2"); await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null); // excluded await repo.RecordPlayAsync("track-2", PlayBucket.Complete, "anon-3"); Assert.That(await repo.CountDistinctListenersForTrackAsync("track-1"), Is.EqualTo(2)); Assert.That(await repo.CountDistinctListenersForTrackAsync("track-2"), Is.EqualTo(1)); } // --- Per-release distinct listeners (derived, D4) --- // A release's listener count is the distinct anon ids across all its tracks: a listener who heard two // tracks of the release counts once (union, not a per-track sum), and null anon ids are excluded. [Test] public async Task CountDistinctListenersForRelease_DistinctAcrossTracks() { 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, "anon-1"); await repo.RecordPlayAsync("t2", PlayBucket.Complete, "anon-1"); // same listener, second track await repo.RecordPlayAsync("t2", PlayBucket.Complete, "anon-2"); await repo.RecordPlayAsync("t1", PlayBucket.Complete, null); // excluded Assert.That(await repo.CountDistinctListenersForReleaseAsync(release.Id), Is.EqualTo(2), "anon-1 heard two tracks but is one distinct listener of the release"); } // A play of a track in another release does not bleed into this release's listener count. [Test] public async Task CountDistinctListenersForRelease_ExcludesOtherReleases() { var (releaseA, _) = await SeedTrackAsync("a-track"); var (releaseB, _) = await SeedTrackAsync("b-track"); var repo = CreateRepository(); await repo.RecordPlayAsync("a-track", PlayBucket.Complete, "anon-1"); await repo.RecordPlayAsync("b-track", PlayBucket.Complete, "anon-2"); Assert.That(await repo.CountDistinctListenersForReleaseAsync(releaseA.Id), Is.EqualTo(1)); Assert.That(await repo.CountDistinctListenersForReleaseAsync(releaseB.Id), Is.EqualTo(1)); } }