diff --git a/DeepDrftAPI/Controllers/EventController.cs b/DeepDrftAPI/Controllers/EventController.cs index f403081..c9104f3 100644 --- a/DeepDrftAPI/Controllers/EventController.cs +++ b/DeepDrftAPI/Controllers/EventController.cs @@ -22,6 +22,12 @@ public class EventController : ControllerBase // payloads are a track key + an enum, well under 1 KB. private const int MaxBodyBytes = 1024; + // The anonId is a client-minted GUID string (~36 chars); the anon_id column is varchar(64). Reject + // anything longer as malformed rather than silently truncating — an over-long token is either a bug + // or an inflation attempt, and a truncated id would corrupt the distinct-listener count by colliding + // distinct listeners onto one prefix. Whitespace-only is treated as absent. + private const int MaxAnonIdLength = 64; + private readonly IEventService _eventService; private readonly ILogger _logger; @@ -43,9 +49,10 @@ public class EventController : ControllerBase return BadRequest("trackEntryKey is required"); if (!Enum.IsDefined(payload.Bucket)) return BadRequest("bucket is invalid"); + if (!TryNormalizeAnonId(payload.AnonId, out var anonId)) + return BadRequest("anonId is invalid"); - // Wave 16.1 writes no anonId — defend the substrate by dropping any the client sends early. - var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId: null, ct); + var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId, ct); if (!result.Success) { // A telemetry failure must never surface to the listener as an error they can act on, but @@ -69,8 +76,10 @@ public class EventController : ControllerBase return BadRequest("targetType is invalid"); if (!Enum.IsDefined(payload.Channel)) return BadRequest("channel is invalid"); + if (!TryNormalizeAnonId(payload.AnonId, out var anonId)) + return BadRequest("anonId is invalid"); - var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId: null, ct); + var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId, ct); if (!result.Success) { _logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message); @@ -79,4 +88,27 @@ public class EventController : ControllerBase return Accepted(); } + + // Normalize an incoming anonId (wave 16.3): whitespace-only / empty / null collapses to a null token + // (the listener didn't send one, or storage was unavailable — a valid, anonId-less event). A token + // over the column width is rejected (400) rather than truncated, since truncation would collide + // distinct listeners. Returns false only on the over-long case; null and a valid token both pass. + private static bool TryNormalizeAnonId(string? raw, out string? anonId) + { + if (string.IsNullOrWhiteSpace(raw)) + { + anonId = null; + return true; + } + + var trimmed = raw.Trim(); + if (trimmed.Length > MaxAnonIdLength) + { + anonId = null; + return false; + } + + anonId = trimmed; + return true; + } } diff --git a/DeepDrftData/EventManager.cs b/DeepDrftData/EventManager.cs index 5753858..75ce05e 100644 --- a/DeepDrftData/EventManager.cs +++ b/DeepDrftData/EventManager.cs @@ -53,4 +53,48 @@ public class EventManager : IEventService return Result.CreateFailResult(e.Message); } } + + public async Task> GetDistinctListenerCount(CancellationToken cancellationToken = default) + { + try + { + var count = await _repository.CountDistinctListenersAsync(cancellationToken); + return ResultContainer.CreatePassResult(count); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to count distinct listeners"); + return ResultContainer.CreateFailResult(e.Message); + } + } + + public async Task> GetDistinctListenerCountForTrack( + string trackEntryKey, CancellationToken cancellationToken = default) + { + try + { + var count = await _repository.CountDistinctListenersForTrackAsync(trackEntryKey, cancellationToken); + return ResultContainer.CreatePassResult(count); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to count distinct listeners for track {TrackEntryKey}", trackEntryKey); + return ResultContainer.CreateFailResult(e.Message); + } + } + + public async Task> GetDistinctListenerCountForRelease( + long releaseId, CancellationToken cancellationToken = default) + { + try + { + var count = await _repository.CountDistinctListenersForReleaseAsync(releaseId, cancellationToken); + return ResultContainer.CreatePassResult(count); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to count distinct listeners for release {ReleaseId}", releaseId); + return ResultContainer.CreateFailResult(e.Message); + } + } } diff --git a/DeepDrftData/IEventService.cs b/DeepDrftData/IEventService.cs index 42a963b..5feaf77 100644 --- a/DeepDrftData/IEventService.cs +++ b/DeepDrftData/IEventService.cs @@ -20,4 +20,20 @@ public interface IEventService /// Record one share: append a share_event row. Target and channel come straight from the client. Task RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null, CancellationToken cancellationToken = default); + + /// + /// Site-wide distinct-listener count (Phase 16 §3, D3 — all-time): distinct non-null anon_id + /// values across all play events. Null tokens are excluded (not a known listener). The capability for + /// wave 16.5's "N listeners" card; nothing surfaces it via API or UI in wave 16.3. + /// + Task> GetDistinctListenerCount(CancellationToken cancellationToken = default); + + /// Distinct listeners who played the given track (by vault entry key). Null tokens excluded. + Task> GetDistinctListenerCountForTrack(string trackEntryKey, CancellationToken cancellationToken = default); + + /// + /// Distinct listeners across the release's tracks (derived, D4) — a listener who played any track in + /// the release counts once. Null tokens excluded. + /// + Task> GetDistinctListenerCountForRelease(long releaseId, CancellationToken cancellationToken = default); } diff --git a/DeepDrftData/Repositories/EventRepository.cs b/DeepDrftData/Repositories/EventRepository.cs index e61ef99..bc77171 100644 --- a/DeepDrftData/Repositories/EventRepository.cs +++ b/DeepDrftData/Repositories/EventRepository.cs @@ -83,6 +83,44 @@ public class EventRepository } } + /// + /// Count distinct non-null anon ids across every play event (Phase 16 §3 / §4.2 — the all-time + /// unique-listener metric, D3). Null anon ids (events where the listener sent no token, or storage + /// was unavailable) are excluded — they are not a known listener and must not inflate the count. This + /// is the site-wide listener reach figure; the per-track / per-release overloads scope it. + /// + public Task CountDistinctListenersAsync(CancellationToken ct = default) + => _context.PlayEvents + .Where(e => e.AnonId != null) + .Select(e => e.AnonId) + .Distinct() + .CountAsync(ct); + + /// + /// Distinct listeners for one track, keyed by its vault entry key (the same key the play event + /// stamps). Null anon ids excluded. Per-track scope of . + /// + public Task CountDistinctListenersForTrackAsync(string trackEntryKey, CancellationToken ct = default) + => _context.PlayEvents + .Where(e => e.TrackEntryKey == trackEntryKey && e.AnonId != null) + .Select(e => e.AnonId) + .Distinct() + .CountAsync(ct); + + /// + /// Distinct listeners for one release, derived across the release's tracks (D4): the play event + /// stamps the resolved release id at write time, so a distinct count over anon_id filtered by + /// release_id is exactly "distinct listeners who played any track in this release." Null anon + /// ids excluded. A listener who heard two tracks of the release counts once (it is a distinct count + /// over the union, not a sum of per-track counts). + /// + public Task CountDistinctListenersForReleaseAsync(long releaseId, CancellationToken ct = default) + => _context.PlayEvents + .Where(e => e.ReleaseId == releaseId && e.AnonId != null) + .Select(e => e.AnonId) + .Distinct() + .CountAsync(ct); + /// Append one share event. No rollup table for shares in wave 16.1 — a plain insert. public async Task RecordShareAsync( ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId, diff --git a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs index a105e20..5202023 100644 --- a/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs +++ b/DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor.cs @@ -12,6 +12,7 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable [Inject] public required ILogger Logger { get; set; } [Inject] public required BeaconInterop Beacon { get; set; } [Inject] public required IPlayEventSink PlayEventSink { get; set; } + [Inject] public required IAnonIdProvider AnonId { get; set; } private IStreamingPlayerService? _audioPlayerService; private QueueService? _queueService; @@ -50,6 +51,20 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable _queueService.Attach(_audioPlayerService); } + /// + /// Warm the anon-id cache once the provider is interactive (Phase 16 wave 16.3). Done here, after the + /// first render, because the localStorage read is JS interop — not available during prerender. By the + /// time any play session closes and the sink reads AnonId.Current, the cache is populated; a + /// play that somehow closes before this completes simply sends no anonId (acceptable over-count). The + /// provider is the natural warm point: it is mounted in MainLayout, so it goes interactive on every + /// page the player can play from. + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + await AnonId.EnsureLoadedAsync(); + } + /// /// Dispose the player on unmount so the JS setInterval driving progress /// callbacks no longer holds a DotNetObjectReference into a destroyed diff --git a/DeepDrftPublic.Client/Controls/SharePopover.razor.cs b/DeepDrftPublic.Client/Controls/SharePopover.razor.cs index c73ff50..1010f7e 100644 --- a/DeepDrftPublic.Client/Controls/SharePopover.razor.cs +++ b/DeepDrftPublic.Client/Controls/SharePopover.razor.cs @@ -30,6 +30,7 @@ public partial class SharePopover : ComponentBase, IDisposable [Inject] public required NavigationManager Navigation { get; set; } [Inject] public required IJSRuntime JS { get; set; } [Inject] public required ShareTracker ShareTracker { get; set; } + [Inject] public required IAnonIdProvider AnonId { get; set; } private bool IsReleaseMode => ReleaseEntryKey is not null; @@ -65,7 +66,15 @@ public partial class SharePopover : ComponentBase, IDisposable ? EmbedSnippetBuilder.ForRelease(Navigation.BaseUri, ReleaseEntryKey!) : EmbedSnippetBuilder.ForTrack(Navigation.BaseUri, EntryKey!); - private void Toggle() => _open = !_open; + private async Task Toggle() + { + _open = !_open; + // Warm the anon-id cache when the popover opens (wave 16.3) so a copy-share fired moments later + // reads a populated AnonId.Current. Idempotent and best-effort — if it fails the share simply + // carries no anonId. Opening is interactive, so the localStorage interop is available here. + if (_open) + await AnonId.EnsureLoadedAsync(); + } private void Close() => _open = false; diff --git a/DeepDrftPublic.Client/Services/AnonIdProvider.cs b/DeepDrftPublic.Client/Services/AnonIdProvider.cs new file mode 100644 index 0000000..606cad4 --- /dev/null +++ b/DeepDrftPublic.Client/Services/AnonIdProvider.cs @@ -0,0 +1,41 @@ +using Microsoft.JSInterop; + +namespace DeepDrftPublic.Client.Services; + +/// +/// Production over the window.DeepDrftAnonId TS interop. Reads the +/// first-party localStorage GUID once and caches it for the session, so the synchronous emit paths +/// read it with no JS hop. Scoped (per-session) like the other telemetry collaborators; the underlying +/// token itself outlives the session in localStorage — the cache just avoids repeated interop. +/// +public sealed class AnonIdProvider : IAnonIdProvider +{ + private readonly IJSRuntime _js; + private bool _loaded; + + public AnonIdProvider(IJSRuntime js) + { + _js = js; + } + + public string? Current { get; private set; } + + public async ValueTask EnsureLoadedAsync() + { + if (_loaded) return; + + try + { + // The module returns null when localStorage is unavailable; we store that null and still + // mark loaded so we don't retry every emit. A genuine interop failure (module not yet + // imported, prerender) is caught below and leaves _loaded false so a later warm can succeed. + Current = await _js.InvokeAsync("DeepDrftAnonId.get"); + _loaded = true; + } + catch + { + // Interop unavailable (prerender / module not loaded). Leave Current null and unloaded so a + // subsequent warm retries — telemetry simply omits the id until then. + } + } +} diff --git a/DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs b/DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs index fb6a333..c900718 100644 --- a/DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs +++ b/DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; using DeepDrftModels.DTOs; using DeepDrftModels.Enums; using Microsoft.AspNetCore.Components; @@ -10,16 +11,26 @@ namespace DeepDrftPublic.Client.Services; /// it via navigator.sendBeacon to the proxied api/event/play route. Fire-and-forget by /// design — is synchronous (it is called from the player's close /// path and the unload handler, neither of which can await), so the beacon is dispatched without -/// awaiting and its failure is irrelevant. No anonId is sent in wave 16.1. +/// awaiting and its failure is irrelevant. The current anonId (wave 16.3) is read synchronously +/// from the warmed cache and omitted when null (storage unavailable / not +/// yet warmed) — an anonId-less play still counts, it just doesn't contribute to the listener tally. /// public sealed class BeaconPlayEventSink : IPlayEventSink { + // Omit a null anonId from the wire payload (§2.2 — "omitted entirely" when absent) rather than + // sending "anonId":null. The API treats absent and null identically, so this is cosmetic minimalism; + // it does not change the integer enum encoding the 16.1 contract already relies on. + private static readonly JsonSerializerOptions BeaconJson = + new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + private readonly BeaconInterop _beacon; + private readonly IAnonIdProvider _anonId; private readonly string _playUrl; - public BeaconPlayEventSink(BeaconInterop beacon, NavigationManager navigation) + public BeaconPlayEventSink(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation) { _beacon = beacon; + _anonId = anonId; // The WASM client posts to its own host, which proxies to DeepDrftAPI. BaseUri carries a // trailing slash; the route does not lead with one. _playUrl = $"{navigation.BaseUri}api/event/play"; @@ -31,7 +42,8 @@ public sealed class BeaconPlayEventSink : IPlayEventSink { TrackEntryKey = trackEntryKey, Bucket = bucket, - }); + AnonId = _anonId.Current, + }, BeaconJson); // Fire-and-forget: do not await. The beacon survives unload; the C# task may not, and we do not // act on the result either way. diff --git a/DeepDrftPublic.Client/Services/IAnonIdProvider.cs b/DeepDrftPublic.Client/Services/IAnonIdProvider.cs new file mode 100644 index 0000000..8919417 --- /dev/null +++ b/DeepDrftPublic.Client/Services/IAnonIdProvider.cs @@ -0,0 +1,32 @@ +namespace DeepDrftPublic.Client.Services; + +/// +/// Supplies the client-minted anonymous listener id (Phase 16 §3, wave 16.3, D5 Option A) to the +/// fire-and-forget telemetry sinks. The id is a random first-party localStorage GUID — opaque, no +/// PII, no fingerprinting, clearable. Split into an async warm () and a +/// synchronous read () so the existing sync emit paths (the beacon sink, the share +/// tracker) need no async signature change: a caller warms the cache when it goes interactive, and the +/// emit then reads the cached value with no JS round-trip on the close/unload path. +/// +/// +/// Degrades to null when localStorage is unavailable (private mode / blocked / partitioned +/// third-party iframe) — the sink then omits the id and sends an anonId-less event. Over-counting is the +/// accepted direction of error (§3); a missing id never throws. +/// +/// +public interface IAnonIdProvider +{ + /// + /// The cached anon id, or null if not yet warmed or if storage is unavailable. Synchronous and safe + /// to read from the player close path and the page-unload handler, neither of which can await. + /// + string? Current { get; } + + /// + /// Warm the cache from localStorage via JS interop (minting on first visit). Idempotent — only + /// the first successful read populates the cache; later calls are no-ops. Best-effort: a JS failure + /// (interop unavailable during prerender, storage blocked) leaves null and never + /// throws. + /// + ValueTask EnsureLoadedAsync(); +} diff --git a/DeepDrftPublic.Client/Services/ShareTracker.cs b/DeepDrftPublic.Client/Services/ShareTracker.cs index f72cd30..4d7de5c 100644 --- a/DeepDrftPublic.Client/Services/ShareTracker.cs +++ b/DeepDrftPublic.Client/Services/ShareTracker.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; using DeepDrftModels.DTOs; using DeepDrftModels.Enums; using Microsoft.AspNetCore.Components; @@ -14,7 +15,8 @@ namespace DeepDrftPublic.Client.Services; /// /// Scoped (per-session) so the debounce memory lives for the session and resets on a fresh load, matching /// the "feels like one act" intent: copying the same link three times in a row is one share, not three. -/// The beacon send is fire-and-forget; no anonId is sent in wave 16.1. +/// The beacon send is fire-and-forget; the current anonId (wave 16.3) is read synchronously from +/// the warmed cache and omitted when null. /// /// public sealed class ShareTracker @@ -23,13 +25,20 @@ public sealed class ShareTracker // recommendation — long enough to fold a flurry of repeat copies into one intent. private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(60); + // Omit a null anonId from the wire payload (§2.2). Cosmetic — the API tolerates null — and does not + // change the integer enum encoding the 16.1 contract relies on. + private static readonly JsonSerializerOptions BeaconJson = + new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + private readonly BeaconInterop _beacon; + private readonly IAnonIdProvider _anonId; private readonly string _shareUrl; private readonly Dictionary _lastSent = new(); - public ShareTracker(BeaconInterop beacon, NavigationManager navigation) + public ShareTracker(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation) { _beacon = beacon; + _anonId = anonId; _shareUrl = $"{navigation.BaseUri}api/event/share"; } @@ -61,7 +70,8 @@ public sealed class ShareTracker TargetType = targetType, TargetKey = targetKey, Channel = channel, - }); + AnonId = _anonId.Current, + }, BeaconJson); // Fire-and-forget — a dropped share telemetry event is acceptable. _ = _beacon.SendAsync(_shareUrl, json); diff --git a/DeepDrftPublic.Client/Startup.cs b/DeepDrftPublic.Client/Startup.cs index 077f82e..55781aa 100644 --- a/DeepDrftPublic.Client/Startup.cs +++ b/DeepDrftPublic.Client/Startup.cs @@ -38,8 +38,11 @@ public static class Startup // Phase 16 anonymous telemetry (client side). BeaconInterop wraps sendBeacon; the play sink and // share tracker fire events through it. The play tracker itself is NOT registered — the player // is not DI-registered, so AudioPlayerProvider constructs the tracker and attaches it. ShareTracker - // is scoped so its per-(target,channel) debounce memory lives for the session. + // is scoped so its per-(target,channel) debounce memory lives for the session. AnonIdProvider + // (wave 16.3) caches the first-party localStorage listener id; scoped so the cache lives for the + // session, warmed when a surface goes interactive (the player provider, the share popover). services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); } diff --git a/DeepDrftPublic/Components/App.razor b/DeepDrftPublic/Components/App.razor index f11459c..e36ba5b 100644 --- a/DeepDrftPublic/Components/App.razor +++ b/DeepDrftPublic/Components/App.razor @@ -26,6 +26,7 @@ diff --git a/DeepDrftPublic/Interop/telemetry/anonid.ts b/DeepDrftPublic/Interop/telemetry/anonid.ts new file mode 100644 index 0000000..60457e1 --- /dev/null +++ b/DeepDrftPublic/Interop/telemetry/anonid.ts @@ -0,0 +1,65 @@ +/** + * Anonymous listener id interop (Phase 16 §3, wave 16.3, D5 Option A). Mints a random first-party GUID + * on first visit, stores it in localStorage, and reads it back thereafter — one opaque token per + * browser-install-until-cleared. No PII, no fingerprinting, no cross-site use: it is a "this browser, + * until you clear it" token the server counts distinctly to estimate unique listeners. + * + * Degrades safely: if localStorage is unavailable (private mode, blocked, partitioned third-party + * iframe) it returns null rather than throwing, and the caller simply sends no anonId. Over-counting is + * the known, accepted direction of error (§3). + * + * Exposed on window.DeepDrftAnonId; imported once in App.razor alongside the audio engine and beacon. + */ + +const STORAGE_KEY = 'deepdrft.anonId'; + +// crypto.randomUUID is the standard, secure source. A guarded fallback covers older/insecure-context +// browsers where it is absent — still a random opaque token, not a fingerprint. +function mint(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + // RFC4122-ish fallback from getRandomValues; only reached on browsers lacking randomUUID. + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +const DeepDrftAnonId = { + /** + * Read the stored anon id, minting and persisting one on first call. Returns null if localStorage + * cannot be read or written (private mode / blocked / partitioned) — telemetry then omits the id. + * Both the read and the write are guarded independently: a readable-but-unwritable store still mints + * a fresh id each call (acceptable over-count) rather than throwing. + */ + get: (): string | null => { + try { + const existing = localStorage.getItem(STORAGE_KEY); + if (existing) return existing; + + const minted = mint(); + try { + localStorage.setItem(STORAGE_KEY, minted); + } catch { + // Read worked, write did not — return the minted value anyway; it just won't persist. + } + return minted; + } catch { + // localStorage is entirely unavailable — send no anonId. + return null; + } + }, +}; + +declare global { + interface Window { + DeepDrftAnonId: typeof DeepDrftAnonId; + } +} + +window.DeepDrftAnonId = DeepDrftAnonId; + +export { DeepDrftAnonId }; diff --git a/DeepDrftTests/AnonIdPayloadTests.cs b/DeepDrftTests/AnonIdPayloadTests.cs new file mode 100644 index 0000000..1310f03 --- /dev/null +++ b/DeepDrftTests/AnonIdPayloadTests.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using DeepDrftModels.Enums; +using DeepDrftPublic.Client.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace DeepDrftTests; + +/// +/// Tests that the Phase 16 wave-16.3 anon id is threaded onto the beacon payloads emitted by +/// and , and omitted when the provider has +/// no token. Both sinks serialize internally and dispatch through BeaconInterop → the +/// DeepDrftBeacon.send(url, json) JS call, so the assertions capture that JSON string off a fake +/// JS runtime and inspect the anonId field — the same bytes the browser would POST. +/// +[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 InvokeAsync(string identifier, object?[]? args) + { + if (identifier == "DeepDrftBeacon.send" && args is { Length: 2 } && args[1] is string json) + LastJson = json; + return ValueTask.FromResult(default!); + } + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + => InvokeAsync(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 InvokeAsync(string identifier, object?[]? args) + => throw new InvalidOperationException("interop unavailable"); + + public ValueTask InvokeAsync(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); + } +} diff --git a/DeepDrftTests/AnonIdQueryTests.cs b/DeepDrftTests/AnonIdQueryTests.cs new file mode 100644 index 0000000..d554466 --- /dev/null +++ b/DeepDrftTests/AnonIdQueryTests.cs @@ -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; + +/// +/// 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)); + } +} diff --git a/DeepDrftTests/ShareTrackerTests.cs b/DeepDrftTests/ShareTrackerTests.cs index 3d8f5e8..f18c9eb 100644 --- a/DeepDrftTests/ShareTrackerTests.cs +++ b/DeepDrftTests/ShareTrackerTests.cs @@ -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]