From df012fbeedf526e379c19c60870ebb3ea112cbe5 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 13:26:10 +1000 Subject: [PATCH 01/12] refactor(audience-sdk): centralise IdentityType and ConsentLevel wire-format strings The wire-format strings emitted under MessageFields.IdentityType and the ConsentLevel display strings used to live as inline literals in three places: the SDK's switch maps in IdentityType.cs / ConsentLevel.cs, the [TestCase] attributes in IdentityTypeTests / ConsentLevelTests, and the sample-app's SampleAppUi.Consent dropdown / status-cell strings. Adds: - IdentityTypeWireFormat (Passport, Steam, Epic, Google, Apple, Discord, Email, Custom) in IdentityType.cs - ConsentLevelWireFormat (None, Anonymous, Full) in ConsentLevel.cs Migrations: - IdentityTypeExtensions.ToLowercaseString and ParseLowercaseString switches now reference IdentityTypeWireFormat. - ConsentLevelExtensions.ToLowercaseString switch references ConsentLevelWireFormat. - IdentityTypeTests / ConsentLevelTests [TestCase] attributes reference the consts. - SampleAppUi.Consent constants delegate to ConsentLevelWireFormat. Centralising loses the implicit wire-format pinning the duplicated [TestCase] strings used to provide (a typo in the SDK switch would no longer be caught by the test, since both reference the same const). Restored explicitly via two new tests: IdentityTypeWireFormat_PinsExactStringValues and ConsentLevelWireFormat_PinsExactStringValues, which assert each const equals its expected wire string. A backend contract break or accidental rename now fails at the pinning test rather than slipping through. Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Assets/SampleApp/Scripts/SampleAppUi.cs | 8 ++-- src/Packages/Audience/Runtime/ConsentLevel.cs | 14 ++++-- src/Packages/Audience/Runtime/IdentityType.cs | 43 +++++++++++------ .../Tests/Runtime/ConsentLevelTests.cs | 15 ++++-- .../Tests/Runtime/IdentityTypeTests.cs | 46 ++++++++++++------- 5 files changed, 85 insertions(+), 41 deletions(-) diff --git a/examples/audience/Assets/SampleApp/Scripts/SampleAppUi.cs b/examples/audience/Assets/SampleApp/Scripts/SampleAppUi.cs index d842cf876..01da185d9 100644 --- a/examples/audience/Assets/SampleApp/Scripts/SampleAppUi.cs +++ b/examples/audience/Assets/SampleApp/Scripts/SampleAppUi.cs @@ -310,13 +310,13 @@ internal static class LogLabels } // ---- Consent dropdown / status values ---- - // Mirrors ConsentLevel.ToLowercaseString(). + // Mirrors ConsentLevel.ToLowercaseString() so the sample-app stays aligned with SDK output. internal static class Consent { - internal const string None = "none"; - internal const string Anonymous = "anonymous"; - internal const string Full = "full"; + internal const string None = ConsentLevelWireFormat.None; + internal const string Anonymous = ConsentLevelWireFormat.Anonymous; + internal const string Full = ConsentLevelWireFormat.Full; } // Log payload JSON keys used by RunAndLog "Ok" row dictionaries. diff --git a/src/Packages/Audience/Runtime/ConsentLevel.cs b/src/Packages/Audience/Runtime/ConsentLevel.cs index 2b3db4724..98a4f75ce 100644 --- a/src/Packages/Audience/Runtime/ConsentLevel.cs +++ b/src/Packages/Audience/Runtime/ConsentLevel.cs @@ -28,13 +28,21 @@ public enum ConsentLevel Full } + // Strings the SDK emits for ConsentLevel. + internal static class ConsentLevelWireFormat + { + internal const string None = "none"; + internal const string Anonymous = "anonymous"; + internal const string Full = "full"; + } + internal static class ConsentLevelExtensions { internal static string ToLowercaseString(this ConsentLevel level) => level switch { - ConsentLevel.None => "none", - ConsentLevel.Anonymous => "anonymous", - ConsentLevel.Full => "full", + ConsentLevel.None => ConsentLevelWireFormat.None, + ConsentLevel.Anonymous => ConsentLevelWireFormat.Anonymous, + ConsentLevel.Full => ConsentLevelWireFormat.Full, _ => throw new System.ArgumentOutOfRangeException( nameof(level), level, "Unhandled ConsentLevel"), }; diff --git a/src/Packages/Audience/Runtime/IdentityType.cs b/src/Packages/Audience/Runtime/IdentityType.cs index 6d5fe1d3b..08dd5351e 100644 --- a/src/Packages/Audience/Runtime/IdentityType.cs +++ b/src/Packages/Audience/Runtime/IdentityType.cs @@ -37,6 +37,19 @@ public enum IdentityType Custom, } + // Strings emitted under MessageFields.IdentityType. + internal static class IdentityTypeWireFormat + { + internal const string Passport = "passport"; + internal const string Steam = "steam"; + internal const string Epic = "epic"; + internal const string Google = "google"; + internal const string Apple = "apple"; + internal const string Discord = "discord"; + internal const string Email = "email"; + internal const string Custom = "custom"; + } + internal static class IdentityTypeExtensions { // Throws on unknown casts. Every identify / alias event must carry an @@ -45,14 +58,14 @@ internal static class IdentityTypeExtensions // loudly rather than ship an event with a missing or empty namespace. internal static string ToLowercaseString(this IdentityType type) => type switch { - IdentityType.Passport => "passport", - IdentityType.Steam => "steam", - IdentityType.Epic => "epic", - IdentityType.Google => "google", - IdentityType.Apple => "apple", - IdentityType.Discord => "discord", - IdentityType.Email => "email", - IdentityType.Custom => "custom", + IdentityType.Passport => IdentityTypeWireFormat.Passport, + IdentityType.Steam => IdentityTypeWireFormat.Steam, + IdentityType.Epic => IdentityTypeWireFormat.Epic, + IdentityType.Google => IdentityTypeWireFormat.Google, + IdentityType.Apple => IdentityTypeWireFormat.Apple, + IdentityType.Discord => IdentityTypeWireFormat.Discord, + IdentityType.Email => IdentityTypeWireFormat.Email, + IdentityType.Custom => IdentityTypeWireFormat.Custom, _ => throw new System.ArgumentOutOfRangeException(nameof(type), type, "Unknown IdentityType value; cast an out-of-range value."), }; @@ -60,13 +73,13 @@ internal static class IdentityTypeExtensions internal static IdentityType ParseLowercaseString(string? value) => (value ?? string.Empty).ToLowerInvariant() switch { - "passport" => IdentityType.Passport, - "steam" => IdentityType.Steam, - "epic" => IdentityType.Epic, - "google" => IdentityType.Google, - "apple" => IdentityType.Apple, - "discord" => IdentityType.Discord, - "email" => IdentityType.Email, + IdentityTypeWireFormat.Passport => IdentityType.Passport, + IdentityTypeWireFormat.Steam => IdentityType.Steam, + IdentityTypeWireFormat.Epic => IdentityType.Epic, + IdentityTypeWireFormat.Google => IdentityType.Google, + IdentityTypeWireFormat.Apple => IdentityType.Apple, + IdentityTypeWireFormat.Discord => IdentityType.Discord, + IdentityTypeWireFormat.Email => IdentityType.Email, _ => IdentityType.Custom, }; } diff --git a/src/Packages/Audience/Tests/Runtime/ConsentLevelTests.cs b/src/Packages/Audience/Tests/Runtime/ConsentLevelTests.cs index 27ed22710..89a556fd1 100644 --- a/src/Packages/Audience/Tests/Runtime/ConsentLevelTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ConsentLevelTests.cs @@ -6,14 +6,23 @@ namespace Immutable.Audience.Tests [TestFixture] internal class ConsentLevelTests { - [TestCase(ConsentLevel.None, "none")] - [TestCase(ConsentLevel.Anonymous, "anonymous")] - [TestCase(ConsentLevel.Full, "full")] + [TestCase(ConsentLevel.None, ConsentLevelWireFormat.None)] + [TestCase(ConsentLevel.Anonymous, ConsentLevelWireFormat.Anonymous)] + [TestCase(ConsentLevel.Full, ConsentLevelWireFormat.Full)] public void ToLowercaseString_MapsEachEnumValueToLowercaseBackendString(ConsentLevel level, string expected) { Assert.AreEqual(expected, level.ToLowercaseString()); } + [Test] + public void ConsentLevelWireFormat_PinsExactStringValues() + { + // Pins each emitted string directly so a typo or backend rename fails the build. + Assert.AreEqual("none", ConsentLevelWireFormat.None); + Assert.AreEqual("anonymous", ConsentLevelWireFormat.Anonymous); + Assert.AreEqual("full", ConsentLevelWireFormat.Full); + } + [Test] public void ToLowercaseString_UnknownValue_Throws() { diff --git a/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs b/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs index 0ec2de376..4ff2ec293 100644 --- a/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs +++ b/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs @@ -6,32 +6,46 @@ namespace Immutable.Audience.Tests [TestFixture] internal class IdentityTypeTests { - [TestCase(IdentityType.Passport, "passport")] - [TestCase(IdentityType.Steam, "steam")] - [TestCase(IdentityType.Epic, "epic")] - [TestCase(IdentityType.Google, "google")] - [TestCase(IdentityType.Apple, "apple")] - [TestCase(IdentityType.Discord, "discord")] - [TestCase(IdentityType.Email, "email")] - [TestCase(IdentityType.Custom, "custom")] + [TestCase(IdentityType.Passport, IdentityTypeWireFormat.Passport)] + [TestCase(IdentityType.Steam, IdentityTypeWireFormat.Steam)] + [TestCase(IdentityType.Epic, IdentityTypeWireFormat.Epic)] + [TestCase(IdentityType.Google, IdentityTypeWireFormat.Google)] + [TestCase(IdentityType.Apple, IdentityTypeWireFormat.Apple)] + [TestCase(IdentityType.Discord, IdentityTypeWireFormat.Discord)] + [TestCase(IdentityType.Email, IdentityTypeWireFormat.Email)] + [TestCase(IdentityType.Custom, IdentityTypeWireFormat.Custom)] public void ToLowercaseString_MapsEachEnumValueToLowercaseBackendString(IdentityType type, string expected) { Assert.AreEqual(expected, type.ToLowercaseString()); } - [TestCase("passport", IdentityType.Passport)] - [TestCase("steam", IdentityType.Steam)] - [TestCase("epic", IdentityType.Epic)] - [TestCase("google", IdentityType.Google)] - [TestCase("apple", IdentityType.Apple)] - [TestCase("discord", IdentityType.Discord)] - [TestCase("email", IdentityType.Email)] - [TestCase("custom", IdentityType.Custom)] + [TestCase(IdentityTypeWireFormat.Passport, IdentityType.Passport)] + [TestCase(IdentityTypeWireFormat.Steam, IdentityType.Steam)] + [TestCase(IdentityTypeWireFormat.Epic, IdentityType.Epic)] + [TestCase(IdentityTypeWireFormat.Google, IdentityType.Google)] + [TestCase(IdentityTypeWireFormat.Apple, IdentityType.Apple)] + [TestCase(IdentityTypeWireFormat.Discord, IdentityType.Discord)] + [TestCase(IdentityTypeWireFormat.Email, IdentityType.Email)] + [TestCase(IdentityTypeWireFormat.Custom, IdentityType.Custom)] public void ParseLowercaseString_MapsKnownStringToEnum(string wire, IdentityType expected) { Assert.AreEqual(expected, IdentityTypeExtensions.ParseLowercaseString(wire)); } + [Test] + public void IdentityTypeWireFormat_PinsExactStringValues() + { + // Pins each constant's exact string so a typo or backend rename fails the build. + Assert.AreEqual("passport", IdentityTypeWireFormat.Passport); + Assert.AreEqual("steam", IdentityTypeWireFormat.Steam); + Assert.AreEqual("epic", IdentityTypeWireFormat.Epic); + Assert.AreEqual("google", IdentityTypeWireFormat.Google); + Assert.AreEqual("apple", IdentityTypeWireFormat.Apple); + Assert.AreEqual("discord", IdentityTypeWireFormat.Discord); + Assert.AreEqual("email", IdentityTypeWireFormat.Email); + Assert.AreEqual("custom", IdentityTypeWireFormat.Custom); + } + [TestCase("Steam", IdentityType.Steam)] [TestCase("STEAM", IdentityType.Steam)] [TestCase("Passport", IdentityType.Passport)] From f8791f0df58306f0fbe7d0a84b849c4c02a018ea Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 13:27:18 +1000 Subject: [PATCH 02/12] test(audience-sdk): use nameof() for typed-event validation message field references TypedEventTests asserted that argument-validation error messages contained the C# property name being checked: Does.Contain("Status"), Does.Contain("Flow"), Does.Contain("Currency") (twice), Does.Contain("Amount"), Does.Contain("Value"). Each inline string mirrored a Progression / Resource / Purchase property name. A property rename in the SDK (Resource.Currency to Resource.CurrencyCode, say) would break the message wording but pass the test, since "Currency" was matched as a substring even when the SDK no longer mentioned it. Replaces six inline strings with nameof(Progression.Status), nameof(Resource.Flow), nameof(Resource.Currency), nameof(Resource.Amount), nameof(Purchase.Currency), nameof(Purchase.Value). Each now tracks the property name at compile time; renaming the property either updates the test automatically or fails to compile. Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Audience/Tests/Runtime/Events/TypedEventTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs b/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs index 1cd0ac742..afd0fb353 100644 --- a/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs @@ -18,7 +18,7 @@ public void Progression_WithoutStatus_ThrowsOnToProperties() var evt = new Progression { World = TestFixtures.ProgressionWorldTutorial }; var ex = Assert.Throws(() => evt.ToProperties()); - Assert.That(ex!.Message, Does.Contain("Status")); + Assert.That(ex!.Message, Does.Contain(nameof(Progression.Status))); } [Test] @@ -88,7 +88,7 @@ public void Resource_WithoutFlow_ThrowsOnToProperties() var evt = new Resource { Currency = TestFixtures.ResourceCurrency, Amount = 100 }; var ex = Assert.Throws(() => evt.ToProperties()); - Assert.That(ex!.Message, Does.Contain("Flow")); + Assert.That(ex!.Message, Does.Contain(nameof(Resource.Flow))); } [Test] @@ -97,7 +97,7 @@ public void Resource_WithoutCurrency_ThrowsOnToProperties() var evt = new Resource { Flow = ResourceFlow.Source, Amount = 100 }; var ex = Assert.Throws(() => evt.ToProperties()); - Assert.That(ex!.Message, Does.Contain("Currency")); + Assert.That(ex!.Message, Does.Contain(nameof(Resource.Currency))); } [Test] @@ -106,7 +106,7 @@ public void Resource_WithoutAmount_ThrowsOnToProperties() var evt = new Resource { Flow = ResourceFlow.Source, Currency = TestFixtures.ResourceCurrency }; var ex = Assert.Throws(() => evt.ToProperties()); - Assert.That(ex!.Message, Does.Contain("Amount")); + Assert.That(ex!.Message, Does.Contain(nameof(Resource.Amount))); } [Test] @@ -157,7 +157,7 @@ public void Purchase_WithoutCurrency_ThrowsOnToProperties() var evt = new Purchase { Value = 9.99m }; var ex = Assert.Throws(() => evt.ToProperties()); - Assert.That(ex!.Message, Does.Contain("Currency")); + Assert.That(ex!.Message, Does.Contain(nameof(Purchase.Currency))); } [Test] @@ -166,7 +166,7 @@ public void Purchase_WithoutValue_ThrowsOnToProperties() var evt = new Purchase { Currency = TestFixtures.UsdCurrency }; var ex = Assert.Throws(() => evt.ToProperties()); - Assert.That(ex!.Message, Does.Contain("Value")); + Assert.That(ex!.Message, Does.Contain(nameof(Purchase.Value))); } [Test] From 4f240a14696e2af2d3ac9f17df1297e19c752cdc Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 13:29:24 +1000 Subject: [PATCH 03/12] test(audience-sdk): centralise LogTests inputs and reference Log.Prefix LogTests had inline literals for the Log.Debug / Log.Warn inputs ("silent", "hello", "something off"), the prefix substring asserted on emitted lines ("[ImmutableAudience]"), and the warn marker ("WARN"). Adds four file-local consts (SilentDebugInput, EnabledDebugInput, WarnInput, WarnMarker) at the top of the fixture and migrates the six call sites. The prefix assertion now references Log.Prefix, the SDK's existing const for the same string, so a SDK-side prefix rename automatically propagates. The "WARN" substring is kept as a local marker rather than slicing Log.WarnPrefix; "[ImmutableAudience] WARN:" includes punctuation the assertion does not require. A direct WARN constant matches the test's actual intent ("the warn pipeline includes WARN somewhere"). Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Tests/Runtime/Utility/LogTests.cs | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/Packages/Audience/Tests/Runtime/Utility/LogTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/LogTests.cs index 42a3cda0c..a890a691a 100644 --- a/src/Packages/Audience/Tests/Runtime/Utility/LogTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Utility/LogTests.cs @@ -6,6 +6,14 @@ namespace Immutable.Audience.Tests [TestFixture] internal class LogTests { + // Inputs to Log.Debug / Log.Warn used across the fixture. + private const string SilentDebugInput = "silent"; + private const string EnabledDebugInput = "hello"; + private const string WarnInput = "something off"; + + // Substring marker that Log.Warn injects between the prefix and the user message. + private const string WarnMarker = "WARN"; + private List _captured; [SetUp] @@ -28,7 +36,7 @@ public void Debug_WhenDisabled_EmitsNothing() { Log.Enabled = false; - Log.Debug("silent"); + Log.Debug(SilentDebugInput); Assert.AreEqual(0, _captured.Count); } @@ -38,11 +46,11 @@ public void Debug_WhenEnabled_EmitsWithPrefix() { Log.Enabled = true; - Log.Debug("hello"); + Log.Debug(EnabledDebugInput); Assert.AreEqual(1, _captured.Count); - StringAssert.StartsWith("[ImmutableAudience]", _captured[0]); - StringAssert.Contains("hello", _captured[0]); + StringAssert.StartsWith(Log.Prefix, _captured[0]); + StringAssert.Contains(EnabledDebugInput, _captured[0]); } [Test] @@ -50,11 +58,11 @@ public void Warn_AlwaysEmits_EvenWhenDisabled() { Log.Enabled = false; - Log.Warn("something off"); + Log.Warn(WarnInput); Assert.AreEqual(1, _captured.Count); - StringAssert.Contains("WARN", _captured[0]); - StringAssert.Contains("something off", _captured[0]); + StringAssert.Contains(WarnMarker, _captured[0]); + StringAssert.Contains(WarnInput, _captured[0]); } } } From 51edfcc1db3c1d3258a09b9a880beab9952f43d4 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 13:31:15 +1000 Subject: [PATCH 04/12] test(audience-sdk): route queue assertions through SDK constants ImmutableAudienceTests was using inline strings to assert against captured log lines and queue file contents: - Has.Some.Contains("Dropping") for the dropped-event marker that Track / Identify / Alias warns share - "\"purchase\"", "\"identify\"", "\"alias\"" for queue-file content checks (the wire-format envelope's "type" / "eventName" fields) Adds AudienceLogs.DroppingMarker = "Dropping" so the marker has a single source of truth and the test references it directly. Migrates queue-file content checks to interpolate against the SDK's existing EventNames.Purchase, MessageTypes.Identify, and MessageTypes.Alias constants so a backend rename to those wire strings would touch one place rather than scatter across the test. Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- src/Packages/Audience/Runtime/Utility/Log.cs | 4 ++++ .../Audience/Tests/Runtime/ImmutableAudienceTests.cs | 12 ++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs index 788f09323..dec6b7de4 100644 --- a/src/Packages/Audience/Runtime/Utility/Log.cs +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -85,6 +85,10 @@ internal static string ConsentSyncThrew(Exception ex) => internal static class AudienceLogs { + // Marker shared by Track / Identify / Alias dropped-event log messages. + internal const string DroppingMarker = "Dropping"; + + // ---- Init / config validation ---- internal const string InitCalledTwice = diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 7384c56a2..0e1969547 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -323,7 +323,7 @@ public void Track_IEventMissingRequiredField_DropsWithWarn() // Assert the stable parts (event-type name and trailing "Dropping") // so the test survives any change to the exception type or message. Assert.That(lines, Has.Some.Contains(nameof(Purchase))); - Assert.That(lines, Has.Some.Contains("Dropping")); + Assert.That(lines, Has.Some.Contains(AudienceLogs.DroppingMarker)); } finally { Log.Writer = null; } @@ -331,7 +331,7 @@ public void Track_IEventMissingRequiredField_DropsWithWarn() var queueDir = AudiencePaths.QueueDir(_testDir); var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); - Assert.IsFalse(contents.Any(c => c.Contains("\"purchase\"")), + Assert.IsFalse(contents.Any(c => c.Contains($"\"{EventNames.Purchase}\"")), "purchase event with missing required Value must be dropped, not enqueued"); } @@ -608,7 +608,7 @@ public void Track_TypedPurchase_WritesCorrectEventName() var queueDir = AudiencePaths.QueueDir(_testDir); var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); - Assert.IsTrue(contents.Any(c => c.Contains("\"purchase\""))); + Assert.IsTrue(contents.Any(c => c.Contains($"\"{EventNames.Purchase}\""))); } // ----------------------------------------------------------------- @@ -627,7 +627,7 @@ public void Identify_FullConsent_WritesIdentifyEvent() var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => - c.Contains("\"identify\"") && c.Contains("\"76561198012345\""))); + c.Contains($"\"{MessageTypes.Identify}\"") && c.Contains("\"76561198012345\""))); } [Test] @@ -641,7 +641,7 @@ public void Identify_AnonymousConsent_IsIgnored() var queueDir = AudiencePaths.QueueDir(_testDir); var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); - Assert.IsFalse(contents.Any(c => c.Contains("\"identify\"")), + Assert.IsFalse(contents.Any(c => c.Contains($"\"{MessageTypes.Identify}\"")), "identify should be discarded at Anonymous consent"); } @@ -657,7 +657,7 @@ public void Alias_FullConsent_WritesAliasEvent() var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => - c.Contains("\"alias\"") && c.Contains("\"steam123\""))); + c.Contains($"\"{MessageTypes.Alias}\"") && c.Contains("\"steam123\""))); } // ----------------------------------------------------------------- From 61e831a907a72350a033f4cbf4fba4d86b49dc97 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 13:32:16 +1000 Subject: [PATCH 05/12] test(audience-sdk): extend TestFixtures with Steam / Passport / generic user ID fixtures ImmutableAudienceTests had four inline ID fixtures around the identity / alias write tests: - "76561198012345": real-shape Steam community 64-bit ID, used as the Identify input AND as the queue-content assertion subject - "user1": minimal generic userId for the discarded-identify test - "steam123": generic Steam ID for the alias-write test, used as both Alias input and queue-content assertion subject - "user_456": generic Passport ID for the alias-write test Adds SteamId64, GenericUserSingleId, SteamId, PassportId to TestFixtures and migrates the six call sites. The SteamId64 / SteamId input-assertion pairs were the worst offenders since a typo on one side would silently pass on the other. Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Audience/Tests/Runtime/ImmutableAudienceTests.cs | 10 +++++----- src/Packages/Audience/Tests/Runtime/TestFixtures.cs | 10 ++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 0e1969547..8aef28a42 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -620,14 +620,14 @@ public void Identify_FullConsent_WritesIdentifyEvent() { ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); - ImmutableAudience.Identify("76561198012345", IdentityType.Steam); + ImmutableAudience.Identify(TestFixtures.SteamId64, IdentityType.Steam); ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => - c.Contains($"\"{MessageTypes.Identify}\"") && c.Contains("\"76561198012345\""))); + c.Contains($"\"{MessageTypes.Identify}\"") && c.Contains($"\"{TestFixtures.SteamId64}\""))); } [Test] @@ -635,7 +635,7 @@ public void Identify_AnonymousConsent_IsIgnored() { ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); - ImmutableAudience.Identify("user1", IdentityType.Steam); + ImmutableAudience.Identify(TestFixtures.GenericUserSingleId, IdentityType.Steam); ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); @@ -650,14 +650,14 @@ public void Alias_FullConsent_WritesAliasEvent() { ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); - ImmutableAudience.Alias("steam123", IdentityType.Steam, "user_456", IdentityType.Passport); + ImmutableAudience.Alias(TestFixtures.SteamId, IdentityType.Steam, TestFixtures.PassportId, IdentityType.Passport); ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => - c.Contains($"\"{MessageTypes.Alias}\"") && c.Contains("\"steam123\""))); + c.Contains($"\"{MessageTypes.Alias}\"") && c.Contains($"\"{TestFixtures.SteamId}\""))); } // ----------------------------------------------------------------- diff --git a/src/Packages/Audience/Tests/Runtime/TestFixtures.cs b/src/Packages/Audience/Tests/Runtime/TestFixtures.cs index dfd7407d3..30226fe7d 100644 --- a/src/Packages/Audience/Tests/Runtime/TestFixtures.cs +++ b/src/Packages/Audience/Tests/Runtime/TestFixtures.cs @@ -92,5 +92,15 @@ internal static class TestFixtures internal const string GenericAliasFromId = "fromId"; internal const string GenericAliasToId = "toId"; internal const string GenericAliasFromShort = "from"; + + // Real-shape 64-bit Steam community ID. Asserts the input is carried through faithfully. + internal const string SteamId64 = "76561198012345"; + + // Generic Steam / Passport ID fixtures used by alias and consent tests. + internal const string SteamId = "steam123"; + internal const string PassportId = "user_456"; + + // Generic single-user fixture for tests that just need any userId. + internal const string GenericUserSingleId = "user1"; } } From 312a52b9324ba2126b25803f06b4b75d121157b3 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 13:34:24 +1000 Subject: [PATCH 06/12] test(audience-sdk): centralise SessionTests sabotage message and DiskStoreTests inline JSON keys SessionTests had "track explode" inline four times as the InvalidOperationException message thrown by ThrowingTrack delegates across four session lifecycle scenarios. Adds a TrackExplodeMessage file-local const at the top of the fixture and migrates all four. DiskStoreTests built a Purchase-shaped JSON payload by hand-rolling the wire format with inline keys ("type", "eventName", "anonymousId", "userId", "properties", "currency", "value") and inline values ("track", "purchase", "USD", "a"). Replaces those with the SDK's existing constants (MessageFields.*, MessageTypes.Track, EventNames.Purchase, EventPropertyKeys.Currency / .Value) and the TestFixtures fixtures already centralised (UsdCurrency, TestEventNames.PlaceholderA). The "u" userId placeholder is left inline; centralising a single one-char placeholder would add more noise than it removes. Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Audience/Tests/Runtime/Core/SessionTests.cs | 11 +++++++---- .../Tests/Runtime/Transport/DiskStoreTests.cs | 9 +++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs index 8c9fdec1e..fd7a385df 100644 --- a/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs @@ -10,6 +10,9 @@ namespace Immutable.Audience.Tests [TestFixture] internal class SessionTests { + // Exception thrown by the ThrowingTrack sabotage delegate across four scenarios. + private const string TrackExplodeMessage = "track explode"; + private List<(string name, Dictionary props)> _events; [SetUp] @@ -546,7 +549,7 @@ public void OnHeartbeat_TrackCallbackThrows_DoesNotEscape() void ThrowingTrack(string name, Dictionary props) { if (name == EventNames.SessionHeartbeat) - throw new InvalidOperationException("track explode"); + throw new InvalidOperationException(TrackExplodeMessage); } using var session = new Session(ThrowingTrack); @@ -581,7 +584,7 @@ public void Start_TrackCallbackThrows_DoesNotEscape() void ThrowingTrack(string name, Dictionary props) { if (name == EventNames.SessionStart) - throw new InvalidOperationException("track explode"); + throw new InvalidOperationException(TrackExplodeMessage); } using var session = new Session(ThrowingTrack); @@ -616,7 +619,7 @@ public void End_TrackCallbackThrows_DoesNotEscape() void ThrowingTrack(string name, Dictionary props) { if (name == EventNames.SessionEnd) - throw new InvalidOperationException("track explode"); + throw new InvalidOperationException(TrackExplodeMessage); } using var session = new Session(ThrowingTrack); @@ -652,7 +655,7 @@ public void SafeTrack_LogWriterThrows_DoesNotEscape() void ThrowingTrack(string name, Dictionary props) { if (name == EventNames.SessionHeartbeat) - throw new InvalidOperationException("track explode"); + throw new InvalidOperationException(TrackExplodeMessage); } using var session = new Session(ThrowingTrack); diff --git a/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs index 416a50931..ddb522b0e 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs @@ -207,8 +207,13 @@ public void ApplyAnonymousDowngrade_PurchaseValue_RoundsTripsExactlyForRealistic TearDown(); SetUp(); - var json = "{\"type\":\"track\",\"eventName\":\"purchase\",\"anonymousId\":\"a\",\"userId\":\"u\"," - + "\"properties\":{\"currency\":\"USD\",\"value\":" + amount + "}}"; + var json = $"{{\"{MessageFields.Type}\":\"{MessageTypes.Track}\"," + + $"\"{MessageFields.EventName}\":\"{EventNames.Purchase}\"," + + $"\"{MessageFields.AnonymousId}\":\"{TestEventNames.PlaceholderA}\"," + + $"\"{MessageFields.UserId}\":\"u\"," + + $"\"{MessageFields.Properties}\":{{" + + $"\"{EventPropertyKeys.Currency}\":\"{TestFixtures.UsdCurrency}\"," + + $"\"{EventPropertyKeys.Value}\":{amount}}}}}"; _store.Write(json); _store.ApplyAnonymousDowngrade(); From 4ed8a25327ad2dad9b6245fbbb6fc26264dbd09d Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 14:25:00 +1000 Subject: [PATCH 07/12] test(audience-sdk): share mixed-case wire-format fixtures Renames TestFixtures.DistributionPlatformSteamCased and DistributionPlatformSteamUppercase to neutral SteamPascalCase and SteamUpperCase. The same "Steam" / "STEAM" string fixtures are relevant to two test families: ImmutableAudienceTests' DistributionPlatform lowercase normalisation, and IdentityTypeTests' ParseLowercaseString case-insensitive matching. The new names drop the family-specific prefix. Adds: - PassportPascalCase ("Passport") for the IdentityType mixed-case fixture batch. - SteamSuffixed ("steamX") for the steam-prefix-but-not-exact-match fixture in the Custom-fallback test. Migrates references: - ImmutableAudienceTests:1127 / 1138 (rename only) - IdentityTypeTests:55-57 (ParseLowercaseString_AcceptsMixedCase cases) - IdentityTypeTests:66 ([TestCase("steamX")]) - ImmutableAudienceTests:398 (missed inline "user1", now GenericUserSingleId from the earlier centralisation pass) Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Audience/Tests/Runtime/IdentityTypeTests.cs | 8 ++++---- .../Audience/Tests/Runtime/ImmutableAudienceTests.cs | 6 +++--- src/Packages/Audience/Tests/Runtime/TestFixtures.cs | 11 +++++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs b/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs index 4ff2ec293..2fa47f45c 100644 --- a/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs +++ b/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs @@ -46,9 +46,9 @@ public void IdentityTypeWireFormat_PinsExactStringValues() Assert.AreEqual("custom", IdentityTypeWireFormat.Custom); } - [TestCase("Steam", IdentityType.Steam)] - [TestCase("STEAM", IdentityType.Steam)] - [TestCase("Passport", IdentityType.Passport)] + [TestCase(TestFixtures.SteamPascalCase, IdentityType.Steam)] + [TestCase(TestFixtures.SteamUpperCase, IdentityType.Steam)] + [TestCase(TestFixtures.PassportPascalCase, IdentityType.Passport)] public void ParseLowercaseString_AcceptsMixedCase(string wire, IdentityType expected) { Assert.AreEqual(expected, IdentityTypeExtensions.ParseLowercaseString(wire)); @@ -57,7 +57,7 @@ public void ParseLowercaseString_AcceptsMixedCase(string wire, IdentityType expe [TestCase(null)] [TestCase("")] [TestCase(TestFixtures.UnknownProvider)] - [TestCase("steamX")] + [TestCase(TestFixtures.SteamSuffixed)] public void ParseLowercaseString_FallsBackToCustomForUnknownOrEmpty(string? wire) { // ParseLowercaseString never throws; unknown values map to Custom. diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 8aef28a42..26647d259 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -395,7 +395,7 @@ public void Identify_InvalidIdentityTypeCast_Throws() var invalid = (IdentityType)999; Assert.Throws( - () => ImmutableAudience.Identify("user1", invalid), + () => ImmutableAudience.Identify(TestFixtures.GenericUserSingleId, invalid), "invalid enum cast must throw so a broken call fails loud rather than " + "shipping an identify event that cannot be matched for deletion"); } @@ -1124,7 +1124,7 @@ public void Init_GameLaunch_IncludesDistributionPlatform() public void Init_LowercasesDistributionPlatform_WhenCallerPassesMixedCase() { var config = MakeConfig(); - config.DistributionPlatform = TestFixtures.DistributionPlatformSteamCased; + config.DistributionPlatform = TestFixtures.SteamPascalCase; ImmutableAudience.Init(config); Assert.AreEqual(DistributionPlatforms.Steam, config.DistributionPlatform, @@ -1135,7 +1135,7 @@ public void Init_LowercasesDistributionPlatform_WhenCallerPassesMixedCase() public void Init_LowercasesDistributionPlatform_WhenCallerPassesAllUpperCase() { var config = MakeConfig(); - config.DistributionPlatform = TestFixtures.DistributionPlatformSteamUppercase; + config.DistributionPlatform = TestFixtures.SteamUpperCase; ImmutableAudience.Init(config); Assert.AreEqual(DistributionPlatforms.Steam, config.DistributionPlatform); diff --git a/src/Packages/Audience/Tests/Runtime/TestFixtures.cs b/src/Packages/Audience/Tests/Runtime/TestFixtures.cs index 30226fe7d..a8a971df6 100644 --- a/src/Packages/Audience/Tests/Runtime/TestFixtures.cs +++ b/src/Packages/Audience/Tests/Runtime/TestFixtures.cs @@ -80,10 +80,13 @@ internal static class TestFixtures // File body that occupies the queue directory path so directory creation fails. internal const string DiskBlockerContent = "blocker"; - // DistributionPlatform mixed-case fixtures for Init's lowercase - // normalisation test. - internal const string DistributionPlatformSteamCased = "Steam"; - internal const string DistributionPlatformSteamUppercase = "STEAM"; + // Mixed-case "Steam" / "Passport" fixtures shared by DistributionPlatform and IdentityType tests. + internal const string SteamPascalCase = "Steam"; + internal const string SteamUpperCase = "STEAM"; + internal const string PassportPascalCase = "Passport"; + + // Steam-prefixed-but-not-exact-match fixture for the Custom-fallback test. + internal const string SteamSuffixed = "steamX"; // Unity Application.platform string for the GameLaunch.Platform test. internal const string PlatformWindows = "WindowsPlayer"; From 866c788148df23533176dfc50ac90da92f50cb29 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 14:42:57 +1000 Subject: [PATCH 08/12] test(audience-sdk): centralise publishable-key fixtures across transport, stress, and offline tests Three test files had inline publishable-key fixtures with no intra-test reason for diverging from the canonical TestDefaults.PublishableKey: - ThreadSafetyStressTests:48 used "pk_imapik-test-stress" - OfflineResilienceTests:47 used "pk_imapik-test-key" Both fall under "any test-prefix key, content does not matter". Migrated to TestDefaults.PublishableKey. HttpTransportTests:162 used "pk_imapik-prodkey" specifically to verify a non-test-prefix key resolves to ProductionBaseUrl. The test cannot use TestDefaults.PublishableKey since that one IS test-prefixed. Added a file-local ProdPublishableKey const alongside the existing response-body fixtures and migrated the one usage. The const carries a comment explaining the prefix constraint. Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Audience/Tests/Runtime/OfflineResilienceTests.cs | 2 +- .../Audience/Tests/Runtime/ThreadSafetyStressTests.cs | 2 +- .../Audience/Tests/Runtime/Transport/HttpTransportTests.cs | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs b/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs index 341835b80..974f2eb58 100644 --- a/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/OfflineResilienceTests.cs @@ -44,7 +44,7 @@ protected override Task SendAsync(HttpRequestMessage reques private AudienceConfig MakeConfig() => new AudienceConfig { - PublishableKey = "pk_imapik-test-key", + PublishableKey = TestDefaults.PublishableKey, Consent = ConsentLevel.Anonymous, PersistentDataPath = _testDir, FlushIntervalSeconds = TestDefaults.FlushIntervalSeconds, diff --git a/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs b/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs index 984a905a7..82f36b599 100644 --- a/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs @@ -45,7 +45,7 @@ protected override Task SendAsync(HttpRequestMessage reques private AudienceConfig MakeConfig(ConsentLevel consent = ConsentLevel.Full) => new AudienceConfig { - PublishableKey = "pk_imapik-test-stress", + PublishableKey = TestDefaults.PublishableKey, Consent = consent, PersistentDataPath = _testDir, FlushIntervalSeconds = TestDefaults.FlushIntervalSeconds, diff --git a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs index 2c599c723..39eaf1e1a 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs @@ -17,6 +17,9 @@ namespace Immutable.Audience.Tests [TestFixture] internal class HttpTransportTests { + // Non-test-prefix publishable key. Must not carry pk_imapik-test- (asserts production BaseUrl). + private const string ProdPublishableKey = "pk_imapik-prodkey"; + // Response body fixtures. private const string MalformedResponseBody = "not-json"; private const string EmptyJsonObjectBody = "{}"; @@ -156,7 +159,7 @@ public async Task SendBatchAsync_200_UsesCorrectUrlForProdKey() HttpRequestMessage? captured = null; var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}", onRequest: req => captured = req); - using var transport = new HttpTransport(_store, "pk_imapik-prodkey", handler: handler); + using var transport = new HttpTransport(_store, ProdPublishableKey, handler: handler); await transport.SendBatchAsync(); From 75e7d944d0651462d0984348e10feb45e77955aa Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 14:44:29 +1000 Subject: [PATCH 09/12] test(audience-sdk): centralise Retry-After HTTP header name HttpTransportTests had "Retry-After" inline three times (across the delta-seconds, http-date, and past-date 429 tests) and ConsentSyncTests had it once in CapturingHandler.SendAsync. The string is the RFC 7231 standard header name; the SDK reads it via the typed response.Headers.RetryAfter accessor, but the mocks set it by name. Adds a file-local RetryAfterHeader const at the top of each fixture (matching the existing publishable-key / response-body file-local const pattern) and migrates all four sites. Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs | 6 +++++- .../Tests/Runtime/Transport/HttpTransportTests.cs | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs b/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs index fdf37f599..c63cf7d2b 100644 --- a/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs @@ -12,6 +12,10 @@ namespace Immutable.Audience.Tests [TestFixture] internal class ConsentSyncTests { + // Standard HTTP header name (RFC 7231) the mock CapturingHandler sets + // on 429 responses to override the SDK's default backoff. + private const string RetryAfterHeader = "Retry-After"; + private string _testDir; [SetUp] @@ -223,7 +227,7 @@ protected override async Task SendAsync( var response = new HttpResponseMessage(status); if ((int)status == 429 && RetryAfterSeconds.HasValue) { - response.Headers.Add("Retry-After", RetryAfterSeconds.Value.ToString()); + response.Headers.Add(RetryAfterHeader, RetryAfterSeconds.Value.ToString()); } return response; } diff --git a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs index 39eaf1e1a..026f162a8 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs @@ -20,6 +20,9 @@ internal class HttpTransportTests // Non-test-prefix publishable key. Must not carry pk_imapik-test- (asserts production BaseUrl). private const string ProdPublishableKey = "pk_imapik-prodkey"; + // Standard HTTP header (RFC 7231) the server sets to override the SDK's 429 backoff. + private const string RetryAfterHeader = "Retry-After"; + // Response body fixtures. private const string MalformedResponseBody = "not-json"; private const string EmptyJsonObjectBody = "{}"; @@ -241,7 +244,7 @@ public async Task SendBatchAsync_429_RetryAfterDeltaSeconds_OverridesExpoBackoff var handler = new MockHandler(() => { var resp = new HttpResponseMessage((HttpStatusCode)429); - resp.Headers.Add("Retry-After", "12"); + resp.Headers.Add(RetryAfterHeader, "12"); return resp; }); using var transport = new HttpTransport(_store, TestDefaults.PublishableKey, @@ -264,7 +267,7 @@ public async Task SendBatchAsync_429_RetryAfterHttpDate_OverridesExpoBackoff() var handler = new MockHandler(() => { var resp = new HttpResponseMessage((HttpStatusCode)429); - resp.Headers.Add("Retry-After", DateTimeOffset.UtcNow.AddSeconds(20).ToString("R")); + resp.Headers.Add(RetryAfterHeader, DateTimeOffset.UtcNow.AddSeconds(20).ToString("R")); return resp; }); using var transport = new HttpTransport(_store, TestDefaults.PublishableKey, @@ -286,7 +289,7 @@ public async Task SendBatchAsync_429_PastRetryAfterDate_FallsBackToExpoBackoff() var handler = new MockHandler(() => { var resp = new HttpResponseMessage((HttpStatusCode)429); - resp.Headers.Add("Retry-After", DateTimeOffset.UtcNow.AddSeconds(-30).ToString("R")); + resp.Headers.Add(RetryAfterHeader, DateTimeOffset.UtcNow.AddSeconds(-30).ToString("R")); return resp; }); using var transport = new HttpTransport(_store, TestDefaults.PublishableKey, From d6f3276e2b3d99bd49a2521b3390b069ae566140 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 14:48:09 +1000 Subject: [PATCH 10/12] test(audience-sdk): centralise JsonTests per-scenario fixture keys and values JsonTests had inline scenario fixtures across nineteen tests: - "flag" (Bool true / false serialise pair) - "n" (Int / Long serialise) - "x" (Null serialise) - "v" (eight float / double NaN, Infinity, normal-range, large / small exponent tests; reused as the diamond test's inner value) - "items" (List serialise) - "level", "score", "perfect", "tags" (RealisticEventPayload nested properties keys) - "fast", "clean" (tags array element values) - "next" (deep-nesting MaxDepth guard) - "self" (cycle-detection self-reference) - "cycle" / "nesting exceeds" (FormatException message markers) - "k", "a", "b" (diamond shared-child scenario) Adds a file-local const block at the top of the fixture grouping the literals by scenario (Bool / Numeric / Null / V / Array, RealisticPayload, cycle / depth, diamond) and migrates every test that consumed them. Where an SDK / fixtures const already covered the same string (TestEventNames.PlaceholderA for "a", TestEventNames.LevelComplete for "level_complete"), the test now references that instead. Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Tests/Runtime/Utility/JsonTests.cs | 107 +++++++++++------- 1 file changed, 67 insertions(+), 40 deletions(-) diff --git a/src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs index fcb9d08ce..c4d012799 100644 --- a/src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs @@ -7,6 +7,33 @@ namespace Immutable.Audience.Tests [TestFixture] public class JsonTests { + // Per-scenario fixture keys/values for the serialise tests. + private const string BoolFixtureKey = "flag"; + private const string NumericFixtureKey = "n"; + private const string NullFixtureKey = "x"; + private const string VShortFixture = "v"; + private const string ArrayFixtureKey = "items"; + + // RealisticEventPayload: nested keys and arrays exercise nested-list serialisation. + private const string PropLevelKey = "level"; + private const string PropScoreKey = "score"; + private const string PropPerfectKey = "perfect"; + private const string PropTagsKey = "tags"; + private const string TagFastValue = "fast"; + private const string TagCleanValue = "clean"; + + // Cycle / depth guard fixtures. + private const string DeepNestNextKey = "next"; + private const string SelfRefKey = "self"; + private const string CycleErrorMarker = "cycle"; + private const string NestingExceedsErrorMarker = "nesting exceeds"; + + // Diamond scenario (shared child under sibling keys is NOT a cycle). + private const string DiamondInnerKey = "k"; + private const string DiamondInnerValue = "v"; + private const string DiamondLeftKey = "a"; + private const string DiamondRightKey = "b"; + [Test] public void Serialize_EmptyDict_ReturnsEmptyObject() { @@ -41,41 +68,41 @@ public void Serialize_StringWithSpecialChars_EscapesCorrectly() [Test] public void Serialize_BoolTrue_ReturnsLowercaseTrue() { - var data = new Dictionary { { "flag", true } }; + var data = new Dictionary { { BoolFixtureKey, true } }; - Assert.AreEqual("{\"flag\":true}", Json.Serialize(data)); + Assert.AreEqual($"{{\"{BoolFixtureKey}\":true}}", Json.Serialize(data)); } [Test] public void Serialize_BoolFalse_ReturnsLowercaseFalse() { - var data = new Dictionary { { "flag", false } }; + var data = new Dictionary { { BoolFixtureKey, false } }; - Assert.AreEqual("{\"flag\":false}", Json.Serialize(data)); + Assert.AreEqual($"{{\"{BoolFixtureKey}\":false}}", Json.Serialize(data)); } [Test] public void Serialize_IntValue_ReturnsIntegerLiteral() { - var data = new Dictionary { { "n", 42 } }; + var data = new Dictionary { { NumericFixtureKey, 42 } }; - Assert.AreEqual("{\"n\":42}", Json.Serialize(data)); + Assert.AreEqual($"{{\"{NumericFixtureKey}\":42}}", Json.Serialize(data)); } [Test] public void Serialize_LongValue_ReturnsIntegerLiteral() { - var data = new Dictionary { { "n", 9876543210L } }; + var data = new Dictionary { { NumericFixtureKey, 9876543210L } }; - Assert.AreEqual("{\"n\":9876543210}", Json.Serialize(data)); + Assert.AreEqual($"{{\"{NumericFixtureKey}\":9876543210}}", Json.Serialize(data)); } [Test] public void Serialize_NullValue_ReturnsJsonNull() { - var data = new Dictionary { { "x", null } }; + var data = new Dictionary { { NullFixtureKey, null } }; - Assert.AreEqual("{\"x\":null}", Json.Serialize(data)); + Assert.AreEqual($"{{\"{NullFixtureKey}\":null}}", Json.Serialize(data)); } [Test] @@ -97,47 +124,47 @@ public void Serialize_NestedDict_ReturnsNestedObject() [Test] public void Serialize_FloatNaN_SerializesAsNull() { - Assert.AreEqual("{\"v\":null}", Json.Serialize(new Dictionary { { "v", float.NaN } })); + Assert.AreEqual($"{{\"{VShortFixture}\":null}}", Json.Serialize(new Dictionary { { VShortFixture, float.NaN } })); } [Test] public void Serialize_FloatPositiveInfinity_SerializesAsNull() { - Assert.AreEqual("{\"v\":null}", Json.Serialize(new Dictionary { { "v", float.PositiveInfinity } })); + Assert.AreEqual($"{{\"{VShortFixture}\":null}}", Json.Serialize(new Dictionary { { VShortFixture, float.PositiveInfinity } })); } [Test] public void Serialize_FloatNegativeInfinity_SerializesAsNull() { - Assert.AreEqual("{\"v\":null}", Json.Serialize(new Dictionary { { "v", float.NegativeInfinity } })); + Assert.AreEqual($"{{\"{VShortFixture}\":null}}", Json.Serialize(new Dictionary { { VShortFixture, float.NegativeInfinity } })); } [Test] public void Serialize_DoubleNaN_SerializesAsNull() { - Assert.AreEqual("{\"v\":null}", Json.Serialize(new Dictionary { { "v", double.NaN } })); + Assert.AreEqual($"{{\"{VShortFixture}\":null}}", Json.Serialize(new Dictionary { { VShortFixture, double.NaN } })); } [Test] public void Serialize_DoubleInfinity_SerializesAsNull() { - Assert.AreEqual("{\"v\":null}", Json.Serialize(new Dictionary { { "v", double.PositiveInfinity } })); + Assert.AreEqual($"{{\"{VShortFixture}\":null}}", Json.Serialize(new Dictionary { { VShortFixture, double.PositiveInfinity } })); } [Test] public void Serialize_FloatValue_NormalRange() { - var data = new Dictionary { { "v", 3.14f } }; + var data = new Dictionary { { VShortFixture, 3.14f } }; var result = Json.Serialize(data); - StringAssert.Contains("\"v\":", result); - StringAssert.DoesNotContain("\"v\":\"", result); // must not be quoted + StringAssert.Contains($"\"{VShortFixture}\":", result); + StringAssert.DoesNotContain($"\"{VShortFixture}\":\"", result); } [Test] public void Serialize_FloatValue_LargeExponent_PreservesValue() { // 1e30f in scientific notation is valid JSON; must not be silently zeroed - var data = new Dictionary { { "v", 1e30f } }; + var data = new Dictionary { { VShortFixture, 1e30f } }; var result = Json.Serialize(data); var serialised = result.Substring(result.IndexOf(':') + 1, result.Length - result.IndexOf(':') - 2); Assert.AreNotEqual("0", serialised); @@ -148,7 +175,7 @@ public void Serialize_FloatValue_LargeExponent_PreservesValue() public void Serialize_FloatValue_SmallNegativeExponent_PreservesValue() { // 1e-30f: the old F6 fallback turned this into "0.000000" - var data = new Dictionary { { "v", 1e-30f } }; + var data = new Dictionary { { VShortFixture, 1e-30f } }; var result = Json.Serialize(data); var serialised = result.Substring(result.IndexOf(':') + 1, result.Length - result.IndexOf(':') - 2); Assert.AreNotEqual("0", serialised); @@ -158,7 +185,7 @@ public void Serialize_FloatValue_SmallNegativeExponent_PreservesValue() [Test] public void Serialize_DoubleValue_SmallNegativeExponent_PreservesValue() { - var data = new Dictionary { { "v", 1e-300 } }; + var data = new Dictionary { { VShortFixture, 1e-300 } }; var result = Json.Serialize(data); var serialised = result.Substring(result.IndexOf(':') + 1, result.Length - result.IndexOf(':') - 2); Assert.AreNotEqual("0", serialised); @@ -170,10 +197,10 @@ public void Serialize_ListValue_ReturnsJsonArray() { var data = new Dictionary { - { "items", new List { "a", 1, true } } + { ArrayFixtureKey, new List { TestEventNames.PlaceholderA, 1, true } } }; - Assert.AreEqual("{\"items\":[\"a\",1,true]}", Json.Serialize(data)); + Assert.AreEqual($"{{\"{ArrayFixtureKey}\":[\"{TestEventNames.PlaceholderA}\",1,true]}}", Json.Serialize(data)); } [Test] @@ -187,10 +214,10 @@ public void Serialize_RealisticEventPayload_ProducesCorrectJson() { MessageFields.UserId, null }, { MessageFields.Properties, new Dictionary { - { "level", 5 }, - { "score", 9800L }, - { "perfect", true }, - { "tags", new List { "fast", "clean" } } + { PropLevelKey, 5 }, + { PropScoreKey, 9800L }, + { PropPerfectKey, true }, + { PropTagsKey, new List { TagFastValue, TagCleanValue } } } } }; @@ -198,11 +225,11 @@ public void Serialize_RealisticEventPayload_ProducesCorrectJson() var result = Json.Serialize(data); StringAssert.Contains($"\"{MessageFields.Type}\":\"{MessageTypes.Track}\"", result); - StringAssert.Contains($"\"{MessageFields.EventName}\":\"level_complete\"", result); + StringAssert.Contains($"\"{MessageFields.EventName}\":\"{TestEventNames.LevelComplete}\"", result); StringAssert.Contains($"\"{MessageFields.UserId}\":null", result); - StringAssert.Contains("\"level\":5", result); - StringAssert.Contains("\"perfect\":true", result); - StringAssert.Contains("\"tags\":[\"fast\",\"clean\"]", result); + StringAssert.Contains($"\"{PropLevelKey}\":5", result); + StringAssert.Contains($"\"{PropPerfectKey}\":true", result); + StringAssert.Contains($"\"{PropTagsKey}\":[\"{TagFastValue}\",\"{TagCleanValue}\"]", result); } [Test] @@ -213,39 +240,39 @@ public void Serialize_NestingExceedsMaxDepth_ThrowsFormatException() for (var i = 0; i < Json.MaxDepth; i++) { var next = new Dictionary(); - current["next"] = next; + current[DeepNestNextKey] = next; current = next; } var ex = Assert.Throws(() => Json.Serialize(root)); - StringAssert.Contains("nesting exceeds", ex.Message); + StringAssert.Contains(NestingExceedsErrorMarker, ex.Message); } [Test] public void Serialize_SelfReferentialDict_ThrowsFormatException() { var root = new Dictionary(); - root["self"] = root; + root[SelfRefKey] = root; var ex = Assert.Throws(() => Json.Serialize(root)); - StringAssert.Contains("cycle", ex.Message); + StringAssert.Contains(CycleErrorMarker, ex.Message); } [Test] public void Serialize_SharedChildInSiblingKeys_IsNotTreatedAsCycle() { // Diamond: visited set tracks the current recursion stack, not all objects ever seen. - var shared = new Dictionary { ["k"] = "v" }; + var shared = new Dictionary { [DiamondInnerKey] = DiamondInnerValue }; var root = new Dictionary { - ["a"] = shared, - ["b"] = shared, + [DiamondLeftKey] = shared, + [DiamondRightKey] = shared, }; var result = Json.Serialize(root); - StringAssert.Contains("\"a\":{\"k\":\"v\"}", result); - StringAssert.Contains("\"b\":{\"k\":\"v\"}", result); + StringAssert.Contains($"\"{DiamondLeftKey}\":{{\"{DiamondInnerKey}\":\"{DiamondInnerValue}\"}}", result); + StringAssert.Contains($"\"{DiamondRightKey}\":{{\"{DiamondInnerKey}\":\"{DiamondInnerValue}\"}}", result); } } } From f85d89cef156a8a793f154c916e407450ac1a558 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 14:49:46 +1000 Subject: [PATCH 11/12] test(audience-sdk): centralise JsonReaderTests scenario fixtures and malformed inputs JsonReaderTests had inline scenario fixtures across the deserialise tests: - "small" / "big" (IntAndLong) - "t" / "f" / "n" (BoolAndNull) - "arr" / "two" (Array) - "abc" (RoundTripViaSerializer anonymousId) - "76561198012345" (RoundTripViaSerializer userId; same string also used in ImmutableAudienceTests' Steam Identify test) - "{not valid}" / "{\"a\":}" / "{\"a\":\"unterminated" (three malformed inputs in MalformedThrows) Adds a file-local const block at the top of the fixture grouping the keys, the array-element value, the anonymousId placeholder, and the three malformed inputs. Each test now interpolates the encoded JSON form from its key consts so a key rename touches one place. The "76561198012345" duplication is removed by referencing TestFixtures.SteamId64 (already centralised). Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Tests/Runtime/Utility/JsonReaderTests.cs | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs index a1e8bb796..389c94c79 100644 --- a/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs @@ -6,6 +6,24 @@ namespace Immutable.Audience.Tests [TestFixture] public class JsonReaderTests { + // Per-scenario fixture keys / values used by the deserialise tests. + private const string IntFixtureKey = "small"; + private const string LongFixtureKey = "big"; + private const string BoolTrueKey = "t"; + private const string BoolFalseKey = "f"; + private const string NullKey = "n"; + private const string ArrayKey = "arr"; + private const string StringElementValue = "two"; + + // Anonymous ID placeholder for RoundTripViaSerializer. + private const string AnonymousIdFixture = "abc"; + + // MalformedThrows test: three deliberately invalid JSON inputs that + // exercise distinct parser failure modes. + private const string MalformedNotValid = "{not valid}"; + private const string MalformedEmptyValue = "{\"a\":}"; + private const string MalformedUnterminatedString = "{\"a\":\"unterminated"; + [Test] public void EmptyObject() { @@ -30,18 +48,18 @@ public void StringWithEscapes() [Test] public void IntAndLong() { - var result = JsonReader.DeserializeObject("{\"small\":42,\"big\":12345678901234}"); - Assert.AreEqual(42, result["small"]); - Assert.AreEqual(12345678901234L, result["big"]); + var result = JsonReader.DeserializeObject($"{{\"{IntFixtureKey}\":42,\"{LongFixtureKey}\":12345678901234}}"); + Assert.AreEqual(42, result[IntFixtureKey]); + Assert.AreEqual(12345678901234L, result[LongFixtureKey]); } [Test] public void BoolAndNull() { - var result = JsonReader.DeserializeObject("{\"t\":true,\"f\":false,\"n\":null}"); - Assert.AreEqual(true, result["t"]); - Assert.AreEqual(false, result["f"]); - Assert.IsNull(result["n"]); + var result = JsonReader.DeserializeObject($"{{\"{BoolTrueKey}\":true,\"{BoolFalseKey}\":false,\"{NullKey}\":null}}"); + Assert.AreEqual(true, result[BoolTrueKey]); + Assert.AreEqual(false, result[BoolFalseKey]); + Assert.IsNull(result[NullKey]); } [Test] @@ -55,11 +73,11 @@ public void NestedObject() [Test] public void Array() { - var result = JsonReader.DeserializeObject("{\"arr\":[1,\"two\",true,null]}"); - var arr = (List)result["arr"]; + var result = JsonReader.DeserializeObject($"{{\"{ArrayKey}\":[1,\"{StringElementValue}\",true,null]}}"); + var arr = (List)result[ArrayKey]; Assert.AreEqual(4, arr.Count); Assert.AreEqual(1, arr[0]); - Assert.AreEqual("two", arr[1]); + Assert.AreEqual(StringElementValue, arr[1]); Assert.AreEqual(true, arr[2]); Assert.IsNull(arr[3]); } @@ -76,8 +94,8 @@ public void RoundTripViaSerializer() [EventPropertyKeys.Status] = ProgressionStatus.Complete.ToLowercaseString(), [EventPropertyKeys.Score] = 1500 }, - [MessageFields.AnonymousId] = "abc", - [MessageFields.UserId] = "76561198012345" + [MessageFields.AnonymousId] = AnonymousIdFixture, + [MessageFields.UserId] = TestFixtures.SteamId64 }; var serialized = Json.Serialize(original); @@ -85,8 +103,8 @@ public void RoundTripViaSerializer() Assert.AreEqual(MessageTypes.Track, parsed[MessageFields.Type]); Assert.AreEqual(EventNames.Progression, parsed[MessageFields.EventName]); - Assert.AreEqual("abc", parsed[MessageFields.AnonymousId]); - Assert.AreEqual("76561198012345", parsed[MessageFields.UserId]); + Assert.AreEqual(AnonymousIdFixture, parsed[MessageFields.AnonymousId]); + Assert.AreEqual(TestFixtures.SteamId64, parsed[MessageFields.UserId]); var props = (Dictionary)parsed[MessageFields.Properties]; Assert.AreEqual(ProgressionStatus.Complete.ToLowercaseString(), props[EventPropertyKeys.Status]); Assert.AreEqual(1500, props[EventPropertyKeys.Score]); @@ -95,9 +113,9 @@ public void RoundTripViaSerializer() [Test] public void MalformedThrows() { - Assert.Throws(() => JsonReader.DeserializeObject("{not valid}")); - Assert.Throws(() => JsonReader.DeserializeObject("{\"a\":}")); - Assert.Throws(() => JsonReader.DeserializeObject("{\"a\":\"unterminated")); + Assert.Throws(() => JsonReader.DeserializeObject(MalformedNotValid)); + Assert.Throws(() => JsonReader.DeserializeObject(MalformedEmptyValue)); + Assert.Throws(() => JsonReader.DeserializeObject(MalformedUnterminatedString)); } } } From 9909f49cfc14a784aba925e820652e37df45d158 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sat, 2 May 2026 14:50:44 +1000 Subject: [PATCH 12/12] test(audience-sdk): centralise EventQueueTests Msg helper key EventQueueTests' local Msg helper labelled its dict's event-name field with an inline "event" key. The string is distinct from the SDK's wire-format MessageFields.EventName ("eventName"); EventQueue accepts arbitrary dicts so the test could pick anything. Inline made this implicit. Adds a file-local MsgEventKey const at the top of the fixture with a comment noting the test-scaffolding role and migrates the one usage. Per the user's "everything random goes in a constant" stance. Follow-up to SDK-272 (centralisation of duplicated literals). --- .../Audience/Tests/Runtime/Transport/EventQueueTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs index 2661b4dc7..85ed79786 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs @@ -9,6 +9,9 @@ namespace Immutable.Audience.Tests [TestFixture] internal class EventQueueTests { + // Test-scaffold key used by the local Msg helper, not MessageFields.EventName. + private const string MsgEventKey = "event"; + private string _testDir; private DiskStore _store; @@ -28,7 +31,7 @@ public void TearDown() } private static Dictionary Msg(string evt) => - new Dictionary { ["event"] = evt }; + new Dictionary { [MsgEventKey] = evt }; [Test] public void Enqueue_ThenFlushSync_PersistesEventToDisk()