diff --git a/src/Packages/Audience/Runtime/AudienceConfig.cs b/src/Packages/Audience/Runtime/AudienceConfig.cs index c4b2ae9af..49cb55fd9 100644 --- a/src/Packages/Audience/Runtime/AudienceConfig.cs +++ b/src/Packages/Audience/Runtime/AudienceConfig.cs @@ -10,6 +10,11 @@ public class AudienceConfig // Studio API key. Required — Init throws if null. public string? PublishableKey { get; set; } + // Override the default API base URL. When null, keys starting with + // "pk_imapik-test-" resolve to Sandbox and all other keys resolve + // to Production. Set explicitly to target a different backend. + public string? BaseUrl { get; set; } + // Initial consent level. public ConsentLevel Consent { get; set; } = ConsentLevel.None; diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index f57a8f3bd..f567aef8a 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -26,14 +26,22 @@ internal static class Constants internal const string PublishableKeyHeader = "x-immutable-publishable-key"; - internal static string MessagesUrl(string? publishableKey) => BaseUrl(publishableKey) + MessagesPath; - internal static string ConsentUrl(string? publishableKey) => BaseUrl(publishableKey) + ConsentPath; - internal static string DataUrl(string? publishableKey) => BaseUrl(publishableKey) + DataPath; + internal static string MessagesUrl(string? publishableKey, string? baseUrlOverride = null) => + BaseUrl(publishableKey, baseUrlOverride) + MessagesPath; + internal static string ConsentUrl(string? publishableKey, string? baseUrlOverride = null) => + BaseUrl(publishableKey, baseUrlOverride) + ConsentPath; + internal static string DataUrl(string? publishableKey, string? baseUrlOverride = null) => + BaseUrl(publishableKey, baseUrlOverride) + DataPath; - internal static string BaseUrl(string? publishableKey) => - publishableKey != null && publishableKey.StartsWith(TestKeyPrefix) + // Override wins when non-empty; otherwise test keys map to Sandbox + // and every other key maps to Production. Matches @imtbl/audience. + internal static string BaseUrl(string? publishableKey, string? baseUrlOverride = null) + { + if (!string.IsNullOrEmpty(baseUrlOverride)) return baseUrlOverride!; + return publishableKey != null && publishableKey.StartsWith(TestKeyPrefix) ? SandboxBaseUrl : ProductionBaseUrl; + } } // Message type values written to (and read back from) the "type" field. diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 45d84239b..d1d9a845b 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -51,6 +51,56 @@ public static class ImmutableAudience // assignments from SetConsent without taking _initLock. private static volatile Session? _session; + // True between Init() and Shutdown(). + public static bool Initialized => _initialized; + + // The consent level the SDK is currently honouring. + public static ConsentLevel CurrentConsent => _state.Level; + + // The user ID from the most recent Identify() call. Null after + // Reset() or when consent is below Full. + public static string? UserId => _state.UserId; + + // An anonymous, persistent ID — unlike SessionId (rotates per + // session) and UserId (identifies the user). Reset() and + // SetConsent(None) wipe it; null while consent is None. + public static string? AnonymousId + { + get + { + if (!_initialized) return null; + var config = _config; + if (config == null || !_state.Level.CanTrack()) return null; + // PersistentDataPath is validated non-null in Init; compiler can't propagate that. + return Identity.Get(config.PersistentDataPath!); + } + } + + // The current session's ID. A new ID is assigned at Init(), at Reset(), + // and when the app resumes after the previous session has timed out. + // Null while consent is None. + public static string? SessionId => _session?.SessionId; + + // Number of unsent events (in memory and on disk). + public static int QueueSize + { + get + { + // Fence off the volatile _initialized load first, matching + // the protocol documented on the reference fields. Without + // this, a weak-memory-order reader could observe + // _initialized=true but _queue/_store still null — the ?. + // short-circuits to 0 in that case, but the inconsistency + // would break the protocol the file claims to follow. + if (!_initialized) return 0; + var queue = _queue; + var store = _store; + var memory = queue?.InMemoryCount ?? 0; + var disk = store?.Count() ?? 0; + return memory + disk; + } + } + // Starts the SDK. Call once at launch. public static void Init(AudienceConfig config) { @@ -82,7 +132,7 @@ public static void Init(AudienceConfig config) _store = new DiskStore(config.PersistentDataPath); _queue = new EventQueue(_store, config.FlushIntervalSeconds, config.FlushSize); - _transport = new HttpTransport(_store, config.PublishableKey, config.OnError, config.HttpHandler); + _transport = new HttpTransport(_store, config.PublishableKey, config.BaseUrl, config.OnError, config.HttpHandler); _controlClient = config.HttpHandler != null ? new HttpClient(config.HttpHandler, disposeHandler: false) : new HttpClient(); @@ -338,7 +388,7 @@ public static Task DeleteData(string? userId = null) query = "anonymousId=" + Uri.EscapeDataString(anonymousId); } - var url = Constants.DataUrl(config.PublishableKey) + "?" + query; + var url = Constants.DataUrl(config.PublishableKey, config.BaseUrl) + "?" + query; var onError = config.OnError; var publishableKey = config.PublishableKey; var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None; @@ -500,7 +550,7 @@ private static void SyncConsentToBackend(AudienceConfig config, ConsentLevel lev var client = _controlClient; if (client == null) return; - var url = Constants.ConsentUrl(config.PublishableKey); + var url = Constants.ConsentUrl(config.PublishableKey, config.BaseUrl); var publishableKey = config.PublishableKey; var onError = config.OnError; var cancellationToken = _shutdownCancellationSource?.Token ?? CancellationToken.None; @@ -719,8 +769,6 @@ internal static void ResetState() } } - internal static ConsentLevel CurrentConsent => _state.Level; - internal static void FlushQueueToDiskForTesting() => _queue?.FlushSync(); // Drives SendBatch without a real timer so the overlapping-tick guard is testable. diff --git a/src/Packages/Audience/Runtime/Transport/EventQueue.cs b/src/Packages/Audience/Runtime/Transport/EventQueue.cs index 66922e3f0..7c457cbb1 100644 --- a/src/Packages/Audience/Runtime/Transport/EventQueue.cs +++ b/src/Packages/Audience/Runtime/Transport/EventQueue.cs @@ -49,6 +49,12 @@ internal EventQueue(DiskStore store, int flushIntervalSeconds, int flushSize) _drainThread.Start(); } + // Approximate count of events currently in the in-memory queue + // awaiting drain to disk. Lock-free read on ConcurrentQueue.Count + // — a snapshot that can race with concurrent enqueue / dequeue. + // Good enough for status-panel display; not an invariant. + internal int InMemoryCount => _memory.Count; + // Enqueues a message dictionary. Lock-free; safe from any thread. // The dictionary is not copied -- callers must not mutate it after // enqueue. Serialisation happens on the drain thread so Track() stays diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 80c2dc9ed..4b422d342 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -28,18 +28,20 @@ internal sealed class HttpTransport : IDisposable // store: source of event batches. // publishableKey: sent as x-immutable-publishable-key on every request. + // baseUrlOverride: explicit backend URL. Null = derive from publishableKey prefix. // onError: optional failure callback. Exceptions thrown inside it are caught. // handler / getUtcNow: test seams; null for production use. internal HttpTransport( DiskStore store, string publishableKey, + string? baseUrlOverride = null, Action? onError = null, HttpMessageHandler? handler = null, Func? getUtcNow = null) { _store = store ?? throw new ArgumentNullException(nameof(store)); _publishableKey = publishableKey ?? throw new ArgumentNullException(nameof(publishableKey)); - _url = Constants.MessagesUrl(publishableKey); + _url = Constants.MessagesUrl(publishableKey, baseUrlOverride); _onError = onError; // disposeHandler: false so the consumer can reuse their handler // across Init/Shutdown cycles (matches _controlClient's policy). diff --git a/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs index f72851810..03d641382 100644 --- a/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs @@ -6,6 +6,54 @@ namespace Immutable.Audience.Tests [TestFixture] internal class ConstantsTests { + // ----------------------------------------------------------------- + // BaseUrl resolution + // ----------------------------------------------------------------- + + [Test] + public void BaseUrl_TestKey_ResolvesToSandbox() + { + Assert.AreEqual(Constants.SandboxBaseUrl, + Constants.BaseUrl("pk_imapik-test-abc")); + } + + [Test] + public void BaseUrl_NonTestKey_ResolvesToProduction() + { + Assert.AreEqual(Constants.ProductionBaseUrl, + Constants.BaseUrl("pk_imapik-prod-abc")); + } + + [Test] + public void BaseUrl_NullKey_ResolvesToProduction() + { + Assert.AreEqual(Constants.ProductionBaseUrl, + Constants.BaseUrl(null)); + } + + [Test] + public void BaseUrl_Override_WinsOverKeyPrefix() + { + // Override wins even for a test-prefixed key that would + // otherwise derive to Sandbox. + const string custom = "https://api.dev.immutable.com"; + Assert.AreEqual(custom, + Constants.BaseUrl("pk_imapik-test-abc", custom)); + } + + [Test] + public void BaseUrl_EmptyOverride_FallsBackToKeyDerivation() + { + // Empty-string override is treated as "no override" so the + // key-prefix fallback still kicks in. + Assert.AreEqual(Constants.SandboxBaseUrl, + Constants.BaseUrl("pk_imapik-test-abc", "")); + } + + // ----------------------------------------------------------------- + // Library version + // ----------------------------------------------------------------- + [Test] public void LibraryVersion_MatchesPackageJson() { diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 77f289a96..60b2e4cb2 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -59,6 +59,117 @@ protected override Task SendAsync(HttpRequestMessage reques } } + // ----------------------------------------------------------------- + // Diagnostic getters (Initialized / CurrentConsent / UserId / + // AnonymousId / SessionId / QueueSize) + // ----------------------------------------------------------------- + + [Test] + public void Initialized_FlipsAroundInitAndShutdown() + { + Assert.IsFalse(ImmutableAudience.Initialized, + "Initialized should be false before Init"); + + ImmutableAudience.Init(MakeConfig()); + Assert.IsTrue(ImmutableAudience.Initialized, + "Initialized should flip true after Init"); + + ImmutableAudience.Shutdown(); + Assert.IsFalse(ImmutableAudience.Initialized, + "Initialized should flip back to false after Shutdown"); + } + + [Test] + public void CurrentConsent_ReflectsLatestSetConsent() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + Assert.AreEqual(ConsentLevel.Anonymous, ImmutableAudience.CurrentConsent); + + ImmutableAudience.SetConsent(ConsentLevel.Full); + Assert.AreEqual(ConsentLevel.Full, ImmutableAudience.CurrentConsent); + + ImmutableAudience.SetConsent(ConsentLevel.None); + Assert.AreEqual(ConsentLevel.None, ImmutableAudience.CurrentConsent); + } + + [Test] + public void UserId_Uninitialised_ReturnsNull() + { + Assert.IsNull(ImmutableAudience.UserId); + } + + [Test] + public void UserId_AfterIdentifyAndReset_TracksState() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Full)); + Assert.IsNull(ImmutableAudience.UserId, + "UserId should be null until Identify is called"); + + ImmutableAudience.Identify("player-42", IdentityType.Custom); + Assert.AreEqual("player-42", ImmutableAudience.UserId, + "UserId must reflect the most recent Identify call"); + + ImmutableAudience.Reset(); + Assert.IsNull(ImmutableAudience.UserId, + "Reset must clear UserId so the next player is not attributed to the previous one"); + } + + [Test] + public void AnonymousId_ConsentNone_ReturnsNull() + { + // Anonymous identifier is consent-gated: below tracking consent, + // no stable id should leak through the getter. + ImmutableAudience.Init(MakeConfig(ConsentLevel.None)); + + Assert.IsNull(ImmutableAudience.AnonymousId); + } + + [Test] + public void AnonymousId_ConsentAnonymous_ReturnsPersistedId() + { + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + // Track once so Identity.GetOrCreate runs and writes the id file. + ImmutableAudience.Track("warmup_event"); + + var id = ImmutableAudience.AnonymousId; + Assert.IsFalse(string.IsNullOrEmpty(id), + "AnonymousId should return the persisted id once tracking has created one"); + } + + [Test] + public void SessionId_MirrorsSessionLifecycle() + { + Assert.IsNull(ImmutableAudience.SessionId, + "SessionId should be null before Init"); + + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + Assert.IsFalse(string.IsNullOrEmpty(ImmutableAudience.SessionId), + "SessionId should be non-null once Init creates a session"); + + ImmutableAudience.Shutdown(); + Assert.IsNull(ImmutableAudience.SessionId, + "SessionId should be null after Shutdown disposes the session"); + } + + [Test] + public void QueueSize_ZeroBeforeInit_GrowsWithEnqueue() + { + Assert.AreEqual(0, ImmutableAudience.QueueSize, + "QueueSize should be 0 before Init"); + + ImmutableAudience.Init(MakeConfig(ConsentLevel.Anonymous)); + // Init enqueues session_start + game_launch; those stay + // in-memory until a flush. QueueSize sums memory + disk so the + // pre-flush snapshot must be > 0. + var afterInit = ImmutableAudience.QueueSize; + Assert.Greater(afterInit, 0, + "QueueSize should include session_start and game_launch after Init"); + + ImmutableAudience.Track("explicit_track_event"); + Assert.Greater(ImmutableAudience.QueueSize, afterInit, + "QueueSize should grow when a new event is enqueued"); + } + // ----------------------------------------------------------------- // Unity context provider // ----------------------------------------------------------------- diff --git a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs index 8d901bd24..0ecd9d95d 100644 --- a/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs @@ -152,6 +152,25 @@ public async Task SendBatchAsync_200_UsesCorrectUrlForProdKey() StringAssert.StartsWith(Constants.ProductionBaseUrl, captured.RequestUri.ToString()); } + [Test] + public async Task SendBatchAsync_BaseUrlOverride_WinsOverKeyPrefix() + { + _store.Write("{\"type\":\"track\"}"); + + HttpRequestMessage captured = null; + var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"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 + // explicit override must win. + using var transport = new HttpTransport(_store, "pk_imapik-test-key1", + baseUrlOverride: custom, handler: handler); + + await transport.SendBatchAsync(); + + StringAssert.StartsWith(custom, captured.RequestUri.ToString()); + } + [Test] public async Task SendBatchAsync_EmptyQueue_ReturnsFalse() {