diff --git a/src/Packages/Audience/Runtime/AudienceConfig.cs b/src/Packages/Audience/Runtime/AudienceConfig.cs index 759645562..f6085bca1 100644 --- a/src/Packages/Audience/Runtime/AudienceConfig.cs +++ b/src/Packages/Audience/Runtime/AudienceConfig.cs @@ -1,17 +1,24 @@ namespace Immutable.Audience { - /// Configuration passed to . + // Configuration passed to ImmutableAudience.Init. public class AudienceConfig { + // Studio API key. public string PublishableKey { get; set; } + + // Initial consent level. public ConsentLevel Consent { get; set; } = ConsentLevel.None; - /// - /// Distribution platform the game is running on. - /// Use for common values, or pass any custom string. - /// + + // Distribution platform the game is running on. public string DistributionPlatform { get; set; } + + // Enable debug logging. public bool Debug { get; set; } = false; + + // How often pending events are flushed to the backend. public int FlushIntervalSeconds { get; set; } = Constants.DefaultFlushIntervalSeconds; + + // Flush as soon as this many events are queued. public int FlushSize { get; set; } = Constants.DefaultFlushSize; } } diff --git a/src/Packages/Audience/Runtime/ConsentLevel.cs b/src/Packages/Audience/Runtime/ConsentLevel.cs index 66e518c05..197836d75 100644 --- a/src/Packages/Audience/Runtime/ConsentLevel.cs +++ b/src/Packages/Audience/Runtime/ConsentLevel.cs @@ -1,13 +1,13 @@ namespace Immutable.Audience { - /// Controls what the Audience SDK tracks. + // How much data the Audience SDK is allowed to collect. public enum ConsentLevel { - /// SDK inert. No events queued or sent. No IDs persisted to disk. + // No tracking. None, - /// Track events with anonymousId only. Identify/Alias discarded with warning. + // Anonymous tracking only. Anonymous, - /// All events. Identify/Alias send. userId attached to track events. + // Full tracking, including identity. Full } } diff --git a/src/Packages/Audience/Runtime/Core/AudiencePaths.cs b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs new file mode 100644 index 000000000..52fb40c33 --- /dev/null +++ b/src/Packages/Audience/Runtime/Core/AudiencePaths.cs @@ -0,0 +1,24 @@ +using System.IO; + +namespace Immutable.Audience +{ + internal static class AudiencePaths + { + private const string RootDirName = "imtbl_audience"; + private const string IdentityFileName = "identity"; + private const string ConsentFileName = "consent"; + private const string QueueDirName = "queue"; + + internal static string AudienceDir(string persistentDataPath) => + Path.Combine(persistentDataPath, RootDirName); + + internal static string IdentityFile(string persistentDataPath) => + Path.Combine(AudienceDir(persistentDataPath), IdentityFileName); + + internal static string ConsentFile(string persistentDataPath) => + Path.Combine(AudienceDir(persistentDataPath), ConsentFileName); + + internal static string QueueDir(string persistentDataPath) => + Path.Combine(AudienceDir(persistentDataPath), QueueDirName); + } +} diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index 6573730d8..a7ed09792 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -25,10 +25,7 @@ internal static string BaseUrl(string publishableKey) => : ProductionBaseUrl; } - /// - /// String constants for common game distribution platforms. - /// Any string is accepted -- studios are not limited to these values. - /// + // Common distribution platform values for AudienceConfig.DistributionPlatform. public static class DistributionPlatforms { public const string Steam = "steam"; diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs index 70033f4c7..2b1143d38 100644 --- a/src/Packages/Audience/Runtime/Core/Identity.cs +++ b/src/Packages/Audience/Runtime/Core/Identity.cs @@ -15,12 +15,6 @@ internal sealed class Identity private static volatile string _cachedId; private static readonly object _sync = new object(); - private static string GetDirectory(string persistentDataPath) => - Path.Combine(persistentDataPath, "imtbl_audience"); - - private static string GetFilePath(string persistentDataPath) => - Path.Combine(GetDirectory(persistentDataPath), "identity"); - // Returns the anonymous ID, generating and persisting it on first call. // Returns null without touching disk when consent is None. // Safe to call from any thread after ImmutableAudience.Init() has run on the main thread. @@ -41,10 +35,10 @@ internal static string GetOrCreate(string persistentDataPath, ConsentLevel conse if (_cachedId != null) return _cachedId; - var dir = GetDirectory(persistentDataPath); + var dir = AudiencePaths.AudienceDir(persistentDataPath); Directory.CreateDirectory(dir); // no-op if already exists - var filePath = GetFilePath(persistentDataPath); + var filePath = AudiencePaths.IdentityFile(persistentDataPath); // Returning player — read the ID we wrote on a previous launch. if (File.Exists(filePath)) @@ -85,7 +79,7 @@ internal static void Reset(string persistentDataPath) { _cachedId = null; - var filePath = GetFilePath(persistentDataPath); + var filePath = AudiencePaths.IdentityFile(persistentDataPath); try { File.Delete(filePath); diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 6c882b7b7..d8b78aed2 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -1,14 +1,11 @@ namespace Immutable.Audience { - /// - /// Entry point for the Immutable Audience SDK. - /// Call once on startup, then use the static methods from any thread. - /// + // Entry point for the Immutable Audience SDK. public static class ImmutableAudience { // Scaffold only -- implementation follows in subsequent sub-issues (see SDK-99). - /// Initialise the SDK. Call once, typically in your game's entry scene. + // Starts the SDK. Call once at launch. public static void Init(AudienceConfig config) { throw new System.NotImplementedException( diff --git a/src/Packages/Audience/Runtime/Transport/DiskStore.cs b/src/Packages/Audience/Runtime/Transport/DiskStore.cs index 6e691521b..e57e09137 100644 --- a/src/Packages/Audience/Runtime/Transport/DiskStore.cs +++ b/src/Packages/Audience/Runtime/Transport/DiskStore.cs @@ -5,10 +5,8 @@ namespace Immutable.Audience { - /// - /// File-per-event persistent store. Each event is written as an atomic - /// {ticks}_{uuid}.json file inside imtbl_audience/queue/. - /// + // File-per-event persistent store. Each event is written as an atomic + // {ticks}_{uuid}.json file inside imtbl_audience/queue/. internal sealed class DiskStore { private readonly string _queueDir; @@ -19,7 +17,7 @@ internal DiskStore(string persistentDataPath) Directory.CreateDirectory(_queueDir); } - /// Atomically writes as a new event file. + // Atomically writes json as a new event file. internal void Write(string json) { var fileName = $"{DateTime.UtcNow.Ticks}_{Guid.NewGuid():N}.json"; @@ -40,10 +38,8 @@ internal void Write(string json) } } - /// - /// Returns up to file paths, oldest first. - /// Files older than days are deleted and excluded. - /// + // Returns up to maxSize file paths, oldest first. Stale files + // (older than Constants.StaleEventDays) are deleted and excluded. internal IReadOnlyList ReadBatch(int maxSize) { if (maxSize <= 0) @@ -83,14 +79,14 @@ internal IReadOnlyList ReadBatch(int maxSize) return result; } - /// Deletes the event files at . + // Deletes the given event files. internal void Delete(IEnumerable paths) { foreach (var path in paths) TryDelete(path); } - /// Returns the total number of event files currently on disk. + // Total number of event files currently on disk. internal int Count() => Directory.GetFiles(_queueDir, "*.json").Length; private static void TryDelete(string path) diff --git a/src/Packages/Audience/Runtime/Transport/EventQueue.cs b/src/Packages/Audience/Runtime/Transport/EventQueue.cs index 0b7491a5f..8f4ae023b 100644 --- a/src/Packages/Audience/Runtime/Transport/EventQueue.cs +++ b/src/Packages/Audience/Runtime/Transport/EventQueue.cs @@ -4,17 +4,11 @@ namespace Immutable.Audience { - /// - /// Thread-safe, disk-persistent batch event queue for the Audience SDK. - /// - /// Enqueue is lock-free and safe to call from any thread. A background - /// drain thread moves events from the in-memory - /// to , flushing either on a time interval or when the - /// in-memory batch reaches . - /// - /// Call before process exit to flush remaining events - /// and stop the drain thread cleanly. - /// + // Thread-safe, disk-persistent batch event queue. + // Enqueue is lock-free and safe from any thread. A background drain + // thread moves events from the in-memory ConcurrentQueue to DiskStore, + // flushing on a time interval or when the batch reaches FlushSize. + // Call Shutdown before process exit. internal sealed class EventQueue : IDisposable { private readonly DiskStore _store; @@ -29,9 +23,9 @@ internal sealed class EventQueue : IDisposable // Volatile so all threads see the shutdown signal immediately. private volatile bool _disposed; - /// Pre-created for this queue. - /// How often to drain to disk regardless of batch size. - /// Drain to disk immediately when this many events are queued. + // store: destination for drained events. + // flushIntervalSeconds: how often to drain to disk regardless of batch size. + // flushSize: drain to disk immediately when this many events are queued. internal EventQueue(DiskStore store, int flushIntervalSeconds, int flushSize) { _store = store ?? throw new ArgumentNullException(nameof(store)); @@ -46,7 +40,7 @@ internal EventQueue(DiskStore store, int flushIntervalSeconds, int flushSize) _drainThread.Start(); } - /// Enqueues a JSON-serialised event. Lock-free; safe from any thread. + // Enqueues a JSON-serialised event. Lock-free; safe from any thread. internal void Enqueue(string json) { if (_disposed) return; @@ -58,19 +52,15 @@ internal void Enqueue(string json) _flushGate.Set(); } - /// - /// Drains the in-memory queue and persists all events to disk immediately. - /// Blocks until the drain is complete. - /// + // Drains the in-memory queue and persists all events to disk + // immediately. Blocks until the drain is complete. internal void FlushSync() { DrainMemoryToDisk(); } - /// - /// Flushes all pending events to disk and stops the drain thread. - /// Safe to call multiple times. - /// + // Flushes all pending events to disk and stops the drain thread. + // Safe to call multiple times. internal void Shutdown() { if (_disposed) return; diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 093d15c65..7590e4229 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -12,9 +12,7 @@ namespace Immutable.Audience { - /// - /// Sends queued events from to the Audience backend. - /// + // Sends queued events from DiskStore to the Audience backend. internal sealed class HttpTransport : IDisposable { private readonly DiskStore _store; @@ -27,11 +25,10 @@ internal sealed class HttpTransport : IDisposable private int _consecutiveFailures; private DateTime? _nextAttemptAt; - /// Source of event batches. - /// Studio API key. Sent as x-immutable-publishable-key on every request. - /// Optional failure callback. Exceptions thrown inside it are caught and ignored. - /// Optional . Callers can supply a custom pipeline (e.g. specific for test purposes). Defaults to the standard handler when null. - /// Optional UTC clock source used for backoff timing (e.g. swappable for deterministic time). Defaults to DateTime.UtcNow when null. + // store: source of event batches. + // publishableKey: sent as x-immutable-publishable-key on every request. + // onError: optional failure callback. Exceptions thrown inside it are caught. + // handler / getUtcNow: test seams; null for production use. internal HttpTransport( DiskStore store, string publishableKey, @@ -48,10 +45,8 @@ internal HttpTransport( _getUtcNow = getUtcNow ?? (() => DateTime.UtcNow); } - /// - /// Attempts to process one batch: reads it from disk, gzips it, and POSTs it. - /// Returns true if a batch was consumed (outcome irrelevant), false if the queue was empty. - /// + // Processes one batch. Returns true if a batch was consumed + // (outcome irrelevant), false if the queue was empty. internal async Task SendBatchAsync(CancellationToken ct = default) { var batch = _store.ReadBatch(Constants.DefaultFlushSize); @@ -80,15 +75,18 @@ internal async Task SendBatchAsync(CancellationToken ct = default) return true; } - var compressed = Gzip.Compress(payload); - try { using var request = new HttpRequestMessage(HttpMethod.Post, _url); request.Headers.Add("x-immutable-publishable-key", _publishableKey); +#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"); +#else + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); +#endif using var response = await _client.SendAsync(request, ct).ConfigureAwait(false); @@ -143,16 +141,11 @@ internal async Task SendBatchAsync(CancellationToken ct = default) _ => 60_000, }; - /// - /// Earliest UTC time at which the next attempt may run. - /// Null when no backoff is active (never failed, or last attempt succeeded). - /// + // Earliest UTC time at which the next attempt may run. + // Null when no backoff is active. internal DateTime? NextAttemptAt => _nextAttemptAt; - /// - /// True while UtcNow < NextAttemptAt. Flips false as the clock - /// advances; no reset required. - /// + // True while UtcNow < NextAttemptAt. Flips false as the clock advances. internal bool IsInBackoffWindow => _getUtcNow() < _nextAttemptAt; public void Dispose() @@ -175,14 +168,9 @@ private void ResetBackoff() _nextAttemptAt = null; } - /// - /// Reads each path and wraps the concatenated JSON bodies in - /// {"batch":[msg1,msg2,...]}. - /// - /// - /// The batched JSON, or null if every path was unreadable. Caller - /// treats null as "nothing to send" and deletes the path list. - /// + // Reads each path and wraps the concatenated JSON bodies in + // {"batch":[msg1,msg2,...]}. Returns null if every path was + // unreadable; the caller treats null as "nothing to send". private static string? BuildPayload(IReadOnlyList paths) { var sb = new StringBuilder("{\"batch\":["); diff --git a/src/Packages/Audience/Runtime/Utility/Gzip.cs b/src/Packages/Audience/Runtime/Utility/Gzip.cs index c54d4cd33..8f8b11c4b 100644 --- a/src/Packages/Audience/Runtime/Utility/Gzip.cs +++ b/src/Packages/Audience/Runtime/Utility/Gzip.cs @@ -1,13 +1,12 @@ +#if IMMUTABLE_AUDIENCE_GZIP using System.IO; using System.IO.Compression; using System.Text; namespace Immutable.Audience { - /// - /// Gzip compression using from System.IO.Compression. - /// Available in Unity 2021+ (.NET Standard 2.1). Pure C#, works on all desktop platforms. - /// + // Gzip compression via GZipStream from System.IO.Compression. + // Available in Unity 2021+ (.NET Standard 2.1). Works on all desktop platforms. internal static class Gzip { internal static byte[] Compress(string text) @@ -23,4 +22,5 @@ internal static byte[] Compress(string text) return output.ToArray(); } } -} \ No newline at end of file +} +#endif diff --git a/src/Packages/Audience/Runtime/Utility/JsonReader.cs b/src/Packages/Audience/Runtime/Utility/JsonReader.cs new file mode 100644 index 000000000..bd516dc63 --- /dev/null +++ b/src/Packages/Audience/Runtime/Utility/JsonReader.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Immutable.Audience +{ + // Minimal JSON reader. Handles the subset produced by Json.Serialize: + // objects, strings, numbers, booleans, null, arrays. Reflection-free so + // IL2CPP-safe. Throws FormatException on malformed input. + internal static class JsonReader + { + internal static Dictionary DeserializeObject(string json) + { + var p = new Parser(json); + p.SkipWhitespace(); + var result = p.ReadObject(); + p.SkipWhitespace(); + if (p.Pos != json.Length) + throw new FormatException($"Trailing content at position {p.Pos}"); + return result; + } + + private struct Parser + { + private readonly string _s; + internal int Pos; + + internal Parser(string s) { _s = s; Pos = 0; } + + internal void SkipWhitespace() + { + while (Pos < _s.Length) + { + var c = _s[Pos]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') Pos++; + else break; + } + } + + internal Dictionary ReadObject() + { + Expect('{'); + var obj = new Dictionary(); + SkipWhitespace(); + if (Peek() == '}') { Pos++; return obj; } + + while (true) + { + SkipWhitespace(); + var key = ReadString(); + SkipWhitespace(); + Expect(':'); + SkipWhitespace(); + obj[key] = ReadValue(); + SkipWhitespace(); + var next = Read(); + if (next == ',') continue; + if (next == '}') return obj; + throw new FormatException($"Expected ',' or '}}' at position {Pos - 1}"); + } + } + + private List ReadArray() + { + Expect('['); + var arr = new List(); + SkipWhitespace(); + if (Peek() == ']') { Pos++; return arr; } + + while (true) + { + SkipWhitespace(); + arr.Add(ReadValue()); + SkipWhitespace(); + var next = Read(); + if (next == ',') continue; + if (next == ']') return arr; + throw new FormatException($"Expected ',' or ']' at position {Pos - 1}"); + } + } + + private object ReadValue() + { + SkipWhitespace(); + var c = Peek(); + if (c == '"') return ReadString(); + if (c == '{') return ReadObject(); + if (c == '[') return ReadArray(); + if (c == 't' || c == 'f') return ReadBool(); + if (c == 'n') { ReadLiteral("null"); return null; } + return ReadNumber(); + } + + private string ReadString() + { + Expect('"'); + var sb = new StringBuilder(); + while (Pos < _s.Length) + { + var c = _s[Pos++]; + if (c == '"') return sb.ToString(); + if (c == '\\') + { + if (Pos >= _s.Length) throw new FormatException("Unterminated escape"); + var esc = _s[Pos++]; + switch (esc) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + case '/': sb.Append('/'); break; + case 'b': sb.Append('\b'); break; + case 'f': sb.Append('\f'); break; + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + case 'u': + if (Pos + 4 > _s.Length) throw new FormatException("Truncated \\u escape"); + var hex = _s.Substring(Pos, 4); + Pos += 4; + sb.Append((char)int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + break; + default: throw new FormatException($"Invalid escape \\{esc}"); + } + } + else sb.Append(c); + } + throw new FormatException("Unterminated string"); + } + + private object ReadNumber() + { + var start = Pos; + if (Peek() == '-') Pos++; + while (Pos < _s.Length) + { + var c = _s[Pos]; + if ((c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') Pos++; + else break; + } + var token = _s.Substring(start, Pos - start); + if (token.IndexOfAny(new[] { '.', 'e', 'E' }) < 0 + && long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l)) + { + if (l >= int.MinValue && l <= int.MaxValue) return (int)l; + return l; + } + if (double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) + return d; + throw new FormatException($"Invalid number '{token}'"); + } + + private bool ReadBool() + { + if (Peek() == 't') { ReadLiteral("true"); return true; } + ReadLiteral("false"); + return false; + } + + private void ReadLiteral(string literal) + { + if (Pos + literal.Length > _s.Length || _s.Substring(Pos, literal.Length) != literal) + throw new FormatException($"Expected '{literal}' at position {Pos}"); + Pos += literal.Length; + } + + private char Peek() => + Pos < _s.Length ? _s[Pos] : throw new FormatException("Unexpected end of input"); + + private char Read() => + Pos < _s.Length ? _s[Pos++] : throw new FormatException("Unexpected end of input"); + + private void Expect(char c) + { + if (Pos >= _s.Length || _s[Pos] != c) + throw new FormatException($"Expected '{c}' at position {Pos}"); + Pos++; + } + } + } +} diff --git a/src/Packages/Audience/Runtime/Utility/Log.cs b/src/Packages/Audience/Runtime/Utility/Log.cs new file mode 100644 index 000000000..af7ee940d --- /dev/null +++ b/src/Packages/Audience/Runtime/Utility/Log.cs @@ -0,0 +1,33 @@ +using System; + +namespace Immutable.Audience +{ + internal static class Log + { + private const string Prefix = "[ImmutableAudience]"; + + internal static bool Enabled { get; set; } + + // Tests set this to capture output; AudienceUnityHooks sets it to Debug.Log. + internal static Action Writer { get; set; } + + internal static void Debug(string message) + { + if (!Enabled) return; + Emit($"{Prefix} {message}"); + } + + internal static void Warn(string message) => + Emit($"{Prefix} WARN: {message}"); + + private static void Emit(string line) + { + if (Writer != null) + { + Writer(line); + return; + } + Console.WriteLine(line); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/GzipTests.cs b/src/Packages/Audience/Tests/Runtime/GzipTests.cs index 8d509be44..074dd21f8 100644 --- a/src/Packages/Audience/Tests/Runtime/GzipTests.cs +++ b/src/Packages/Audience/Tests/Runtime/GzipTests.cs @@ -1,3 +1,4 @@ +#if IMMUTABLE_AUDIENCE_GZIP using System.IO; using System.IO.Compression; using System.Text; @@ -56,4 +57,5 @@ public void Compress_EmptyString_ProducesValidGzip() Assert.AreEqual("", decompressed); } } -} \ No newline at end of file +} +#endif diff --git a/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs index 8e5f18620..30a29f165 100644 --- a/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs +++ b/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs @@ -1,6 +1,8 @@ using System; using System.IO; +#if IMMUTABLE_AUDIENCE_GZIP using System.IO.Compression; +#endif using System.Net; using System.Net.Http; using System.Text; @@ -57,6 +59,7 @@ public async Task SendBatchAsync_200_DeletesFilesFromDisk() Assert.AreEqual(0, _store.Count(), "files should be deleted after 200"); } +#if IMMUTABLE_AUDIENCE_GZIP [Test] public async Task SendBatchAsync_200_SendsGzippedPayloadWithCorrectHeaders() { @@ -65,12 +68,14 @@ public async Task SendBatchAsync_200_SendsGzippedPayloadWithCorrectHeaders() byte[] capturedBody = null; string capturedKey = null; 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}", onRequest: req => { capturedKey = string.Join("", req.Headers.GetValues("x-immutable-publishable-key")); capturedContentType = req.Content.Headers.ContentType.MediaType; + capturedContentEncoding = string.Join("", req.Content.Headers.ContentEncoding); capturedBody = req.Content.ReadAsByteArrayAsync().Result; }); using var transport = new HttpTransport(_store, "pk_imapik-test-key1", handler: handler); @@ -79,12 +84,43 @@ public async Task SendBatchAsync_200_SendsGzippedPayloadWithCorrectHeaders() Assert.AreEqual("pk_imapik-test-key1", capturedKey); Assert.AreEqual("application/json", capturedContentType); + Assert.AreEqual("gzip", capturedContentEncoding); var decompressed = DecompressGzip(capturedBody); StringAssert.StartsWith("{\"batch\":[", decompressed); StringAssert.EndsWith("]}", decompressed); StringAssert.Contains("\"eventName\":\"test\"", decompressed); } +#else + [Test] + public async Task SendBatchAsync_200_SendsPlainJsonPayloadWithoutContentEncoding() + { + _store.Write("{\"type\":\"track\",\"eventName\":\"test\"}"); + + string capturedKey = null; + string capturedContentType = null; + int capturedContentEncodingCount = -1; + string capturedBody = null; + var handler = new MockHandler(HttpStatusCode.OK, "{\"accepted\":1,\"rejected\":0}", + onRequest: req => + { + capturedKey = string.Join("", req.Headers.GetValues("x-immutable-publishable-key")); + capturedContentType = req.Content.Headers.ContentType.MediaType; + capturedContentEncodingCount = req.Content.Headers.ContentEncoding.Count; + capturedBody = req.Content.ReadAsStringAsync().Result; + }); + using var transport = new HttpTransport(_store, "pk_imapik-test-key1", handler: handler); + + await transport.SendBatchAsync(); + + Assert.AreEqual("pk_imapik-test-key1", capturedKey); + Assert.AreEqual("application/json", capturedContentType); + Assert.AreEqual(0, capturedContentEncodingCount, "no Content-Encoding header is permitted in v1"); + StringAssert.StartsWith("{\"batch\":[", capturedBody); + StringAssert.EndsWith("]}", capturedBody); + StringAssert.Contains("\"eventName\":\"test\"", capturedBody); + } +#endif [Test] public async Task SendBatchAsync_200_UsesCorrectUrlForTestKey() @@ -342,6 +378,7 @@ public async Task SendBatchAsync_ErrorCallbackThrows_DoesNotCrash() Assert.DoesNotThrowAsync(() => transport.SendBatchAsync()); } +#if IMMUTABLE_AUDIENCE_GZIP private static string DecompressGzip(byte[] data) { using var input = new MemoryStream(data); @@ -349,11 +386,10 @@ private static string DecompressGzip(byte[] data) using var reader = new StreamReader(gzip, Encoding.UTF8); return reader.ReadToEnd(); } +#endif - /// - /// Minimal HttpMessageHandler that returns a canned response. - /// Optionally captures the request for inspection. - /// + // Minimal HttpMessageHandler that returns a canned response. + // Optionally captures the request for inspection. private class MockHandler : HttpMessageHandler { private readonly Func _factory; diff --git a/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs new file mode 100644 index 000000000..839feb0b6 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Utility/JsonReaderTests.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + public class JsonReaderTests + { + [Test] + public void EmptyObject() + { + var result = JsonReader.DeserializeObject("{}"); + Assert.AreEqual(0, result.Count); + } + + [Test] + public void StringValue() + { + var result = JsonReader.DeserializeObject("{\"key\":\"hello\"}"); + Assert.AreEqual("hello", result["key"]); + } + + [Test] + public void StringWithEscapes() + { + var result = JsonReader.DeserializeObject("{\"val\":\"say \\\"hi\\\"\\nback\\\\slash\\ttab\"}"); + Assert.AreEqual("say \"hi\"\nback\\slash\ttab", result["val"]); + } + + [Test] + public void IntAndLong() + { + var result = JsonReader.DeserializeObject("{\"small\":42,\"big\":12345678901234}"); + Assert.AreEqual(42, result["small"]); + Assert.AreEqual(12345678901234L, result["big"]); + } + + [Test] + public void BoolAndNull() + { + var result = JsonReader.DeserializeObject("{\"t\":true,\"f\":false,\"n\":null}"); + Assert.AreEqual(true, result["t"]); + Assert.AreEqual(false, result["f"]); + Assert.IsNull(result["n"]); + } + + [Test] + public void NestedObject() + { + var result = JsonReader.DeserializeObject("{\"outer\":{\"inner\":\"value\"}}"); + var inner = (Dictionary)result["outer"]; + Assert.AreEqual("value", inner["inner"]); + } + + [Test] + public void Array() + { + var result = JsonReader.DeserializeObject("{\"arr\":[1,\"two\",true,null]}"); + var arr = (List)result["arr"]; + Assert.AreEqual(4, arr.Count); + Assert.AreEqual(1, arr[0]); + Assert.AreEqual("two", arr[1]); + Assert.AreEqual(true, arr[2]); + Assert.IsNull(arr[3]); + } + + [Test] + public void RoundTripViaSerializer() + { + var original = new Dictionary + { + ["type"] = "track", + ["eventName"] = "progression", + ["properties"] = new Dictionary + { + ["status"] = "complete", + ["score"] = 1500 + }, + ["anonymousId"] = "abc", + ["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("complete", props["status"]); + Assert.AreEqual(1500, props["score"]); + } + + [Test] + public void MalformedThrows() + { + Assert.Throws(() => JsonReader.DeserializeObject("{not valid}")); + Assert.Throws(() => JsonReader.DeserializeObject("{\"a\":}")); + Assert.Throws(() => JsonReader.DeserializeObject("{\"a\":\"unterminated")); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/Utility/LogTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/LogTests.cs new file mode 100644 index 000000000..42a3cda0c --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Utility/LogTests.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class LogTests + { + private List _captured; + + [SetUp] + public void SetUp() + { + _captured = new List(); + Log.Writer = line => _captured.Add(line); + Log.Enabled = false; + } + + [TearDown] + public void TearDown() + { + Log.Writer = null; + Log.Enabled = false; + } + + [Test] + public void Debug_WhenDisabled_EmitsNothing() + { + Log.Enabled = false; + + Log.Debug("silent"); + + Assert.AreEqual(0, _captured.Count); + } + + [Test] + public void Debug_WhenEnabled_EmitsWithPrefix() + { + Log.Enabled = true; + + Log.Debug("hello"); + + Assert.AreEqual(1, _captured.Count); + StringAssert.StartsWith("[ImmutableAudience]", _captured[0]); + StringAssert.Contains("hello", _captured[0]); + } + + [Test] + public void Warn_AlwaysEmits_EvenWhenDisabled() + { + Log.Enabled = false; + + Log.Warn("something off"); + + Assert.AreEqual(1, _captured.Count); + StringAssert.Contains("WARN", _captured[0]); + StringAssert.Contains("something off", _captured[0]); + } + } +}