From a768bbf15f91871c44c28f3800bf8c71faae16ea Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Tue, 4 Aug 2026 10:43:50 +1200 Subject: [PATCH] feat(audience): persist identityType from Identify() onto Track() events userId already carries forward from the last Identify() call onto subsequent Track() events; identityType did not. Mirrors Segment/PostHog, which persist the last identify()'d identity and reattach it to later events until the next identify() or a reset/consent downgrade. --- .../SampleApp/Scripts/AudienceSample.UI.cs | 2 +- .../SampleApp/Scripts/AudienceSample.cs | 10 +- .../Audience/Runtime/Core/ConsentState.cs | 12 ++- .../Audience/Runtime/Core/Constants.cs | 1 + .../Audience/Runtime/Events/MessageBuilder.cs | 6 +- .../Audience/Runtime/ImmutableAudience.cs | 34 +++++-- .../Runtime/Events/MessageBuilderTests.cs | 57 +++++++---- .../Tests/Runtime/ImmutableAudienceTests.cs | 96 ++++++++++++++++++- 8 files changed, 171 insertions(+), 47 deletions(-) diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs index ee8d1b099..659626135 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs @@ -656,7 +656,7 @@ private void UpdateAttButtonGate() private void RefreshIdentityPanel() { _identityUserId.text = ImmutableAudience.UserId ?? "—"; - _identityIdentityType.text = _mirrorIdentityType ?? "—"; + _identityIdentityType.text = ImmutableAudience.CurrentIdentityType?.ToLowercaseString() ?? "—"; _identityTraits.text = _mirrorTraits != null ? Json.Serialize(_mirrorTraits, 2) : "—"; _identityAliases.text = _mirrorAliases.Count == 0 ? "—" : string.Join("\n", _mirrorAliases); } diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs index 36e3fc7ee..ce493783b 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs @@ -30,9 +30,8 @@ public sealed partial class AudienceSample : MonoBehaviour private bool _initialised; private Action? _priorSdkLogWriter; - // Sample-side identity mirror. SDK owns UserId; type, traits, and - // aliases are tracked here for the Identity panel. - private string? _mirrorIdentityType; + // Sample-side identity mirror. SDK owns UserId and CurrentIdentityType; + // traits and aliases are tracked here for the Identity panel. private Dictionary? _mirrorTraits; private readonly List _mirrorAliases = new List(); @@ -192,7 +191,7 @@ private void OnIdentify() => RunAndLog("identify()", () => // before this line runs. The one silent no-op left is consent // below Full, which the UserId check below still catches. var accepted = string.Equals(ImmutableAudience.UserId, f.Id, StringComparison.Ordinal); - if (accepted) { _mirrorIdentityType = f.Type; _mirrorTraits = traits; } + if (accepted) _mirrorTraits = traits; OnSdkStateChanged(); var payload = new Dictionary { @@ -210,7 +209,7 @@ private void OnIdentifyTraits() => RunAndLog("identify(traits)", () => if (string.IsNullOrEmpty(userId)) throw new InvalidOperationException("no active identity; call Identify first"); var traits = ParseTraits(CaptureTraitsUpdate()); if (traits == null || traits.Count == 0) throw new InvalidOperationException("traits required"); - ImmutableAudience.Identify(userId, ParseIdentityType(_mirrorIdentityType), traits); + ImmutableAudience.Identify(userId, ImmutableAudience.CurrentIdentityType ?? IdentityType.Custom, traits); _mirrorTraits = traits; OnSdkStateChanged(); return Json.Serialize(traits, 2); @@ -382,7 +381,6 @@ private static string RedactPublishableKey(string key) private void ResetIdentityMirror() { - _mirrorIdentityType = null; _mirrorTraits = null; _mirrorAliases.Clear(); } diff --git a/src/Packages/Audience/Runtime/Core/ConsentState.cs b/src/Packages/Audience/Runtime/Core/ConsentState.cs index 665b05863..c1007bc56 100644 --- a/src/Packages/Audience/Runtime/Core/ConsentState.cs +++ b/src/Packages/Audience/Runtime/Core/ConsentState.cs @@ -10,11 +10,13 @@ internal static class IsExternalInit { } namespace Immutable.Audience { - // Pairs the consent level with the user id so the two always move - // together. Updates swap the whole pair at once. A reader never sees - // the new consent level alongside a leftover user id. - internal sealed record ConsentState(ConsentLevel Level, string? UserId) + // Pairs the consent level with the user id and identity type so all three + // always move together. Updates swap the whole set at once. A reader + // never sees the new consent level alongside a leftover user id or + // identity type. IdentityType has no default: every construction site + // must state it explicitly so a future caller can't silently drop it. + internal sealed record ConsentState(ConsentLevel Level, string? UserId, IdentityType? IdentityType) { - internal static readonly ConsentState None = new(ConsentLevel.None, null); + internal static readonly ConsentState None = new(ConsentLevel.None, null, null); } } diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index 077131501..c07aedd35 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -50,6 +50,7 @@ internal static class MessageFields { internal const string Type = "type"; internal const string UserId = "userId"; + internal const string IdentityType = "identityType"; internal const string DeviceId = "deviceId"; internal const string ConsentLevel = "consentLevel"; internal const string SessionId = "sessionId"; diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index 8f0182915..19c126a94 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -11,6 +11,7 @@ internal static Dictionary Track( string eventName, string? anonymousId, string? userId, + string? identityType, string? deviceId, string packageVersion, string consentLevel, @@ -28,6 +29,9 @@ internal static Dictionary Track( if (!string.IsNullOrEmpty(userId)) msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength); + if (!string.IsNullOrEmpty(identityType)) + msg[MessageFields.IdentityType] = Truncate(identityType, Constants.MaxFieldLength); + if (!string.IsNullOrEmpty(deviceId)) msg[MessageFields.DeviceId] = Truncate(deviceId, Constants.MaxFieldLength); @@ -65,7 +69,7 @@ internal static Dictionary Identify( if (!string.IsNullOrEmpty(deviceId)) msg[MessageFields.DeviceId] = Truncate(deviceId, Constants.MaxFieldLength); - msg["identityType"] = Truncate(identityType, Constants.MaxFieldLength); + msg[MessageFields.IdentityType] = Truncate(identityType, Constants.MaxFieldLength); if (traits != null && traits.Count > 0) { diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 5ea2b2d83..4725100d4 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -117,6 +117,17 @@ public static class ImmutableAudience /// public static string? UserId => _state.UserId; + /// + /// The identity provider from the most recent + /// + /// call. + /// + /// + /// Null after or when consent is below + /// . + /// + public static IdentityType? CurrentIdentityType => _state.IdentityType; + /// /// An anonymous, persistent ID for this device. /// @@ -230,7 +241,7 @@ public static void Init(AudienceConfig config) Log.Enabled = config.Debug; // Persisted consent overrides the config default (prior downgrade survives restart). var initialLevel = ConsentStore.Load(config.PersistentDataPath) ?? config.Consent; - _state = new ConsentState(initialLevel, null); + _state = new ConsentState(initialLevel, null, null); _store = new DiskStore(config.PersistentDataPath); _queue = new EventQueue(_store, config.FlushIntervalSeconds, config.FlushSize); @@ -419,7 +430,8 @@ private static void EnqueueTrackedEvent( var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, state.Level); var deviceId = Identity.GetOrCreateDeviceId(config.PersistentDataPath!, state.Level); var userId = state.Level == ConsentLevel.Full ? state.UserId : null; - var msg = MessageBuilder.Track(eventName, anonymousId, userId, deviceId, Constants.LibraryVersion, + var identityType = state.Level == ConsentLevel.Full ? state.IdentityType?.ToLowercaseString() : null; + var msg = MessageBuilder.Track(eventName, anonymousId, userId, identityType, deviceId, Constants.LibraryVersion, state.Level.ToLowercaseString(), properties, sessionId, config.TestMode, timestampOverride); EnqueueTrack(msg); } @@ -479,7 +491,7 @@ public static void Identify(string userId, IdentityType identityType, Dictionary } config = _config; if (config == null) return; - _state = current with { UserId = userId }; + _state = current with { UserId = userId, IdentityType = identityType }; } var anonymousId = Identity.GetOrCreate(config.PersistentDataPath!, level); @@ -564,7 +576,7 @@ public static void Reset() oldSession = _session; queueForPurge = _queue; - _state = _state with { UserId = null }; + _state = _state with { UserId = null, IdentityType = null }; // Swap under the lock so racing SetConsent/OnPause/OnResume see // either the old, the new, or null; never a torn reference. @@ -642,12 +654,13 @@ public static void SetConsent(ConsentLevel level) previous = previousState.Level; if (level == previous) return; - // Atomic swap: Level + UserId publish together. Drop UserId on - // any downgrade out of Full so a racing Track/Identify cannot - // observe (Anonymous, oldUserId). + // Atomic swap: Level + UserId + IdentityType publish together. + // Drop both on any downgrade out of Full so a racing + // Track/Identify cannot observe (Anonymous, oldUserId). _state = new ConsentState( level, - level == ConsentLevel.Full ? previousState.UserId : null); + level == ConsentLevel.Full ? previousState.UserId : null, + level == ConsentLevel.Full ? previousState.IdentityType : null); if (level == ConsentLevel.None) { @@ -931,7 +944,7 @@ public static void Shutdown() _config = null; _store = null; - _state = _state with { UserId = null }; + _state = _state with { UserId = null, IdentityType = null }; } // Phase 2 outside _initLock: end session, drain timers, flush, dispose. @@ -1026,7 +1039,10 @@ private static void EnqueueTrack(Dictionary? msg) if (!state.Level.CanTrack()) return null; m[MessageFields.ConsentLevel] = state.Level.ToLowercaseString(); if (state.Level != ConsentLevel.Full) + { m.Remove(MessageFields.UserId); + m.Remove(MessageFields.IdentityType); + } return m; }); } diff --git a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs index 9f60acc42..fcee3b6cc 100644 --- a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs @@ -16,7 +16,7 @@ public class MessageBuilderTests [Test] public void Track_RequiredFieldsPresent() { - var result = MessageBuilder.Track("level_complete", AnonId, null, null, PackageVersion, Consent); + var result = MessageBuilder.Track("level_complete", AnonId, null, null, null, PackageVersion, Consent); Assert.AreEqual("track", result["type"]); Assert.IsTrue(result.ContainsKey("messageId")); @@ -31,7 +31,7 @@ public void Track_EventNameLongerThan256Chars_TruncatedTo256() { var longName = new string('x', 300); - var result = MessageBuilder.Track(longName, null, null, null, PackageVersion, Consent); + var result = MessageBuilder.Track(longName, null, null, null, null, PackageVersion, Consent); Assert.AreEqual(256, ((string)result["eventName"]).Length); } @@ -39,7 +39,7 @@ public void Track_EventNameLongerThan256Chars_TruncatedTo256() [Test] public void Track_NullUserId_NotPresentInDict() { - var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion, Consent); + var result = MessageBuilder.Track("evt", AnonId, null, null, null, PackageVersion, Consent); Assert.IsFalse(result.ContainsKey("userId")); } @@ -47,16 +47,33 @@ public void Track_NullUserId_NotPresentInDict() [Test] public void Track_NonNullUserId_PresentInDict() { - var result = MessageBuilder.Track("evt", AnonId, "user-99", null, PackageVersion, Consent); + var result = MessageBuilder.Track("evt", AnonId, "user-99", null, null, PackageVersion, Consent); Assert.IsTrue(result.ContainsKey("userId")); Assert.AreEqual("user-99", result["userId"]); } + [Test] + public void Track_NullIdentityType_NotPresentInDict() + { + var result = MessageBuilder.Track("evt", AnonId, "user-99", null, null, PackageVersion, Consent); + + Assert.IsFalse(result.ContainsKey("identityType")); + } + + [Test] + public void Track_NonNullIdentityType_PresentInDict() + { + var result = MessageBuilder.Track("evt", AnonId, "user-99", "steam", null, PackageVersion, Consent); + + Assert.IsTrue(result.ContainsKey("identityType")); + Assert.AreEqual("steam", result["identityType"]); + } + [Test] public void Track_DeviceId_PresentWhenProvided() { - var result = MessageBuilder.Track("evt", AnonId, null, DeviceId, PackageVersion, Consent); + var result = MessageBuilder.Track("evt", AnonId, null, null, DeviceId, PackageVersion, Consent); Assert.IsTrue(result.ContainsKey("deviceId")); Assert.AreEqual(DeviceId, result["deviceId"]); @@ -65,7 +82,7 @@ public void Track_DeviceId_PresentWhenProvided() [Test] public void Track_DeviceId_AbsentWhenNull() { - var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion, Consent); + var result = MessageBuilder.Track("evt", AnonId, null, null, null, PackageVersion, Consent); Assert.IsFalse(result.ContainsKey("deviceId")); } @@ -114,7 +131,7 @@ public void Alias_DeviceId_PresentWhenProvided() [Test] public void AllMessages_ContextContainsLibraryAndLibraryVersion() { - var track = MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent); + var track = MessageBuilder.Track("evt", null, null, null, null, PackageVersion, Consent); var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, "full"); var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, "full"); @@ -129,7 +146,7 @@ public void AllMessages_ContextContainsLibraryAndLibraryVersion() [Test] public void AllMessages_SurfaceIsUnity() { - var track = MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent); + var track = MessageBuilder.Track("evt", null, null, null, null, PackageVersion, Consent); var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, "full"); var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, "full"); @@ -143,7 +160,7 @@ public void AllMessages_ConsentLevelStamped() { // Every message carries the consent level it was built under, so the // backend records the explicit level instead of inferring it. - var track = MessageBuilder.Track("evt", null, null, null, PackageVersion, "anonymous"); + var track = MessageBuilder.Track("evt", null, null, null, null, PackageVersion, "anonymous"); var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, "full"); var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, "full"); @@ -158,7 +175,7 @@ public void Track_ConsentLevelFull_NoUserId_IsFullButUnidentified() // Full consent does not require a userId (e.g. before Identify()); the // explicit consentLevel is exactly what distinguishes this from // anonymous traffic. - var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion, "full"); + var result = MessageBuilder.Track("evt", AnonId, null, null, null, PackageVersion, "full"); Assert.AreEqual("full", result["consentLevel"]); Assert.IsFalse(result.ContainsKey("userId")); @@ -181,7 +198,7 @@ public void Track_MessageId_IsUniquePerCall() // Backend deduplicates on messageId; collisions silently drop events. var ids = new HashSet(); for (var i = 0; i < 1000; i++) - ids.Add((string)MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent)["messageId"]); + ids.Add((string)MessageBuilder.Track("evt", null, null, null, null, PackageVersion, Consent)["messageId"]); Assert.AreEqual(1000, ids.Count); } @@ -212,7 +229,7 @@ public void Track_EventTimestampOverride_UsedInsteadOfNow() { var backdated = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); - var result = MessageBuilder.Track("session_end", AnonId, null, null, PackageVersion, Consent, + var result = MessageBuilder.Track("session_end", AnonId, null, null, null, PackageVersion, Consent, eventTimestamp: backdated); Assert.AreEqual(backdated.ToString("o"), result["eventTimestamp"]); @@ -226,7 +243,7 @@ public void Track_EventTimestampOverride_NonUtcKind_NormalizedToUtc() // ship a non-UTC "o" string past the backend's schema check. var unspecified = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified); - var result = MessageBuilder.Track("session_end", AnonId, null, null, PackageVersion, Consent, + var result = MessageBuilder.Track("session_end", AnonId, null, null, null, PackageVersion, Consent, eventTimestamp: unspecified); var ts = (string)result["eventTimestamp"]; @@ -249,7 +266,7 @@ public void AllMessages_Context_LibraryAndLibraryVersionAreNonEmptyStrings() [Test] public void Track_TestModeTrue_IncludesTestFlag() { - var result = MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent, testMode: true); + var result = MessageBuilder.Track("evt", null, null, null, null, PackageVersion, Consent, testMode: true); Assert.IsTrue(result.ContainsKey("test"), "test field must be present when testMode is true"); Assert.AreEqual(true, result["test"]); } @@ -257,14 +274,14 @@ public void Track_TestModeTrue_IncludesTestFlag() [Test] public void Track_TestModeFalse_ExcludesTestFlag() { - var result = MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent, testMode: false); + var result = MessageBuilder.Track("evt", null, null, null, null, PackageVersion, Consent, testMode: false); Assert.IsFalse(result.ContainsKey("test"), "test field must not be present when testMode is false"); } [Test] public void AllMessages_TestModeTrue_AllIncludeTestFlag() { - var track = MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent, testMode: true); + var track = MessageBuilder.Track("evt", null, null, null, null, PackageVersion, Consent, testMode: true); var identify = MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, "full", testMode: true); var alias = MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, "full", testMode: true); @@ -277,7 +294,7 @@ public void AllMessages_TestModeTrue_AllIncludeTestFlag() private static IEnumerable> EveryMessageType() { - yield return MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent); + yield return MessageBuilder.Track("evt", null, null, null, null, PackageVersion, Consent); yield return MessageBuilder.Identify(null, "u1", null, "steam", PackageVersion, "full"); yield return MessageBuilder.Alias("f", "t1", "t", "t2", null, PackageVersion, "full"); } @@ -289,7 +306,7 @@ private static IEnumerable> EveryMessageType() [Test] public void Track_SessionIdProvided_PresentInDict() { - var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion, Consent, + var result = MessageBuilder.Track("evt", AnonId, null, null, null, PackageVersion, Consent, sessionId: "session-1"); Assert.IsTrue(result.ContainsKey("sessionId")); @@ -299,7 +316,7 @@ public void Track_SessionIdProvided_PresentInDict() [Test] public void Track_SessionIdNull_AbsentFromDict() { - var result = MessageBuilder.Track("evt", AnonId, null, null, PackageVersion, Consent); + var result = MessageBuilder.Track("evt", AnonId, null, null, null, PackageVersion, Consent); Assert.IsFalse(result.ContainsKey("sessionId")); } @@ -345,7 +362,7 @@ public void Track_SessionIdLongerThan256Chars_TruncatedTo256() { var longSessionId = new string('s', 300); - var result = MessageBuilder.Track("evt", null, null, null, PackageVersion, Consent, + var result = MessageBuilder.Track("evt", null, null, null, null, PackageVersion, Consent, sessionId: longSessionId); Assert.AreEqual(256, ((string)result["sessionId"]).Length); diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 14797c934..268e7fd2f 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -120,6 +120,22 @@ public void UserId_AfterIdentifyAndReset_TracksState() "Reset must clear UserId so the next player is not attributed to the previous one"); } + [Test] + public void CurrentIdentityType_AfterIdentifyAndReset_TracksState() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + Assert.IsNull(ImmutableAudience.CurrentIdentityType, + "CurrentIdentityType should be null until Identify is called"); + + ImmutableAudience.Identify("player-42", IdentityType.Steam); + Assert.AreEqual(IdentityType.Steam, ImmutableAudience.CurrentIdentityType, + "CurrentIdentityType must reflect the most recent Identify call"); + + ImmutableAudience.Reset(); + Assert.IsNull(ImmutableAudience.CurrentIdentityType, + "Reset must clear CurrentIdentityType so the next player is not attributed to the previous one"); + } + [Test] public void AnonymousId_ConsentNone_ReturnsNull() { @@ -213,6 +229,39 @@ public void DeviceId_PresentOnTrackEvents() "track events must include deviceId at Anonymous+ consent"); } + [Test] + public void IdentityType_PersistsFromIdentifyOntoSubsequentTrackEvents() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + ImmutableAudience.Identify("player-42", IdentityType.Steam); + ImmutableAudience.Track("post_identify_event"); + ImmutableAudience.FlushQueueToDiskForTesting(); + + var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); + var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + + Assert.IsTrue( + blobs.Any(b => b.Contains("\"eventName\":\"post_identify_event\"") && b.Contains("\"identityType\":\"steam\"")), + "track events after Identify must carry the identityType from that Identify call"); + } + + [Test] + public void IdentityType_ClearedByReset_AbsentFromSubsequentTrackEvents() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + ImmutableAudience.Identify("player-42", IdentityType.Steam); + ImmutableAudience.Reset(); + ImmutableAudience.Track("post_reset_event"); + ImmutableAudience.FlushQueueToDiskForTesting(); + + var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); + var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + var postResetBlob = blobs.First(b => b.Contains("\"eventName\":\"post_reset_event\"")); + + Assert.IsFalse(postResetBlob.Contains("\"identityType\":"), + "track events after Reset must not carry a stale identityType"); + } + [Test] public void DeviceId_StableAcrossAnonIdRotation() { @@ -1421,8 +1470,9 @@ public void SetConsent_DowngradeToAnonymous_StressTest_NoUserIdLeak() // recorded before the downgrade keep the userId/consent they were // captured with and are intentionally not rewritten. // - // Sabotage: remove the `m.Remove(MessageFields.UserId)` in - // EnqueueTrack and this test leaks reproducibly. + // Sabotage: remove either `m.Remove(MessageFields.UserId)` or + // `m.Remove(MessageFields.IdentityType)` in EnqueueTrack and this + // test leaks reproducibly. const int trackersPerIteration = 4; const string testUserId = "user_race_stress"; @@ -1452,14 +1502,50 @@ public void SetConsent_DowngradeToAnonymous_StressTest_NoUserIdLeak() ImmutableAudience.FlushQueueToDiskForTesting(); int userIdLeaks = 0; + int identityTypeLeaks = 0; if (Directory.Exists(queueDir)) { - userIdLeaks = Directory.GetFiles(queueDir, "*.json") - .Select(File.ReadAllText) - .Count(c => c.Contains($"\"{testUserId}\"")); + var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + userIdLeaks = blobs.Count(c => c.Contains($"\"{testUserId}\"")); + identityTypeLeaks = blobs.Count(c => c.Contains("\"identityType\":\"steam\"")); } Assert.AreEqual(0, userIdLeaks, "track events must not retain userId past SetConsent(Anonymous)"); + Assert.AreEqual(0, identityTypeLeaks, "track events must not retain identityType past SetConsent(Anonymous)"); + } + + [Test] + public void SetConsent_DowngradeToAnonymous_ClearsIdentityTypeAndCurrentIdentityType() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + ImmutableAudience.Identify("player-42", IdentityType.Steam); + + ImmutableAudience.SetConsent(ConsentLevel.Anonymous); + Assert.IsNull(ImmutableAudience.CurrentIdentityType, + "CurrentIdentityType must be cleared by a downgrade out of Full consent"); + + ImmutableAudience.Track("post_downgrade_event"); + ImmutableAudience.FlushQueueToDiskForTesting(); + + var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); + var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + var postDowngradeBlob = blobs.First(b => b.Contains("\"eventName\":\"post_downgrade_event\"")); + + Assert.IsFalse(postDowngradeBlob.Contains("\"identityType\":"), + "track events after a consent downgrade must not carry a stale identityType"); + } + + [Test] + public void Shutdown_ClearsCurrentIdentityType() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + ImmutableAudience.Identify("player-42", IdentityType.Steam); + Assert.AreEqual(IdentityType.Steam, ImmutableAudience.CurrentIdentityType); + + ImmutableAudience.Shutdown(); + + Assert.IsNull(ImmutableAudience.CurrentIdentityType, + "Shutdown must clear CurrentIdentityType so a later Init doesn't inherit a stale identity"); } [Test]