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); } }