diff --git a/examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs b/examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs new file mode 100644 index 000000000..bb134f0e0 --- /dev/null +++ b/examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +// Grants test access without exposing these helpers on the SDK consumer surface. +[assembly: InternalsVisibleTo("Immutable.Audience.Samples.SampleApp.Tests")] diff --git a/examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs.meta b/examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs.meta new file mode 100644 index 000000000..089ebcfe1 --- /dev/null +++ b/examples/audience/Assets/SampleApp/Scripts/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 95f1c28711234c4699e747945c9ecb23 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs index 245240707..301661e8d 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs @@ -51,33 +51,33 @@ internal readonly struct EventSpec EventField.Text("platform", optional: true), }), new EventSpec("wishlist_remove", new[] { EventField.Text("gameId") }), - new EventSpec("purchase", new[] { - EventField.Text("currency"), - EventField.Number("value"), - EventField.Text("itemId", optional: true), - EventField.Text("itemName", optional: true), - EventField.Number("quantity", optional: true), - EventField.Text("transactionId", optional: true), + new EventSpec(EventNames.Purchase, new[] { + EventField.Text(EventPropertyKeys.Currency), + EventField.Number(EventPropertyKeys.Value), + EventField.Text(EventPropertyKeys.ItemId, optional: true), + EventField.Text(EventPropertyKeys.ItemName, optional: true), + EventField.Number(EventPropertyKeys.Quantity, optional: true), + EventField.Text(EventPropertyKeys.TransactionId, optional: true), }), // game_launch is deliberately absent. The Event Reference v1 defines // it as auto-tracked on Init with no public typed class; firing it // from the Send button would double-emit. - new EventSpec("progression", new[] { - EventField.Enum("status", new[] { "start", "complete", "fail" }), - EventField.Text("world", optional: true), - EventField.Text("level", optional: true), - EventField.Text("stage", optional: true), - EventField.Number("score", optional: true), - EventField.Number("durationSec", optional: true), + new EventSpec(EventNames.Progression, new[] { + EventField.Enum(EventPropertyKeys.Status, new[] { "start", "complete", "fail" }), + EventField.Text(EventPropertyKeys.World, optional: true), + EventField.Text(EventPropertyKeys.Level, optional: true), + EventField.Text(EventPropertyKeys.Stage, optional: true), + EventField.Number(EventPropertyKeys.Score, optional: true), + EventField.Number(EventPropertyKeys.DurationSec, optional: true), }), - new EventSpec("resource", new[] { - EventField.Enum("flow", new[] { "sink", "source" }), - EventField.Text("currency"), - EventField.Number("amount"), - EventField.Text("itemType", optional: true), - EventField.Text("itemId", optional: true), + new EventSpec(EventNames.Resource, new[] { + EventField.Enum(EventPropertyKeys.Flow, new[] { "sink", "source" }), + EventField.Text(EventPropertyKeys.Currency), + EventField.Number(EventPropertyKeys.Amount), + EventField.Text(EventPropertyKeys.ItemType, optional: true), + EventField.Text(EventPropertyKeys.ItemId, optional: true), }), - new EventSpec("milestone_reached", new[] { EventField.Text("name") }), + new EventSpec(EventNames.MilestoneReached, new[] { EventField.Text(EventPropertyKeys.Name) }), new EventSpec("game_page_viewed", new[] { EventField.Text("gameId"), EventField.Text("gameName", optional: true), @@ -99,7 +99,7 @@ internal readonly struct EventSpec { switch (name) { - case "progression": + case EventNames.Progression: return new Progression { Status = ParseProgressionStatus(props), @@ -109,7 +109,7 @@ internal readonly struct EventSpec Score = OptionalInt(props, "score"), DurationSec = OptionalFloat(props, "durationSec"), }; - case "resource": + case EventNames.Resource: return new Resource { Flow = ParseResourceFlow(props), @@ -118,7 +118,7 @@ internal readonly struct EventSpec ItemType = OptionalString(props, "itemType"), ItemId = OptionalString(props, "itemId"), }; - case "purchase": + case EventNames.Purchase: return new Purchase { Currency = OptionalString(props, "currency") ?? "", @@ -128,7 +128,7 @@ internal readonly struct EventSpec Quantity = OptionalInt(props, "quantity"), TransactionId = OptionalString(props, "transactionId"), }; - case "milestone_reached": + case EventNames.MilestoneReached: return new MilestoneReached { Name = OptionalString(props, "name") ?? "" }; default: return null; diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs index 96e22d0fe..1c70fbd78 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.UI.cs @@ -780,7 +780,7 @@ private VisualElement BuildLogRow(LogEntry entry) private static string FormatLogEntry(LogEntry entry, bool singleLine) { var sb = new StringBuilder() - .Append(entry.Timestamp.ToString("o", CultureInfo.InvariantCulture)) + .Append(entry.Timestamp.ToString(Constants.IsoTimestampFormat, CultureInfo.InvariantCulture)) .Append(" [").Append(entry.Source == LogSource.Sdk ? "SDK" : "APP").Append("] ") .Append(entry.Label); if (!string.IsNullOrEmpty(entry.Body)) diff --git a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs index e6fbe99a7..225994742 100644 --- a/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs +++ b/examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs @@ -132,7 +132,7 @@ private void OnSendCatalogueEvent(EventSpec spec, Dictionary RunAndLog("track()", () => var props = string.IsNullOrEmpty(f.RawProps) ? null : JsonReader.DeserializeObject(f.RawProps); ImmutableAudience.Track(f.Name, props); var echo = new Dictionary { ["event"] = f.Name }; - if (props != null) echo["properties"] = props; + if (props != null) echo[MessageFields.Properties] = props; return Json.Serialize(echo, 2); }); @@ -199,10 +199,10 @@ private void OnIdentify() => RunAndLog("identify()", () => var payload = new Dictionary { ["id"] = f.Id, - ["identityType"] = f.Type, + [MessageFields.IdentityType] = f.Type, ["accepted"] = accepted, }; - if (traits != null) payload["traits"] = traits; + if (traits != null) payload[MessageFields.Traits] = traits; return Json.Serialize(payload, 2); }); @@ -233,8 +233,8 @@ private void OnAlias() => RunAndLog("alias()", () => } return Json.Serialize(new Dictionary { - ["from"] = new Dictionary { ["id"] = f.FromId, ["identityType"] = f.FromType }, - ["to"] = new Dictionary { ["id"] = f.ToId, ["identityType"] = f.ToType }, + ["from"] = new Dictionary { ["id"] = f.FromId, [MessageFields.IdentityType] = f.FromType }, + ["to"] = new Dictionary { ["id"] = f.ToId, [MessageFields.IdentityType] = f.ToType }, ["accepted"] = accepted, }, 2); }); diff --git a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs index 43a423302..cee8ca7af 100644 --- a/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs +++ b/examples/audience/Assets/SampleApp/Tests/Runtime/SampleAppLiveFireTests.cs @@ -36,7 +36,7 @@ public void SetUp() // (and identity/queue) to disk under /imtbl_audience. // Without wiping it, a SetConsent(None) from a prior test leaks into // the next test's Init via ConsentStore.Load. - var sdkDir = System.IO.Path.Combine(Application.persistentDataPath, SampleAppUi.SdkPersistedDirName); + var sdkDir = AudiencePaths.AudienceDir(Application.persistentDataPath); if (System.IO.Directory.Exists(sdkDir)) System.IO.Directory.Delete(sdkDir, recursive: true); diff --git a/src/Packages/Audience/Runtime/AssemblyInfo.cs b/src/Packages/Audience/Runtime/AssemblyInfo.cs index d390acb91..565986f27 100644 --- a/src/Packages/Audience/Runtime/AssemblyInfo.cs +++ b/src/Packages/Audience/Runtime/AssemblyInfo.cs @@ -7,3 +7,6 @@ // JsonReader.DeserializeObject; both stay internal so their // signatures aren't frozen into the public API. [assembly: InternalsVisibleTo("Immutable.Audience.Samples.SampleApp")] + +// SampleApp tests pin against the same internal constants the runtime SDK uses. +[assembly: InternalsVisibleTo("Immutable.Audience.Samples.SampleApp.Tests")] diff --git a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs index 52fb40c33..dbcc604d7 100644 --- a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs +++ b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs @@ -9,6 +9,13 @@ internal static class AudiencePaths private const string ConsentFileName = "consent"; private const string QueueDirName = "queue"; + // Queue files are named _.json. + internal const string QueueFileExtension = ".json"; + internal const string QueueGlob = "*" + QueueFileExtension; + + // Files ending in this suffix are mid-write and must not be read. + internal const string TempFileSuffix = ".tmp"; + internal static string AudienceDir(string persistentDataPath) => Path.Combine(persistentDataPath, RootDirName); diff --git a/src/Packages/Audience/Runtime/Core/ConsentStore.cs b/src/Packages/Audience/Runtime/Core/ConsentStore.cs index 58ea20326..515abff22 100644 --- a/src/Packages/Audience/Runtime/Core/ConsentStore.cs +++ b/src/Packages/Audience/Runtime/Core/ConsentStore.cs @@ -12,7 +12,7 @@ internal static void Save(string persistentDataPath, ConsentLevel level) Directory.CreateDirectory(AudiencePaths.AudienceDir(persistentDataPath)); var filePath = AudiencePaths.ConsentFile(persistentDataPath); - var tmpPath = filePath + ".tmp"; + var tmpPath = filePath + AudiencePaths.TempFileSuffix; File.WriteAllText(tmpPath, ((int)level).ToString()); diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index f217812d5..a8fe1eebc 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -17,6 +17,12 @@ internal static class Constants internal const int MaxBatchSize = 100; internal const int StaleEventDays = 30; internal const int MaxFieldLength = 256; // Backend schema limit. + + // Timestamp format the backend wants on every event. + internal const string IsoTimestampFormat = "o"; + + // Format that lets numbers survive a JSON round-trip unchanged. + internal const string RoundTripNumberFormat = "R"; internal const int ControlPlaneRequestTimeoutSeconds = 30; internal const string LibraryName = "com.immutable.audience"; @@ -25,6 +31,10 @@ internal static class Constants internal const string ConsentSource = "UnitySDK"; internal const string PublishableKeyHeader = "x-immutable-publishable-key"; + internal const string ContentEncodingHeader = "Content-Encoding"; + + internal const string MediaTypeJson = "application/json"; + internal const string GzipEncoding = "gzip"; internal static string MessagesUrl(string? publishableKey, string? baseUrlOverride = null) => BaseUrl(publishableKey, baseUrlOverride) + MessagesPath; @@ -52,12 +62,129 @@ internal static class MessageTypes internal const string Alias = "alias"; } + // Property keys for the auto-fired game_launch event. + internal static class GameLaunchPropertyKeys + { + internal const string Platform = "platform"; + internal const string Version = "version"; + internal const string BuildGuid = "buildGuid"; + internal const string UnityVersion = "unityVersion"; + internal const string OsFamily = "osFamily"; + internal const string DeviceModel = "deviceModel"; + internal const string Gpu = "gpu"; + internal const string GpuVendor = "gpuVendor"; + internal const string Cpu = "cpu"; + internal const string CpuCores = "cpuCores"; + internal const string RamMb = "ramMb"; + internal const string ScreenDpi = "screenDpi"; + internal const string DistributionPlatform = "distributionPlatform"; + } + + // Keys merged into every event's context dictionary. + internal static class ContextKeys + { + internal const string UserAgent = "userAgent"; + internal const string Timezone = "timezone"; + internal const string Locale = "locale"; + internal const string Screen = "screen"; + } + + // JSON keys for the consent-sync PUT body. + internal static class ConsentBodyFields + { + internal const string Status = "status"; + internal const string Source = "source"; + } + + // JSON keys for the messages POST envelope and response. + internal static class ResponseFields + { + internal const string MessagesEnvelope = "messages"; + internal const string Rejected = "rejected"; + } + + // Keys inside each event's "properties" dict. + internal static class EventPropertyKeys + { + // Shared across multiple events (Session + Progression for DurationSec, + // Resource + Purchase for Currency / ItemId). + internal const string SessionId = "sessionId"; + internal const string DurationSec = "durationSec"; + internal const string Currency = "currency"; + internal const string ItemId = "itemId"; + + // Progression-specific + internal const string Status = "status"; + internal const string World = "world"; + internal const string Level = "level"; + internal const string Stage = "stage"; + internal const string Score = "score"; + + // Resource-specific + internal const string Flow = "flow"; + internal const string Amount = "amount"; + internal const string ItemType = "itemType"; + + // Purchase-specific + internal const string Value = "value"; + internal const string ItemName = "itemName"; + internal const string Quantity = "quantity"; + internal const string TransactionId = "transactionId"; + + // MilestoneReached-specific + internal const string Name = "name"; + } + + // Event names we send on Track. + internal static class EventNames + { + // Session lifecycle (auto-fired) + internal const string SessionStart = "session_start"; + internal const string SessionEnd = "session_end"; + internal const string SessionHeartbeat = "session_heartbeat"; + + // Init lifecycle (auto-fired) + internal const string GameLaunch = "game_launch"; + + // Typed events (IEvent implementations) + internal const string Progression = "progression"; + internal const string Resource = "resource"; + internal const string Purchase = "purchase"; + internal const string MilestoneReached = "milestone_reached"; + } + // Wire-format field names that cross module boundaries inside the SDK // (read by one module, written by another). internal static class MessageFields { + // Envelope keys present on every message internal const string Type = "type"; + internal const string MessageId = "messageId"; + internal const string EventTimestamp = "eventTimestamp"; + internal const string Context = "context"; + internal const string Surface = "surface"; + + // Track envelope + internal const string EventName = "eventName"; + internal const string Properties = "properties"; + + // Identity envelope (track, identify, alias) + internal const string AnonymousId = "anonymousId"; internal const string UserId = "userId"; + + // Identify envelope + internal const string IdentityType = "identityType"; + internal const string Traits = "traits"; + + // Alias envelope + internal const string FromId = "fromId"; + internal const string FromType = "fromType"; + internal const string ToId = "toId"; + internal const string ToType = "toType"; + + // Context dictionary keys + internal const string Library = "library"; + internal const string LibraryVersion = "libraryVersion"; } /// diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs index 3c2e1943a..5699602bd 100644 --- a/src/Packages/Audience/Runtime/Core/Identity.cs +++ b/src/Packages/Audience/Runtime/Core/Identity.cs @@ -92,7 +92,7 @@ internal static void ClearCache() // New install: generate a UUID and persist it atomically. // Write to a .tmp file first so a crash mid-write leaves no corrupt file. var newId = Guid.NewGuid().ToString(); - var tmpPath = filePath + ".tmp"; + var tmpPath = filePath + AudiencePaths.TempFileSuffix; File.WriteAllText(tmpPath, newId); try diff --git a/src/Packages/Audience/Runtime/Core/Session.cs b/src/Packages/Audience/Runtime/Core/Session.cs index deb6e17aa..603cd25b0 100644 --- a/src/Packages/Audience/Runtime/Core/Session.cs +++ b/src/Packages/Audience/Runtime/Core/Session.cs @@ -28,6 +28,12 @@ internal sealed class Session : IDisposable // 30s: alt-tab beyond this rolls the session on Resume. internal const int PauseTimeoutMs = 30_000; + // How long we wait for an in-flight heartbeat callback to finish during teardown. + internal const int HeartbeatDrainTimeoutMs = 1_000; + + // How long we wait for the previous heartbeat to clear when Start is called twice. + internal const int StartDrainTimeoutMs = 500; + private readonly TrackDelegate _track; private readonly Func _getUtcNow; private readonly int _heartbeatIntervalMs; @@ -77,8 +83,8 @@ internal void Start() } } - // 500ms budget. Double-Start is a misuse path. - TimerDisposal.DisposeAndWait(oldTimer, TimeSpan.FromMilliseconds(500)); + // Double-Start is a misuse path; StartDrainTimeoutMs caps the wait. + TimerDisposal.DisposeAndWait(oldTimer, TimeSpan.FromMilliseconds(StartDrainTimeoutMs)); // Phase 2: populate new state. Re-check _disposed (may have flipped during drain). string sessionId; @@ -95,9 +101,9 @@ internal void Start() _heartbeatTimer = new Timer(_ => OnHeartbeat(), null, _heartbeatIntervalMs, _heartbeatIntervalMs); } - SafeTrack("session_start", new Dictionary + SafeTrack(EventNames.SessionStart, new Dictionary { - ["sessionId"] = sessionId + [EventPropertyKeys.SessionId] = sessionId }); } @@ -171,10 +177,10 @@ internal void End() // duration is engagement-aware (excludes pause). Web SDK emits // wall-clock; dashboards should not assume parity. - SafeTrack("session_end", new Dictionary + SafeTrack(EventNames.SessionEnd, new Dictionary { - ["sessionId"] = sessionId, - ["durationSec"] = duration + [EventPropertyKeys.SessionId] = sessionId, + [EventPropertyKeys.DurationSec] = duration }); } @@ -196,10 +202,10 @@ internal void EmitEndAndSeal() ResetSessionStateLocked(); } - SafeTrack("session_end", new Dictionary + SafeTrack(EventNames.SessionEnd, new Dictionary { - ["sessionId"] = sessionId, - ["durationSec"] = duration + [EventPropertyKeys.SessionId] = sessionId, + [EventPropertyKeys.DurationSec] = duration }); } @@ -240,11 +246,11 @@ internal void OnHeartbeat() // Build outside _lock so track doesn't re-enter. var properties = new Dictionary { - ["sessionId"] = sessionId, - ["durationSec"] = duration + [EventPropertyKeys.SessionId] = sessionId, + [EventPropertyKeys.DurationSec] = duration }; - SafeTrack("session_heartbeat", properties); + SafeTrack(EventNames.SessionHeartbeat, properties); } // Stops exceptions from the track callback from reaching upstream. @@ -264,7 +270,8 @@ private void SafeTrack(string eventName, Dictionary properties) } // Stops the timer and waits for the in-flight callback. Runs outside - // _lock (OnHeartbeat re-enters). 1s budget (quits must not hang). Warns on timeout. + // _lock (OnHeartbeat re-enters). HeartbeatDrainTimeoutMs caps the + // wait so quits don't hang. Warns on timeout. private void DrainHeartbeatTimer() { Timer? timer; @@ -275,7 +282,7 @@ private void DrainHeartbeatTimer() } if (timer == null) return; - if (!TimerDisposal.DisposeAndWait(timer, TimeSpan.FromSeconds(1))) + if (!TimerDisposal.DisposeAndWait(timer, TimeSpan.FromMilliseconds(HeartbeatDrainTimeoutMs))) { Log.Warn(AudienceLogs.SessionHeartbeatTimeout); } diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index 43f815248..fd0488ecb 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -15,10 +15,10 @@ internal static Dictionary Track( Dictionary? properties = null) { var msg = BuildBase(MessageTypes.Track, packageVersion); - msg["eventName"] = Truncate(eventName, Constants.MaxFieldLength); + msg[MessageFields.EventName] = Truncate(eventName, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(anonymousId)) - msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength); + msg[MessageFields.AnonymousId] = Truncate(anonymousId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(userId)) msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength); @@ -26,7 +26,7 @@ internal static Dictionary Track( if (properties != null && properties.Count > 0) { TruncateStringValues(properties); - msg["properties"] = properties; + msg[MessageFields.Properties] = properties; } return msg; @@ -42,17 +42,17 @@ internal static Dictionary Identify( var msg = BuildBase(MessageTypes.Identify, packageVersion); if (!string.IsNullOrEmpty(anonymousId)) - msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength); + msg[MessageFields.AnonymousId] = Truncate(anonymousId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(userId)) msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength); - msg["identityType"] = Truncate(identityType, Constants.MaxFieldLength); + msg[MessageFields.IdentityType] = Truncate(identityType, Constants.MaxFieldLength); if (traits != null && traits.Count > 0) { TruncateStringValues(traits); - msg["traits"] = traits; + msg[MessageFields.Traits] = traits; } return msg; @@ -66,10 +66,10 @@ internal static Dictionary Alias( string packageVersion) { var msg = BuildBase(MessageTypes.Alias, packageVersion); - msg["fromId"] = Truncate(fromId, Constants.MaxFieldLength); - msg["fromType"] = Truncate(fromType, Constants.MaxFieldLength); - msg["toId"] = Truncate(toId, Constants.MaxFieldLength); - msg["toType"] = Truncate(toType, Constants.MaxFieldLength); + msg[MessageFields.FromId] = Truncate(fromId, Constants.MaxFieldLength); + msg[MessageFields.FromType] = Truncate(fromType, Constants.MaxFieldLength); + msg[MessageFields.ToId] = Truncate(toId, Constants.MaxFieldLength); + msg[MessageFields.ToType] = Truncate(toType, Constants.MaxFieldLength); return msg; } @@ -78,14 +78,14 @@ private static Dictionary BuildBase(string type, string packageV return new Dictionary { [MessageFields.Type] = type, - ["messageId"] = Guid.NewGuid().ToString(), - ["eventTimestamp"] = DateTime.UtcNow.ToString("o"), - ["context"] = new Dictionary + [MessageFields.MessageId] = Guid.NewGuid().ToString(), + [MessageFields.EventTimestamp] = DateTime.UtcNow.ToString(Constants.IsoTimestampFormat), + [MessageFields.Context] = new Dictionary { - ["library"] = Constants.LibraryName, - ["libraryVersion"] = Truncate(packageVersion, Constants.MaxFieldLength) + [MessageFields.Library] = Constants.LibraryName, + [MessageFields.LibraryVersion] = Truncate(packageVersion, Constants.MaxFieldLength) }, - ["surface"] = Constants.Surface + [MessageFields.Surface] = Constants.Surface }; } diff --git a/src/Packages/Audience/Runtime/Events/TypedEvents.cs b/src/Packages/Audience/Runtime/Events/TypedEvents.cs index ccf549137..a946d7400 100644 --- a/src/Packages/Audience/Runtime/Events/TypedEvents.cs +++ b/src/Packages/Audience/Runtime/Events/TypedEvents.cs @@ -78,7 +78,7 @@ public class Progression : IEvent public float? DurationSec { get; set; } /// - public string EventName => "progression"; + public string EventName => EventNames.Progression; /// public Dictionary ToProperties() @@ -88,14 +88,14 @@ public Dictionary ToProperties() var props = new Dictionary { - ["status"] = Status.Value.ToLowercaseString() + [EventPropertyKeys.Status] = Status.Value.ToLowercaseString() }; - if (World != null) props["world"] = World; - if (Level != null) props["level"] = Level; - if (Stage != null) props["stage"] = Stage; - if (Score.HasValue) props["score"] = Score.Value; - if (DurationSec.HasValue) props["durationSec"] = DurationSec.Value; + if (World != null) props[EventPropertyKeys.World] = World; + if (Level != null) props[EventPropertyKeys.Level] = Level; + if (Stage != null) props[EventPropertyKeys.Stage] = Stage; + if (Score.HasValue) props[EventPropertyKeys.Score] = Score.Value; + if (DurationSec.HasValue) props[EventPropertyKeys.DurationSec] = DurationSec.Value; return props; } @@ -163,7 +163,7 @@ public class Resource : IEvent public string? ItemId { get; set; } /// - public string EventName => "resource"; + public string EventName => EventNames.Resource; /// public Dictionary ToProperties() @@ -177,13 +177,13 @@ public Dictionary ToProperties() var props = new Dictionary { - ["flow"] = Flow.Value.ToLowercaseString(), - ["currency"] = Currency, - ["amount"] = Amount.Value + [EventPropertyKeys.Flow] = Flow.Value.ToLowercaseString(), + [EventPropertyKeys.Currency] = Currency, + [EventPropertyKeys.Amount] = Amount.Value }; - if (ItemType != null) props["itemType"] = ItemType; - if (ItemId != null) props["itemId"] = ItemId; + if (ItemType != null) props[EventPropertyKeys.ItemType] = ItemType; + if (ItemId != null) props[EventPropertyKeys.ItemId] = ItemId; return props; } @@ -227,7 +227,7 @@ public class Purchase : IEvent public string? TransactionId { get; set; } /// - public string EventName => "purchase"; + public string EventName => EventNames.Purchase; // Hand-rolled to avoid pulling System.Text.RegularExpressions into the IL2CPP build. private static bool IsIso4217(string s) @@ -252,14 +252,14 @@ public Dictionary ToProperties() var props = new Dictionary { - ["currency"] = Currency, - ["value"] = Value.Value + [EventPropertyKeys.Currency] = Currency, + [EventPropertyKeys.Value] = Value.Value }; - if (ItemId != null) props["itemId"] = ItemId; - if (ItemName != null) props["itemName"] = ItemName; - if (Quantity.HasValue) props["quantity"] = Quantity.Value; - if (TransactionId != null) props["transactionId"] = TransactionId; + if (ItemId != null) props[EventPropertyKeys.ItemId] = ItemId; + if (ItemName != null) props[EventPropertyKeys.ItemName] = ItemName; + if (Quantity.HasValue) props[EventPropertyKeys.Quantity] = Quantity.Value; + if (TransactionId != null) props[EventPropertyKeys.TransactionId] = TransactionId; return props; } @@ -278,7 +278,7 @@ public class MilestoneReached : IEvent public string? Name { get; set; } /// - public string EventName => "milestone_reached"; + public string EventName => EventNames.MilestoneReached; /// public Dictionary ToProperties() @@ -288,7 +288,7 @@ public Dictionary ToProperties() return new Dictionary { - ["name"] = Name + [EventPropertyKeys.Name] = Name }; } } diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index f1f28be7d..c9fc1a542 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -26,6 +27,12 @@ public static class ImmutableAudience // teardown (Session.Dispose, timer drain, queue shutdown, transport // flush, disposes). This keeps the hold time to nanoseconds so a caller // arriving on a different thread is not stranded behind those budgets. + // How many times we retry the consent-sync PUT after a 429. + internal const int ConsentSyncMaxAttempts = 4; + + // How long we wait before the first consent-sync retry. Doubles each time. + internal const int ConsentSyncBaseRetryMs = 1_000; + private static AudienceConfig? _config; private static DiskStore? _store; private static EventQueue? _queue; @@ -434,7 +441,7 @@ public static Task DeleteData(string? userId = null) string query; if (!string.IsNullOrEmpty(userId)) { - query = "userId=" + Uri.EscapeDataString(userId); + query = $"{MessageFields.UserId}=" + Uri.EscapeDataString(userId); } else { @@ -442,7 +449,7 @@ public static Task DeleteData(string? userId = null) var anonymousId = Identity.Get(config.PersistentDataPath!); if (string.IsNullOrEmpty(anonymousId)) return Task.CompletedTask; - query = "anonymousId=" + Uri.EscapeDataString(anonymousId); + query = $"{MessageFields.AnonymousId}=" + Uri.EscapeDataString(anonymousId); } var url = Constants.DataUrl(config.PublishableKey, config.BaseUrl) + "?" + query; @@ -616,17 +623,17 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev var body = Json.Serialize(new Dictionary { - ["status"] = level.ToLowercaseString(), - ["source"] = Constants.ConsentSource, + [ConsentBodyFields.Status] = level.ToLowercaseString(), + [ConsentBodyFields.Source] = Constants.ConsentSource, // Explicit null lets the backend distinguish "unknown" from a missing field. - ["anonymousId"] = anonymousId!, + [MessageFields.AnonymousId] = anonymousId!, }); Task.Run(async () => { - // 429 retried up to 4 attempts (1s/2s/4s or Retry-After). - // Other non-2xx fail fast. - const int maxAttempts = 4; + // 429 retried up to ConsentSyncMaxAttempts attempts (1s/2s/4s + // or Retry-After). Other non-2xx fail fast. + const int maxAttempts = ConsentSyncMaxAttempts; var attempt = 0; try { @@ -635,15 +642,15 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev attempt++; using var request = new HttpRequestMessage(HttpMethod.Put, url); request.Headers.Add(Constants.PublishableKeyHeader, publishableKey); - request.Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"); + request.Content = new StringContent(body, System.Text.Encoding.UTF8, Constants.MediaTypeJson); using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) return; - if ((int)response.StatusCode == 429 && attempt < maxAttempts) + if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < maxAttempts) { var delay = HttpRetry.ParseRetryAfter(response) - ?? TimeSpan.FromMilliseconds(1_000 * (1 << (attempt - 1))); + ?? TimeSpan.FromMilliseconds(ConsentSyncBaseRetryMs * (1 << (attempt - 1))); await Task.Delay(delay, cancellationToken).ConfigureAwait(false); continue; } @@ -918,10 +925,10 @@ private static void MergeUnityContext(Dictionary? msg) } if (extra == null) return; - if (!(msg.TryGetValue("context", out var ctxObj) && ctxObj is Dictionary ctx)) + if (!(msg.TryGetValue(MessageFields.Context, out var ctxObj) && ctxObj is Dictionary ctx)) { ctx = new Dictionary(); - msg["context"] = ctx; + msg[MessageFields.Context] = ctx; } foreach (var kv in extra) @@ -1009,11 +1016,11 @@ private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAt // Config-supplied distributionPlatform overrides the provider value. if (config.DistributionPlatform != null) - properties["distributionPlatform"] = config.DistributionPlatform; + properties[GameLaunchPropertyKeys.DistributionPlatform] = config.DistributionPlatform; // No sessionId on game_launch per Event Reference. Pipeline correlates // via eventTimestamp with the session_start that fires just before. - Track("game_launch", properties.Count > 0 ? properties : null); + Track(EventNames.GameLaunch, properties.Count > 0 ? properties : null); } } } diff --git a/src/Packages/Audience/Runtime/Transport/DiskStore.cs b/src/Packages/Audience/Runtime/Transport/DiskStore.cs index b407a7f37..b0f794a04 100644 --- a/src/Packages/Audience/Runtime/Transport/DiskStore.cs +++ b/src/Packages/Audience/Runtime/Transport/DiskStore.cs @@ -23,17 +23,17 @@ internal sealed class DiskStore internal DiskStore(string persistentDataPath) { - _queueDir = Path.Combine(persistentDataPath, "imtbl_audience", "queue"); + _queueDir = AudiencePaths.QueueDir(persistentDataPath); Directory.CreateDirectory(_queueDir); - _cachedCount = Directory.GetFiles(_queueDir, "*.json").Length; + _cachedCount = Directory.GetFiles(_queueDir, AudiencePaths.QueueGlob).Length; } // Atomically writes json as a new event file. internal void Write(string json) { - var fileName = $"{DateTime.UtcNow.Ticks}_{Guid.NewGuid():N}.json"; + var fileName = $"{DateTime.UtcNow.Ticks}_{Guid.NewGuid():N}{AudiencePaths.QueueFileExtension}"; var finalPath = Path.Combine(_queueDir, fileName); - var tmpPath = finalPath + ".tmp"; + var tmpPath = finalPath + AudiencePaths.TempFileSuffix; File.WriteAllText(tmpPath, json); @@ -66,7 +66,7 @@ internal IReadOnlyList ReadBatch(int maxSize) var result = new List(); // Sort by filename (ticks prefix) → oldest first - var files = Directory.GetFiles(_queueDir, "*.json") + var files = Directory.GetFiles(_queueDir, AudiencePaths.QueueGlob) .OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal); foreach (var path in files) @@ -116,7 +116,7 @@ private static bool TryDelete(string path) internal void DeleteAll() { string[] paths; - try { paths = Directory.GetFiles(_queueDir, "*.json"); } + try { paths = Directory.GetFiles(_queueDir, AudiencePaths.QueueGlob); } catch (DirectoryNotFoundException) { return; } foreach (var path in paths) @@ -128,7 +128,7 @@ internal void DeleteAll() internal void ApplyAnonymousDowngrade() { string[] paths; - try { paths = Directory.GetFiles(_queueDir, "*.json"); } + try { paths = Directory.GetFiles(_queueDir, AudiencePaths.QueueGlob); } catch (DirectoryNotFoundException) { return; } foreach (var path in paths) @@ -179,7 +179,7 @@ private void RewriteTrackWithoutUserId(string path, Dictionary m try { var rewritten = Json.Serialize(msg); - var tmp = path + ".tmp"; + var tmp = path + AudiencePaths.TempFileSuffix; File.WriteAllText(tmp, rewritten); try { File.Move(tmp, path); } catch (IOException) diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 6ac6e5357..2e14da13d 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -15,6 +15,17 @@ namespace Immutable.Audience // Sends queued events from DiskStore to the Audience backend. internal sealed class HttpTransport : IDisposable { + // How long we wait for one POST before giving up. + // Without this, one stuck request can block everything else. + internal const int RequestTimeoutSeconds = 30; + + // How long we wait before retrying after a failed POST. Doubles each time. + internal const int Backoff1stMs = 5_000; + internal const int Backoff2ndMs = 10_000; + internal const int Backoff3rdMs = 20_000; + internal const int Backoff4thMs = 40_000; + internal const int BackoffCapMs = 60_000; + private readonly DiskStore _store; private readonly string _url; private readonly string _publishableKey; @@ -48,7 +59,7 @@ internal HttpTransport( _client = handler != null ? new HttpClient(handler, disposeHandler: false) : new HttpClient(); - _client.Timeout = TimeSpan.FromSeconds(30); + _client.Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds); _getUtcNow = getUtcNow ?? (() => DateTime.UtcNow); } @@ -89,10 +100,10 @@ internal async Task SendBatchAsync(CancellationToken ct = default) #if IMMUTABLE_AUDIENCE_GZIP var compressed = Gzip.Compress(payload); request.Content = new ByteArrayContent(compressed); - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - request.Content.Headers.Add("Content-Encoding", "gzip"); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(Constants.MediaTypeJson); + request.Content.Headers.Add(Constants.ContentEncodingHeader, Constants.GzipEncoding); #else - request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + request.Content = new StringContent(payload, Encoding.UTF8, Constants.MediaTypeJson); #endif using var response = await _client.SendAsync(request, ct).ConfigureAwait(false); @@ -114,7 +125,7 @@ internal async Task SendBatchAsync(CancellationToken ct = default) $"Batch partially rejected: {rejected} of {batch.Count} events dropped"); } } - else if (statusCode == 429) + else if (statusCode == (int)HttpStatusCode.TooManyRequests) { // 429 is retryable (RFC 6585). Keep the batch, honor Retry-After // if present else use the existing 5xx backoff schedule. No @@ -182,11 +193,11 @@ internal int BackoffMs private int BackoffMsLocked() => _consecutiveFailures switch { <= 0 => 0, - 1 => 5_000, - 2 => 10_000, - 3 => 20_000, - 4 => 40_000, - _ => 60_000, + 1 => Backoff1stMs, + 2 => Backoff2ndMs, + 3 => Backoff3rdMs, + 4 => Backoff4thMs, + _ => BackoffCapMs, }; // Earliest UTC time at which the next attempt may run. @@ -244,7 +255,7 @@ private void ResetBackoff() // schema (#/components/schemas/MessagesRequest property "messages"). private static string? BuildPayload(IReadOnlyList paths) { - var sb = new StringBuilder("{\"messages\":["); + var sb = new StringBuilder($"{{\"{ResponseFields.MessagesEnvelope}\":["); var count = 0; for (var i = 0; i < paths.Count; i++) @@ -296,7 +307,7 @@ private static async Task ParseRejectedCount(HttpResponseMessage response, try { var parsed = JsonReader.DeserializeObject(body); - if (!parsed.TryGetValue("rejected", out var raw)) return 0; + if (!parsed.TryGetValue(ResponseFields.Rejected, out var raw)) return 0; return raw switch { int i => i, diff --git a/src/Packages/Audience/Runtime/Unity/DeviceCollector.cs b/src/Packages/Audience/Runtime/Unity/DeviceCollector.cs index 62be7dfa8..18588a964 100644 --- a/src/Packages/Audience/Runtime/Unity/DeviceCollector.cs +++ b/src/Packages/Audience/Runtime/Unity/DeviceCollector.cs @@ -14,17 +14,17 @@ internal static Dictionary CollectContext() // 256-char cap mirrors Web SDK's identifier truncation. var ctx = new Dictionary { - ["userAgent"] = Truncate(SystemInfo.operatingSystem, 256), + [ContextKeys.UserAgent] = Truncate(SystemInfo.operatingSystem, Constants.MaxFieldLength), }; var timezone = SafeTimezone(); - if (timezone != null) ctx["timezone"] = Truncate(timezone, 256); + if (timezone != null) ctx[ContextKeys.Timezone] = Truncate(timezone, Constants.MaxFieldLength); var locale = LocaleString(); - if (locale != null) ctx["locale"] = Truncate(locale, 256); + if (locale != null) ctx[ContextKeys.Locale] = Truncate(locale, Constants.MaxFieldLength); var screen = TryResolveScreenString(); - if (screen != null) ctx["screen"] = Truncate(screen, 256); + if (screen != null) ctx[ContextKeys.Screen] = Truncate(screen, Constants.MaxFieldLength); return ctx; } @@ -49,22 +49,22 @@ internal static Dictionary CollectGameLaunchProperties() { var props = new Dictionary { - ["platform"] = Application.platform.ToString(), - ["version"] = Truncate(Application.version, 256), - ["buildGuid"] = Truncate(Application.buildGUID, 256), - ["unityVersion"] = Truncate(Application.unityVersion, 256), - ["osFamily"] = SystemInfo.operatingSystemFamily.ToString(), - ["deviceModel"] = Truncate(SystemInfo.deviceModel, 256), - ["gpu"] = Truncate(SystemInfo.graphicsDeviceName, 256), - ["gpuVendor"] = Truncate(SystemInfo.graphicsDeviceVendor, 256), - ["cpu"] = Truncate(SystemInfo.processorType, 256), - ["cpuCores"] = SystemInfo.processorCount, - ["ramMb"] = SystemInfo.systemMemorySize, + [GameLaunchPropertyKeys.Platform] = Application.platform.ToString(), + [GameLaunchPropertyKeys.Version] = Truncate(Application.version, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.BuildGuid] = Truncate(Application.buildGUID, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.UnityVersion] = Truncate(Application.unityVersion, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.OsFamily] = SystemInfo.operatingSystemFamily.ToString(), + [GameLaunchPropertyKeys.DeviceModel] = Truncate(SystemInfo.deviceModel, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.Gpu] = Truncate(SystemInfo.graphicsDeviceName, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.GpuVendor] = Truncate(SystemInfo.graphicsDeviceVendor, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.Cpu] = Truncate(SystemInfo.processorType, Constants.MaxFieldLength), + [GameLaunchPropertyKeys.CpuCores] = SystemInfo.processorCount, + [GameLaunchPropertyKeys.RamMb] = SystemInfo.systemMemorySize, }; // Screen.dpi can be 0 on some Linux WMs. var dpi = (int)Screen.dpi; - if (dpi > 0) props["screenDpi"] = dpi; + if (dpi > 0) props[GameLaunchPropertyKeys.ScreenDpi] = dpi; return props; } diff --git a/src/Packages/Audience/Runtime/Utility/Json.cs b/src/Packages/Audience/Runtime/Utility/Json.cs index 724061431..6c67d9168 100644 --- a/src/Packages/Audience/Runtime/Utility/Json.cs +++ b/src/Packages/Audience/Runtime/Utility/Json.cs @@ -60,14 +60,14 @@ private static void WriteValue(StringBuilder sb, object? value, int indent, int if (float.IsNaN(f) || float.IsInfinity(f)) sb.Append("null"); else - sb.Append(f.ToString("R", CultureInfo.InvariantCulture)); + sb.Append(f.ToString(Constants.RoundTripNumberFormat, CultureInfo.InvariantCulture)); } else if (value is double d) { if (double.IsNaN(d) || double.IsInfinity(d)) sb.Append("null"); else - sb.Append(d.ToString("R", CultureInfo.InvariantCulture)); + sb.Append(d.ToString(Constants.RoundTripNumberFormat, CultureInfo.InvariantCulture)); } else if (value is decimal dec) { diff --git a/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs b/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs index f9d31de2c..d684622df 100644 --- a/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ConsentSyncTests.cs @@ -43,10 +43,10 @@ public void SetConsent_FiresPut_WithExpectedBodyShape() var body = JsonReader.DeserializeObject(put.Body); Assert.AreEqual(Constants.ConsentUrl("pk_imapik-test-key1"), put.Url); - Assert.AreEqual("full", body["status"]); - Assert.AreEqual(Constants.ConsentSource, body["source"]); - Assert.IsTrue(body.ContainsKey("anonymousId")); - Assert.IsNotNull(body["anonymousId"], "upgrade PUT must carry the current anonymousId"); + Assert.AreEqual(ConsentLevel.Full.ToLowercaseString(), body[ConsentBodyFields.Status]); + Assert.AreEqual(Constants.ConsentSource, body[ConsentBodyFields.Source]); + Assert.IsTrue(body.ContainsKey(MessageFields.AnonymousId)); + Assert.IsNotNull(body[MessageFields.AnonymousId], "upgrade PUT must carry the current anonymousId"); } [Test] @@ -66,8 +66,8 @@ public void SetConsent_None_PutCarriesOldAnonymousId_AfterReset() var put = WaitForPut(handler); var body = JsonReader.DeserializeObject(put.Body); - Assert.AreEqual("none", body["status"]); - Assert.AreEqual(seeded, body["anonymousId"], + Assert.AreEqual(ConsentLevel.None.ToLowercaseString(), body[ConsentBodyFields.Status]); + Assert.AreEqual(seeded, body[MessageFields.AnonymousId], "revocation PUT must carry the id that was revoked, not null"); Assert.IsFalse(File.Exists(AudiencePaths.IdentityFile(_testDir)), "precondition: Identity.Reset ran"); diff --git a/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs index 926fcbc09..ae807ef44 100644 --- a/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Core/SessionTests.cs @@ -712,7 +712,7 @@ private string[] ReadQueueFiles() { var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); if (!Directory.Exists(queueDir)) return Array.Empty(); - return Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToArray(); + return Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Select(File.ReadAllText).ToArray(); } [Test] @@ -811,7 +811,7 @@ public void Reset_StartsNewSession_DoesNotEmitSessionEnd() // only see post-Reset events. ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); - foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + foreach (var f in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) File.Delete(f); var firstAnonymousId = Identity.Get(_testDir); Assert.IsNotNull(firstAnonymousId, "first session should have minted an anonymousId"); diff --git a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs index abe12f166..75b597428 100644 --- a/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs @@ -15,12 +15,12 @@ public void Track_RequiredFieldsPresent() { var result = MessageBuilder.Track("level_complete", "anon-1", null, PackageVersion); - Assert.AreEqual("track", result["type"]); - Assert.IsTrue(result.ContainsKey("messageId")); - Assert.IsTrue(result.ContainsKey("eventTimestamp")); - Assert.IsTrue(result.ContainsKey("context")); - Assert.IsTrue(result.ContainsKey("surface")); - Assert.AreEqual("level_complete", result["eventName"]); + Assert.AreEqual("track", result[MessageFields.Type]); + Assert.IsTrue(result.ContainsKey(MessageFields.MessageId)); + Assert.IsTrue(result.ContainsKey(MessageFields.EventTimestamp)); + Assert.IsTrue(result.ContainsKey(MessageFields.Context)); + Assert.IsTrue(result.ContainsKey(MessageFields.Surface)); + Assert.AreEqual("level_complete", result[MessageFields.EventName]); } [Test] @@ -30,7 +30,7 @@ public void Track_EventNameLongerThan256Chars_TruncatedTo256() var result = MessageBuilder.Track(longName, null, null, PackageVersion); - Assert.AreEqual(256, ((string)result["eventName"]).Length); + Assert.AreEqual(256, ((string)result[MessageFields.EventName]).Length); } [Test] @@ -38,7 +38,7 @@ public void Track_NullUserId_NotPresentInDict() { var result = MessageBuilder.Track("evt", "anon-1", null, PackageVersion); - Assert.IsFalse(result.ContainsKey("userId")); + Assert.IsFalse(result.ContainsKey(MessageFields.UserId)); } [Test] @@ -46,8 +46,8 @@ public void Track_NonNullUserId_PresentInDict() { var result = MessageBuilder.Track("evt", "anon-1", "user-99", PackageVersion); - Assert.IsTrue(result.ContainsKey("userId")); - Assert.AreEqual("user-99", result["userId"]); + Assert.IsTrue(result.ContainsKey(MessageFields.UserId)); + Assert.AreEqual("user-99", result[MessageFields.UserId]); } [Test] @@ -55,10 +55,10 @@ public void Identify_TypeAndIdentityFieldsPresent() { var result = MessageBuilder.Identify("anon-42", "user-42", "steam", PackageVersion); - Assert.AreEqual("identify", result["type"]); - Assert.AreEqual("anon-42", result["anonymousId"]); - Assert.AreEqual("user-42", result["userId"]); - Assert.AreEqual("steam", result["identityType"]); + Assert.AreEqual("identify", result[MessageFields.Type]); + Assert.AreEqual("anon-42", result[MessageFields.AnonymousId]); + Assert.AreEqual("user-42", result[MessageFields.UserId]); + Assert.AreEqual("steam", result[MessageFields.IdentityType]); } [Test] @@ -66,11 +66,11 @@ public void Alias_AllFourFieldsPresent() { var result = MessageBuilder.Alias("from-id", "email", "to-id", "steam", PackageVersion); - Assert.AreEqual("alias", result["type"]); - Assert.AreEqual("from-id", result["fromId"]); - Assert.AreEqual("email", result["fromType"]); - Assert.AreEqual("to-id", result["toId"]); - Assert.AreEqual("steam", result["toType"]); + Assert.AreEqual("alias", result[MessageFields.Type]); + Assert.AreEqual("from-id", result[MessageFields.FromId]); + Assert.AreEqual("email", result[MessageFields.FromType]); + Assert.AreEqual("to-id", result[MessageFields.ToId]); + Assert.AreEqual("steam", result[MessageFields.ToType]); } [Test] @@ -82,9 +82,9 @@ public void AllMessages_ContextContainsLibraryAndLibraryVersion() foreach (var msg in new[] { track, identify, alias }) { - var ctx = (Dictionary)msg["context"]; - Assert.AreEqual(Constants.LibraryName, ctx["library"]); - Assert.AreEqual(PackageVersion, ctx["libraryVersion"]); + var ctx = (Dictionary)msg[MessageFields.Context]; + Assert.AreEqual(Constants.LibraryName, ctx[MessageFields.Library]); + Assert.AreEqual(PackageVersion, ctx[MessageFields.LibraryVersion]); } } @@ -95,9 +95,9 @@ public void AllMessages_SurfaceIsUnity() var identify = MessageBuilder.Identify(null, "u1", "steam", PackageVersion); var alias = MessageBuilder.Alias("f", "t1", "t", "t2", PackageVersion); - Assert.AreEqual("unity", track["surface"]); - Assert.AreEqual("unity", identify["surface"]); - Assert.AreEqual("unity", alias["surface"]); + Assert.AreEqual("unity", track[MessageFields.Surface]); + Assert.AreEqual("unity", identify[MessageFields.Surface]); + Assert.AreEqual("unity", alias[MessageFields.Surface]); } [Test] @@ -105,7 +105,7 @@ public void AllMessages_MessageId_ParsesAsGuid() { foreach (var msg in EveryMessageType()) { - var id = (string)msg["messageId"]; + var id = (string)msg[MessageFields.MessageId]; Assert.IsTrue(Guid.TryParse(id, out _), $"messageId must parse as Guid; got: '{id}'"); } @@ -117,7 +117,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, PackageVersion)["messageId"]); + ids.Add((string)MessageBuilder.Track("evt", null, null, PackageVersion)[MessageFields.MessageId]); Assert.AreEqual(1000, ids.Count); } @@ -130,7 +130,7 @@ public void AllMessages_EventTimestamp_IsRoundTripIso8601Utc() var before = DateTime.UtcNow.AddSeconds(-2); foreach (var msg in EveryMessageType()) { - var ts = (string)msg["eventTimestamp"]; + var ts = (string)msg[MessageFields.EventTimestamp]; Assert.IsTrue( DateTime.TryParseExact(ts, "o", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed), @@ -148,9 +148,9 @@ public void AllMessages_Context_LibraryAndLibraryVersionAreNonEmptyStrings() { foreach (var msg in EveryMessageType()) { - var ctx = (Dictionary)msg["context"]; - var library = ctx["library"] as string; - var libraryVersion = ctx["libraryVersion"] as string; + var ctx = (Dictionary)msg[MessageFields.Context]; + var library = ctx[MessageFields.Library] as string; + var libraryVersion = ctx[MessageFields.LibraryVersion] as string; Assert.IsFalse(string.IsNullOrEmpty(library), "context.library must be non-empty string"); Assert.IsFalse(string.IsNullOrEmpty(libraryVersion), "context.libraryVersion must be non-empty string"); } diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index c94032cc4..0d397ca53 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -190,7 +190,7 @@ public void ContextProvider_Set_MergesFieldsIntoEveryMessageContext() ImmutableAudience.Shutdown(); var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); - var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + var blobs = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Select(File.ReadAllText).ToList(); Assert.IsTrue(blobs.Any(b => b.Contains("\"userAgent\":\"TestOS 1.0\"") && @@ -219,7 +219,7 @@ public void ContextProvider_Set_MergesOnIdentifyPath() ImmutableAudience.Shutdown(); var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); - var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + var blobs = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Select(File.ReadAllText).ToList(); Assert.IsTrue(blobs.Any(b => b.Contains("\"type\":\"identify\"") && @@ -238,7 +238,7 @@ public void ContextProvider_ThrowingDelegate_SwallowsAndShipsBaseContext() ImmutableAudience.Shutdown(); var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); - var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + var blobs = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Select(File.ReadAllText).ToList(); Assert.IsTrue(blobs.Any(b => b.Contains("\"unit_test_event\"") && b.Contains("\"library\":")), "event should still ship with base context when ContextProvider throws"); @@ -254,7 +254,7 @@ public void ContextProvider_ReturnsNull_ShipsBaseContext() ImmutableAudience.Shutdown(); var queueDir = Path.Combine(_testDir, "imtbl_audience", "queue"); - var blobs = Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText).ToList(); + var blobs = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Select(File.ReadAllText).ToList(); Assert.IsTrue(blobs.Any(b => b.Contains("\"unit_test_event\"") && b.Contains("\"library\":")), "event should still ship with base context when ContextProvider returns null"); @@ -329,7 +329,7 @@ public void Track_IEventMissingRequiredField_DropsWithWarn() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsFalse(contents.Any(c => c.Contains("\"purchase\"")), "purchase event with missing required Value must be dropped, not enqueued"); @@ -363,10 +363,10 @@ public void Track_NullOrEmptyEventName_DoesNotEnqueue() // key ordering, escape style). var queueDir = AudiencePaths.QueueDir(_testDir); if (!Directory.Exists(queueDir)) return; - foreach (var file in Directory.GetFiles(queueDir, "*.json")) + foreach (var file in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) { var msg = JsonReader.DeserializeObject(File.ReadAllText(file)); - if ((string)msg["type"] != "track") continue; + if ((string)msg[MessageFields.Type] != "track") continue; if (!msg.TryGetValue("eventName", out var eventNameObj)) Assert.Fail($"track message {Path.GetFileName(file)} missing eventName field"); @@ -497,7 +497,7 @@ public void Track_CustomEvent_WritesEventToDisk() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var files = Directory.GetFiles(queueDir, "*.json"); + var files = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob); // game_launch + crafting_started Assert.GreaterOrEqual(files.Length, 2); @@ -515,7 +515,7 @@ public void Track_NoProperties_WritesEvent() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"main_menu_opened\""))); } @@ -535,7 +535,7 @@ public void Track_ConsentNone_DoesNotEnqueue() return; } - Assert.AreEqual(0, Directory.GetFiles(queueDir, "*.json").Length); + Assert.AreEqual(0, Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length); } // ----------------------------------------------------------------- @@ -563,13 +563,13 @@ public void Track_TypedEvent_NullEventName_IsDropped() // assertion counts only our test event. ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); - foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + foreach (var f in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) File.Delete(f); Assert.DoesNotThrow(() => ImmutableAudience.Track(new NullNameEvent())); Assert.DoesNotThrow(() => ImmutableAudience.Track(new EmptyNameEvent())); ImmutableAudience.FlushQueueToDiskForTesting(); - Assert.AreEqual(0, Directory.GetFiles(queueDir, "*.json").Length, + Assert.AreEqual(0, Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length, "IEvent with null/empty EventName must be dropped, not enqueued"); } @@ -587,7 +587,7 @@ public void Track_TypedProgression_WritesCorrectEventName() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"progression\"") && c.Contains("\"complete\""))); @@ -606,7 +606,7 @@ public void Track_TypedPurchase_WritesCorrectEventName() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"purchase\""))); } @@ -624,7 +624,7 @@ public void Identify_FullConsent_WritesIdentifyEvent() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"identify\"") && c.Contains("\"76561198012345\""))); @@ -639,7 +639,7 @@ public void Identify_AnonymousConsent_IsIgnored() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsFalse(contents.Any(c => c.Contains("\"identify\"")), "identify should be discarded at Anonymous consent"); @@ -654,7 +654,7 @@ public void Alias_FullConsent_WritesAliasEvent() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"alias\"") && c.Contains("\"steam123\""))); @@ -689,12 +689,12 @@ public void Reset_DiscardsQueuedEventsOnDisk() ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); - Assert.Greater(Directory.GetFiles(queueDir, "*.json").Length, 0, + Assert.Greater(Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length, 0, "precondition: queued event should be on disk before reset"); ImmutableAudience.Reset(); - Assert.AreEqual(0, Directory.GetFiles(queueDir, "*.json").Length, + Assert.AreEqual(0, Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length, "Reset must discard queued events on disk to match the Web SDK"); } @@ -712,12 +712,12 @@ public void SetConsent_DowngradeToNone_PurgesQueueOnDiskAndInMemory() var queueDir = AudiencePaths.QueueDir(_testDir); // Force memory → disk so we can verify the purge wipes both layers. ImmutableAudience.FlushQueueToDiskForTesting(); - Assert.Greater(Directory.GetFiles(queueDir, "*.json").Length, 0, + Assert.Greater(Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length, 0, "precondition: events queued before downgrade exist on disk"); ImmutableAudience.SetConsent(ConsentLevel.None); - Assert.AreEqual(0, Directory.GetFiles(queueDir, "*.json").Length, + Assert.AreEqual(0, Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length, "downgrade to None must purge queued events from disk so they can't leak after revocation"); } @@ -735,7 +735,7 @@ public void SetConsent_DowngradeToNone_DropsInFlightTrack_ThatRacesThePurge() // about our race event only. ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); - foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + foreach (var f in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) File.Delete(f); // Gate the Track thread so it's poised to enqueue at the moment SetConsent // completes its purge. We approximate the race by kicking Track off a @@ -757,7 +757,7 @@ public void SetConsent_DowngradeToNone_DropsInFlightTrack_ThatRacesThePurge() ImmutableAudience.FlushQueueToDiskForTesting(); var leaked = Directory.Exists(queueDir) - ? Directory.GetFiles(queueDir, "*.json").Select(File.ReadAllText) + ? Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Select(File.ReadAllText) .Count(c => c.Contains("\"racing_event\"")) : 0; @@ -787,7 +787,7 @@ public void SetConsent_DowngradeToNone_StressTest_NoLeak() ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); if (Directory.Exists(queueDir)) - foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + foreach (var f in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) File.Delete(f); // All trackers spin up and block on the barrier so they all release // simultaneously. The main thread joins the barrier too and fires @@ -813,7 +813,7 @@ public void SetConsent_DowngradeToNone_StressTest_NoLeak() int leaked = 0; if (Directory.Exists(queueDir)) { - leaked = Directory.GetFiles(queueDir, "*.json") + leaked = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText) .Count(c => c.Contains("\"race_stress\"")); } @@ -935,7 +935,7 @@ public void SetConsent_ConcurrentUpgradeFromNone_StartsOneSession_StressTest() var queueDir = AudiencePaths.QueueDir(_testDir); var sessionStarts = Directory.Exists(queueDir) - ? Directory.GetFiles(queueDir, "*.json") + ? Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText) .Count(c => c.Contains("\"session_start\"")) : 0; @@ -979,7 +979,7 @@ public void SetConsent_DowngradeToAnonymous_StressTest_NoUserIdLeak() ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); if (Directory.Exists(queueDir)) - foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + foreach (var f in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) File.Delete(f); var barrier = new Barrier(trackersPerIteration + 1); var trackers = new Task[trackersPerIteration]; @@ -1001,7 +1001,7 @@ public void SetConsent_DowngradeToAnonymous_StressTest_NoUserIdLeak() int userIdLeaks = 0; if (Directory.Exists(queueDir)) { - userIdLeaks = Directory.GetFiles(queueDir, "*.json") + userIdLeaks = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText) .Count(c => c.Contains($"\"{testUserId}\"")); } @@ -1098,7 +1098,7 @@ public void Init_FiresGameLaunch_Automatically() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"game_launch\"")), "Init should auto-fire game_launch"); @@ -1113,7 +1113,7 @@ public void Init_GameLaunch_IncludesDistributionPlatform() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"game_launch\"") && c.Contains("\"steam\""))); @@ -1173,7 +1173,7 @@ public void Init_ConsentNone_DoesNotFireGameLaunch() return; } - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsFalse(contents.Any(c => c.Contains("\"game_launch\""))); } @@ -1193,7 +1193,7 @@ public void Init_GameLaunch_IncludesLaunchContextProviderFields() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var launchFile = Directory.GetFiles(queueDir, "*.json") + var launchFile = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText) .FirstOrDefault(c => c.Contains("\"game_launch\"")); Assert.IsNotNull(launchFile, "game_launch should have been enqueued"); @@ -1217,7 +1217,7 @@ public void Init_GameLaunch_ConfigDistributionPlatformOverridesProvider() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var launchFile = Directory.GetFiles(queueDir, "*.json") + var launchFile = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText) .First(c => c.Contains("\"game_launch\"")); StringAssert.Contains("\"distributionPlatform\":\"steam\"", launchFile); @@ -1235,7 +1235,7 @@ public void Init_GameLaunch_ProviderThrows_StillFiresEvent() ImmutableAudience.Shutdown(); var queueDir = AudiencePaths.QueueDir(_testDir); - var contents = Directory.GetFiles(queueDir, "*.json") + var contents = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(File.ReadAllText).ToList(); Assert.IsTrue(contents.Any(c => c.Contains("\"game_launch\"")), "game_launch must still ship when the context provider throws"); @@ -1318,16 +1318,16 @@ public void FullToAnonymous_StripsUserIdFromQueuedTrackAndDropsIdentifyAlias() ImmutableAudience.SetConsent(ConsentLevel.Anonymous); var queueDir = AudiencePaths.QueueDir(_testDir); - var files = Directory.GetFiles(queueDir, "*.json"); + var files = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob); foreach (var f in files) { var msg = JsonReader.DeserializeObject(File.ReadAllText(f)); - var type = (string)msg["type"]; + var type = (string)msg[MessageFields.Type]; Assert.AreNotEqual("identify", type, "identify must be purged on Full -> Anonymous"); Assert.AreNotEqual("alias", type, "alias must be purged on Full -> Anonymous"); if (type == "track") - Assert.IsFalse(msg.ContainsKey("userId"), "userId must be stripped from queued track on Full -> Anonymous"); + Assert.IsFalse(msg.ContainsKey(MessageFields.UserId), "userId must be stripped from queued track on Full -> Anonymous"); } } @@ -1342,15 +1342,15 @@ public void FullToAnonymous_FutureTracksOmitUserId() ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); - var trackFiles = Directory.GetFiles(queueDir, "*.json") + var trackFiles = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob) .Select(f => JsonReader.DeserializeObject(File.ReadAllText(f))) - .Where(m => (string)m["type"] == "track" - && m.ContainsKey("eventName") - && (string)m["eventName"] == "tracked_after_downgrade") + .Where(m => (string)m[MessageFields.Type] == "track" + && m.ContainsKey(MessageFields.EventName) + && (string)m[MessageFields.EventName] == "tracked_after_downgrade") .ToList(); Assert.AreEqual(1, trackFiles.Count); - Assert.IsFalse(trackFiles[0].ContainsKey("userId"), + Assert.IsFalse(trackFiles[0].ContainsKey(MessageFields.UserId), "Track under Anonymous consent must not carry userId"); } diff --git a/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs b/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs index 9e9845c91..2a205706f 100644 --- a/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ThreadSafetyStressTests.cs @@ -73,7 +73,7 @@ private void RunSustainedTrackLoad(int threadCount, int durationSeconds) // measures only what our threads enqueue. ImmutableAudience.FlushQueueToDiskForTesting(); var queueDir = AudiencePaths.QueueDir(_testDir); - foreach (var f in Directory.GetFiles(queueDir, "*.json")) File.Delete(f); + foreach (var f in Directory.GetFiles(queueDir, AudiencePaths.QueueGlob)) File.Delete(f); var threads = new Thread[threadCount]; var firedPerThread = new int[threadCount]; @@ -121,7 +121,7 @@ private void RunSustainedTrackLoad(int threadCount, int durationSeconds) ImmutableAudience.FlushQueueToDiskForTesting(); - int onDisk = Directory.GetFiles(queueDir, "*.json").Length; + int onDisk = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Length; Assert.AreEqual(totalFired, onDisk, $"every Track should land on disk; fired={totalFired} onDisk={onDisk} delta={totalFired - onDisk}"); } diff --git a/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs index d3ee554a5..96b4ef3b9 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs @@ -33,7 +33,7 @@ public void Write_CreatesJsonFile_InQueueDirectory() _store.Write("{\"event\":\"test\"}"); var queueDir = AudiencePaths.QueueDir(_testDir); - var files = Directory.GetFiles(queueDir, "*.json"); + var files = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob); Assert.AreEqual(1, files.Length, "should have written exactly one event file"); } @@ -44,7 +44,7 @@ public void Write_FileContents_MatchInputJson() _store.Write(json); var queueDir = AudiencePaths.QueueDir(_testDir); - var file = Directory.GetFiles(queueDir, "*.json").Single(); + var file = Directory.GetFiles(queueDir, AudiencePaths.QueueGlob).Single(); Assert.AreEqual(json, File.ReadAllText(file)); } diff --git a/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs index 548fd79d4..234362ad2 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs @@ -156,9 +156,9 @@ public void PurgeAll_ClearsMemoryAndDisk() { using var queue = new EventQueue(_store, flushIntervalSeconds: 60, flushSize: 100); - queue.Enqueue(new Dictionary { ["type"] = "track", ["eventName"] = "a" }); + queue.Enqueue(new Dictionary { [MessageFields.Type] = "track", [MessageFields.EventName] = "a" }); queue.FlushSync(); - queue.Enqueue(new Dictionary { ["type"] = "track", ["eventName"] = "b" }); + queue.Enqueue(new Dictionary { [MessageFields.Type] = "track", [MessageFields.EventName] = "b" }); queue.PurgeAll(); diff --git a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs index d049705eb..76b8c86f0 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs @@ -52,7 +52,7 @@ public async Task SendBatchAsync_200_DeletesFilesFromDisk() _store.Write("{\"type\":\"track\",\"eventName\":\"a\"}"); _store.Write("{\"type\":\"track\",\"eventName\":\"b\"}"); - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":2,\"rejected\":0}"); + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":2,\"{ResponseFields.Rejected}\":0}}"); using var transport = new HttpTransport(_store, "pk_imapik-test-key1", handler: handler); var sent = await transport.SendBatchAsync(); @@ -72,7 +72,7 @@ public async Task SendBatchAsync_200_SendsGzippedPayloadWithCorrectHeaders() string? capturedContentType = null; string? capturedContentEncoding = null; // Read body inside the callback. The request content is disposed after SendAsync returns. - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}", onRequest: req => { capturedKey = string.Join("", req.Headers.GetValues("x-immutable-publishable-key")); @@ -85,11 +85,11 @@ public async Task SendBatchAsync_200_SendsGzippedPayloadWithCorrectHeaders() await transport.SendBatchAsync(); Assert.AreEqual("pk_imapik-test-key1", capturedKey); - Assert.AreEqual("application/json", capturedContentType); - Assert.AreEqual("gzip", capturedContentEncoding); + Assert.AreEqual(Constants.MediaTypeJson, capturedContentType); + Assert.AreEqual(Constants.GzipEncoding, capturedContentEncoding); var decompressed = DecompressGzip(capturedBody!); - StringAssert.StartsWith("{\"messages\":[", decompressed); + StringAssert.StartsWith($"{{\"{ResponseFields.MessagesEnvelope}\":[", decompressed); StringAssert.EndsWith("]}", decompressed); StringAssert.Contains("\"eventName\":\"test\"", decompressed); } @@ -103,7 +103,7 @@ public async Task SendBatchAsync_200_SendsPlainJsonPayloadWithoutContentEncoding string? capturedContentType = null; int capturedContentEncodingCount = -1; string? capturedBody = null; - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}", onRequest: req => { capturedKey = string.Join("", req.Headers.GetValues("x-immutable-publishable-key")); @@ -116,9 +116,9 @@ public async Task SendBatchAsync_200_SendsPlainJsonPayloadWithoutContentEncoding await transport.SendBatchAsync(); Assert.AreEqual("pk_imapik-test-key1", capturedKey); - Assert.AreEqual("application/json", capturedContentType); + Assert.AreEqual(Constants.MediaTypeJson, capturedContentType); Assert.AreEqual(0, capturedContentEncodingCount, "no Content-Encoding header is permitted in v1"); - StringAssert.StartsWith("{\"messages\":[", capturedBody); + StringAssert.StartsWith($"{{\"{ResponseFields.MessagesEnvelope}\":[", capturedBody); StringAssert.EndsWith("]}", capturedBody); StringAssert.Contains("\"eventName\":\"test\"", capturedBody); } @@ -130,7 +130,7 @@ public async Task SendBatchAsync_200_UsesCorrectUrlForTestKey() _store.Write("{\"type\":\"track\"}"); HttpRequestMessage? captured = null; - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}", onRequest: req => captured = req); using var transport = new HttpTransport(_store, "pk_imapik-test-key1", handler: handler); @@ -145,7 +145,7 @@ public async Task SendBatchAsync_200_UsesCorrectUrlForProdKey() _store.Write("{\"type\":\"track\"}"); HttpRequestMessage? captured = null; - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + 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); @@ -160,7 +160,7 @@ public async Task SendBatchAsync_BaseUrlOverride_WinsOverKeyPrefix() _store.Write("{\"type\":\"track\"}"); HttpRequestMessage? captured = null; - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}", onRequest: req => captured = req); const string custom = "https://api.dev.immutable.com"; // Test-prefixed key would resolve to Sandbox on its own; the @@ -298,7 +298,7 @@ public async Task SendBatchAsync_429ThenSuccess_DeliversBatchAndClearsBackoff() return callCount == 1 ? new HttpResponseMessage((HttpStatusCode)429) : new HttpResponseMessage(HttpStatusCode.OK) - { Content = new StringContent("{\"accepted\":1,\"rejected\":0}") }; + { Content = new StringContent($"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}") }; }); AudienceError? reportedError = null; using var transport = new HttpTransport(_store, "pk_imapik-test-key1", @@ -325,7 +325,7 @@ public async Task SendBatchAsync_200_WithRejected_DeletesFilesAndSurfacesValidat _store.Write("{\"type\":\"track\",\"eventName\":\"a\"}"); _store.Write("{\"type\":\"track\",\"eventName\":\"b\"}"); - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":1}"); + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":1}}"); AudienceError? reportedError = null; using var transport = new HttpTransport(_store, "pk_imapik-test-key1", onError: e => reportedError = e, handler: handler); @@ -343,7 +343,7 @@ public async Task SendBatchAsync_200_ZeroRejected_DoesNotFireOnError() { _store.Write("{\"type\":\"track\",\"eventName\":\"a\"}"); - var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}"); + var handler = new MockHandler(HttpStatusCode.OK, $"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}"); AudienceError? reportedError = null; using var transport = new HttpTransport(_store, "pk_imapik-test-key1", onError: e => reportedError = e, handler: handler); @@ -468,7 +468,7 @@ public async Task BackoffMs_ResetsAfterSuccess() return callCount <= 2 ? new HttpResponseMessage(HttpStatusCode.InternalServerError) : new HttpResponseMessage(HttpStatusCode.OK) - { Content = new StringContent("{\"accepted\":1,\"rejected\":0}") }; + { Content = new StringContent($"{{\"accepted\":1,\"{ResponseFields.Rejected}\":0}}") }; }); using var transport = new HttpTransport(_store, "pk_imapik-test-key1", handler: handler, getUtcNow: _getUtcNow); diff --git a/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs index 839feb0b6..e2c04407f 100644 --- a/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs @@ -69,25 +69,25 @@ public void RoundTripViaSerializer() { var original = new Dictionary { - ["type"] = "track", - ["eventName"] = "progression", - ["properties"] = new Dictionary + [MessageFields.Type] = "track", + [MessageFields.EventName] = "progression", + [MessageFields.Properties] = new Dictionary { ["status"] = "complete", ["score"] = 1500 }, - ["anonymousId"] = "abc", - ["userId"] = "76561198012345" + [MessageFields.AnonymousId] = "abc", + [MessageFields.UserId] = "76561198012345" }; var serialized = Json.Serialize(original); var parsed = JsonReader.DeserializeObject(serialized); - Assert.AreEqual("track", parsed["type"]); - Assert.AreEqual("progression", parsed["eventName"]); - Assert.AreEqual("abc", parsed["anonymousId"]); - Assert.AreEqual("76561198012345", parsed["userId"]); - var props = (Dictionary)parsed["properties"]; + Assert.AreEqual("track", parsed[MessageFields.Type]); + Assert.AreEqual("progression", parsed[MessageFields.EventName]); + Assert.AreEqual("abc", parsed[MessageFields.AnonymousId]); + Assert.AreEqual("76561198012345", parsed[MessageFields.UserId]); + var props = (Dictionary)parsed[MessageFields.Properties]; Assert.AreEqual("complete", props["status"]); Assert.AreEqual(1500, props["score"]); }