Merge p16-w3-anonid into dev (Phase 16 Wave 16.3: unique-listener anonId layer)
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
using System.Text.Json;
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the Phase 16 wave-16.3 anon id is threaded onto the beacon payloads emitted by
|
||||
/// <see cref="BeaconPlayEventSink"/> and <see cref="ShareTracker"/>, and omitted when the provider has
|
||||
/// no token. Both sinks serialize internally and dispatch through <c>BeaconInterop</c> → the
|
||||
/// <c>DeepDrftBeacon.send(url, json)</c> JS call, so the assertions capture that JSON string off a fake
|
||||
/// JS runtime and inspect the <c>anonId</c> field — the same bytes the browser would POST.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class AnonIdPayloadTests
|
||||
{
|
||||
// Captures the JSON body of the most recent DeepDrftBeacon.send(url, json) invocation. The beacon is
|
||||
// fire-and-forget (returns bool); other interop calls (unload registration) are tolerated and ignored.
|
||||
private sealed class CapturingJsRuntime : IJSRuntime
|
||||
{
|
||||
public string? LastJson { get; private set; }
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||
{
|
||||
if (identifier == "DeepDrftBeacon.send" && args is { Length: 2 } && args[1] is string json)
|
||||
LastJson = json;
|
||||
return ValueTask.FromResult<TValue>(default!);
|
||||
}
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
||||
=> InvokeAsync<TValue>(identifier, args);
|
||||
}
|
||||
|
||||
private sealed class StubAnonIdProvider : IAnonIdProvider
|
||||
{
|
||||
public StubAnonIdProvider(string? current) => Current = current;
|
||||
public string? Current { get; }
|
||||
public ValueTask EnsureLoadedAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestNavigationManager : NavigationManager
|
||||
{
|
||||
public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/");
|
||||
protected override void NavigateToCore(string uri, bool forceLoad) { }
|
||||
}
|
||||
|
||||
// The sinks serialize with default (PascalCase) property names; the API binds case-insensitively, so
|
||||
// the wire field is "AnonId". Match it case-insensitively here so the test asserts the value, not the
|
||||
// casing convention. Returns (present, value) while the document is still alive.
|
||||
private static (bool Present, string? Value) FindAnonId(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, "anonId", StringComparison.OrdinalIgnoreCase))
|
||||
return (true, prop.Value.GetString());
|
||||
}
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
private static string? ReadAnonId(string json) => FindAnonId(json).Value;
|
||||
|
||||
private static bool HasAnonIdProperty(string json) => FindAnonId(json).Present;
|
||||
|
||||
// A play emitted while the provider holds a token carries that token in the payload.
|
||||
[Test]
|
||||
public void PlaySink_WithAnonId_IncludesItInPayload()
|
||||
{
|
||||
var js = new CapturingJsRuntime();
|
||||
var sink = new BeaconPlayEventSink(
|
||||
new BeaconInterop(js), new StubAnonIdProvider("listener-42"), new TestNavigationManager());
|
||||
|
||||
sink.EmitPlay("track-key", PlayBucket.Complete);
|
||||
|
||||
Assert.That(js.LastJson, Is.Not.Null);
|
||||
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-42"));
|
||||
}
|
||||
|
||||
// A play emitted when the provider has no token (storage unavailable / not warmed) omits anonId
|
||||
// entirely rather than sending anonId:null.
|
||||
[Test]
|
||||
public void PlaySink_WithoutAnonId_OmitsItFromPayload()
|
||||
{
|
||||
var js = new CapturingJsRuntime();
|
||||
var sink = new BeaconPlayEventSink(
|
||||
new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager());
|
||||
|
||||
sink.EmitPlay("track-key", PlayBucket.Partial);
|
||||
|
||||
Assert.That(js.LastJson, Is.Not.Null);
|
||||
Assert.That(HasAnonIdProperty(js.LastJson!), Is.False, "null anonId is omitted from the wire payload");
|
||||
}
|
||||
|
||||
// A share recorded while the provider holds a token carries it in the payload.
|
||||
[Test]
|
||||
public void ShareTracker_WithAnonId_IncludesItInPayload()
|
||||
{
|
||||
var js = new CapturingJsRuntime();
|
||||
var tracker = new ShareTracker(
|
||||
new BeaconInterop(js), new StubAnonIdProvider("listener-7"), new TestNavigationManager());
|
||||
|
||||
tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);
|
||||
|
||||
Assert.That(js.LastJson, Is.Not.Null);
|
||||
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-7"));
|
||||
}
|
||||
|
||||
// A share recorded with no token omits anonId from the payload.
|
||||
[Test]
|
||||
public void ShareTracker_WithoutAnonId_OmitsItFromPayload()
|
||||
{
|
||||
var js = new CapturingJsRuntime();
|
||||
var tracker = new ShareTracker(
|
||||
new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager());
|
||||
|
||||
tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);
|
||||
|
||||
Assert.That(js.LastJson, Is.Not.Null);
|
||||
Assert.That(HasAnonIdProperty(js.LastJson!), Is.False);
|
||||
}
|
||||
|
||||
// A JS runtime that throws on every call — models localStorage interop being unavailable (private
|
||||
// mode, blocked storage, or the module not yet imported during prerender).
|
||||
private sealed class ThrowingJsRuntime : IJSRuntime
|
||||
{
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||
=> throw new InvalidOperationException("interop unavailable");
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
||||
=> throw new InvalidOperationException("interop unavailable");
|
||||
}
|
||||
|
||||
// The real AnonIdProvider degrades to null (no throw) when the localStorage interop is unavailable —
|
||||
// the acceptance-criterion "degrades safely if localStorage is unavailable". EnsureLoadedAsync
|
||||
// swallows the interop failure and leaves Current null.
|
||||
[Test]
|
||||
public async Task AnonIdProvider_WhenInteropUnavailable_DegradesToNullWithoutThrowing()
|
||||
{
|
||||
var provider = new AnonIdProvider(new ThrowingJsRuntime());
|
||||
|
||||
await provider.EnsureLoadedAsync(); // must not throw
|
||||
|
||||
Assert.That(provider.Current, Is.Null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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 wave-16.3 anon-id layer (<see cref="EventRepository"/>): the
|
||||
/// anon id persists to the <c>anon_id</c> 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 <see cref="PlayEventQueryTests"/>; the transaction-ignored warning is
|
||||
/// suppressed because in-memory has no real transactions (the play write wraps append + bump in one).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class AnonIdQueryTests
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
// --- 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));
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,24 @@ public class ShareTrackerTests
|
||||
protected override void NavigateToCore(string uri, bool forceLoad) { }
|
||||
}
|
||||
|
||||
// Fixed-value anon-id provider so the tracker can attach a token without JS interop. Treated as
|
||||
// already warmed (Current returns the value); EnsureLoadedAsync is a no-op here.
|
||||
private sealed class StubAnonIdProvider : IAnonIdProvider
|
||||
{
|
||||
public StubAnonIdProvider(string? current) => Current = current;
|
||||
public string? Current { get; }
|
||||
public ValueTask EnsureLoadedAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private ShareTracker _tracker = null!;
|
||||
private readonly DateTimeOffset _t0 = new(2026, 6, 19, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
=> _tracker = new ShareTracker(new BeaconInterop(new NoopJsRuntime()), new TestNavigationManager());
|
||||
=> _tracker = new ShareTracker(
|
||||
new BeaconInterop(new NoopJsRuntime()),
|
||||
new StubAnonIdProvider("anon-1"),
|
||||
new TestNavigationManager());
|
||||
|
||||
// A copy-link records one share with channel = link.
|
||||
[Test]
|
||||
|
||||
Reference in New Issue
Block a user