2af0d8650b
Route normal play closes (end/switch/stop) and all shares through a same-origin HttpClient POST so privacy-hardened browsers stop blocking them; keep sendBeacon for the tab-unload edge. Rename the JS module off telemetry/beacon to session/ lifecycle so the retained fallback isn't name-matched. No new data or identifiers.
101 lines
4.6 KiB
C#
101 lines
4.6 KiB
C#
using DeepDrftModels.Enums;
|
|
using DeepDrftPublic.Client.Services;
|
|
using Microsoft.AspNetCore.Components;
|
|
|
|
namespace DeepDrftTests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the Phase 16 share tracker (<see cref="ShareTracker"/>): the per-(target,channel)
|
|
/// debounce (§1b — at most one event per target+channel per 60s window per session). The tracker fires
|
|
/// through the first-party <see cref="IEventPoster"/>; the tests use a no-op poster (the POST is
|
|
/// fire-and-forget and its outcome is irrelevant) and assert on the debounce decision via the bool the
|
|
/// recorder returns — true when an event fired, false when debounced.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class ShareTrackerTests
|
|
{
|
|
// The first-party POST is fire-and-forget; the tracker never reads the result, so a no-op poster that
|
|
// completes immediately is sufficient to exercise the debounce logic.
|
|
private sealed class NoopEventPoster : IEventPoster
|
|
{
|
|
public Task PostAsync(string url, string json) => Task.CompletedTask;
|
|
}
|
|
|
|
// Minimal NavigationManager so the tracker can compose the (unused-in-test) event URL.
|
|
private sealed class TestNavigationManager : NavigationManager
|
|
{
|
|
public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/");
|
|
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 NoopEventPoster(),
|
|
new StubAnonIdProvider("anon-1"),
|
|
new TestNavigationManager());
|
|
|
|
// A copy-link records one share with channel = link.
|
|
[Test]
|
|
public void RecordShare_CopyLink_FiresOnce()
|
|
=> Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
|
|
|
|
// A copy-embed records one share with channel = embed — distinct (target,channel) from the link copy.
|
|
[Test]
|
|
public void RecordShare_CopyEmbedAfterLink_FiresSeparately()
|
|
{
|
|
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
|
|
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Embed, _t0), Is.True,
|
|
"embed is a different channel from link — not debounced against it");
|
|
}
|
|
|
|
// An immediate repeat copy of the same (target, channel) within the window is debounced.
|
|
[Test]
|
|
public void RecordShare_ImmediateRepeat_IsDebounced()
|
|
{
|
|
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
|
|
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0.AddSeconds(5)), Is.False);
|
|
}
|
|
|
|
// After the 60s window elapses, the same (target, channel) fires again.
|
|
[Test]
|
|
public void RecordShare_AfterWindow_FiresAgain()
|
|
{
|
|
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
|
|
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0.AddSeconds(61)), Is.True);
|
|
}
|
|
|
|
// Different targets debounce independently — sharing track A then track B both fire.
|
|
[Test]
|
|
public void RecordShare_DifferentTargets_FireIndependently()
|
|
{
|
|
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "a", ShareChannel.Link, _t0), Is.True);
|
|
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "b", ShareChannel.Link, _t0), Is.True);
|
|
}
|
|
|
|
// A track key and a release key are distinct targets even if the key string collides.
|
|
[Test]
|
|
public void RecordShare_TrackVsRelease_AreDistinctTargets()
|
|
{
|
|
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "x", ShareChannel.Link, _t0), Is.True);
|
|
Assert.That(_tracker.RecordShare(ShareTargetType.Release, "x", ShareChannel.Link, _t0), Is.True);
|
|
}
|
|
|
|
// A blank target key never fires (defensive — the popover guards too).
|
|
[Test]
|
|
public void RecordShare_BlankKey_DoesNotFire()
|
|
=> Assert.That(_tracker.RecordShare(ShareTargetType.Track, " ", ShareChannel.Link, _t0), Is.False);
|
|
}
|