diff --git a/src/Packages/Audience/.gitignore b/src/Packages/Audience/.gitignore new file mode 100644 index 000000000..e660fd93d --- /dev/null +++ b/src/Packages/Audience/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/src/Packages/Audience/Runtime/AssemblyInfo.cs b/src/Packages/Audience/Runtime/AssemblyInfo.cs index b3806e91b..5f1a3ea03 100644 --- a/src/Packages/Audience/Runtime/AssemblyInfo.cs +++ b/src/Packages/Audience/Runtime/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Immutable.Audience.Runtime.Tests")] +[assembly: InternalsVisibleTo("Immutable.Audience.Unity")] diff --git a/src/Packages/Audience/Runtime/Audience.Runtime.csproj b/src/Packages/Audience/Runtime/Audience.Runtime.csproj index 388f1006b..7fc7f1620 100644 --- a/src/Packages/Audience/Runtime/Audience.Runtime.csproj +++ b/src/Packages/Audience/Runtime/Audience.Runtime.csproj @@ -7,8 +7,12 @@ Immutable.Audience.Runtime + + + diff --git a/src/Packages/Audience/Runtime/Core/ConsentStore.cs b/src/Packages/Audience/Runtime/Core/ConsentStore.cs new file mode 100644 index 000000000..8701a56e2 --- /dev/null +++ b/src/Packages/Audience/Runtime/Core/ConsentStore.cs @@ -0,0 +1,52 @@ +#nullable enable + +using System; +using System.IO; + +namespace Immutable.Audience +{ + internal static class ConsentStore + { + internal static void Save(string persistentDataPath, ConsentLevel level) + { + Directory.CreateDirectory(AudiencePaths.AudienceDir(persistentDataPath)); + + var filePath = AudiencePaths.ConsentFile(persistentDataPath); + var tmpPath = filePath + ".tmp"; + + File.WriteAllText(tmpPath, ((int)level).ToString()); + + try + { + File.Move(tmpPath, filePath); + } + catch (IOException) + { + File.Delete(filePath); + File.Move(tmpPath, filePath); + } + } + + // Returns null on missing/malformed/unreadable file; caller falls back to config default. + internal static ConsentLevel? Load(string persistentDataPath) + { + try + { + var filePath = AudiencePaths.ConsentFile(persistentDataPath); + if (!File.Exists(filePath)) return null; + + var text = File.ReadAllText(filePath).Trim(); + if (int.TryParse(text, out var raw) && Enum.IsDefined(typeof(ConsentLevel), raw)) + return (ConsentLevel)raw; + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + return null; + } + } +} diff --git a/src/Packages/Audience/Runtime/Core/Constants.cs b/src/Packages/Audience/Runtime/Core/Constants.cs index a7ed09792..9cb2a6de0 100644 --- a/src/Packages/Audience/Runtime/Core/Constants.cs +++ b/src/Packages/Audience/Runtime/Core/Constants.cs @@ -1,3 +1,5 @@ +#nullable enable + namespace Immutable.Audience { internal static class Constants @@ -14,17 +16,41 @@ internal static class Constants internal const int DefaultFlushSize = 20; internal const int MaxBatchSize = 100; internal const int StaleEventDays = 30; + internal const int MaxFieldLength = 256; // Backend schema limit. internal const string LibraryName = "com.immutable.audience"; + internal const string LibraryVersion = "0.1.0"; internal const string Surface = "unity"; internal const string ConsentSource = "UnitySDK"; - internal static string BaseUrl(string publishableKey) => + 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 BaseUrl(string? publishableKey) => publishableKey != null && publishableKey.StartsWith(TestKeyPrefix) ? SandboxBaseUrl : ProductionBaseUrl; } + // Message type values written to (and read back from) the "type" field. + internal static class MessageTypes + { + internal const string Track = "track"; + internal const string Identify = "identify"; + internal const string Alias = "alias"; + } + + // Wire-format field names that cross module boundaries inside the SDK + // (read by one module, written by another). + internal static class MessageFields + { + internal const string Type = "type"; + internal const string UserId = "userId"; + } + // Common distribution platform values for AudienceConfig.DistributionPlatform. public static class DistributionPlatforms { diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs index 2b1143d38..8ae1f6778 100644 --- a/src/Packages/Audience/Runtime/Core/Identity.cs +++ b/src/Packages/Audience/Runtime/Core/Identity.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using System.IO; @@ -12,13 +14,53 @@ namespace Immutable.Audience internal sealed class Identity { // In-memory cache — volatile so background threads always see the latest write. - private static volatile string _cachedId; + private static volatile string? _cachedId; private static readonly object _sync = new object(); + // Returns the existing anonymous ID, or null if none exists. + // Unlike GetOrCreate, never generates or persists a new one. + internal static string? Get(string persistentDataPath) + { + if (_cachedId != null) return _cachedId; + + lock (_sync) + { + if (_cachedId != null) return _cachedId; + + try + { + var filePath = AudiencePaths.IdentityFile(persistentDataPath); + if (!File.Exists(filePath)) return null; + + _cachedId = File.ReadAllText(filePath).Trim(); + return _cachedId; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + } + + // Drops the in-memory cache without touching disk. Called on + // Shutdown/ResetState so a subsequent Init with a different + // persistentDataPath re-reads the file from the new location. + internal static void ClearCache() + { + lock (_sync) + { + _cachedId = null; + } + } + // 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. - internal static string GetOrCreate(string persistentDataPath, ConsentLevel consent) + internal static string? GetOrCreate(string persistentDataPath, ConsentLevel consent) { // No ID until the player grants at least anonymous consent. if (consent == ConsentLevel.None) diff --git a/src/Packages/Audience/Runtime/Events/IEvent.cs b/src/Packages/Audience/Runtime/Events/IEvent.cs new file mode 100644 index 000000000..0ef5a28d0 --- /dev/null +++ b/src/Packages/Audience/Runtime/Events/IEvent.cs @@ -0,0 +1,13 @@ +#nullable enable + +using System.Collections.Generic; + +namespace Immutable.Audience +{ + // Typed event contract for ImmutableAudience.Track(IEvent). + public interface IEvent + { + string EventName { get; } + Dictionary ToProperties(); + } +} \ No newline at end of file diff --git a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs index efc3ec396..1347e4665 100644 --- a/src/Packages/Audience/Runtime/Events/MessageBuilder.cs +++ b/src/Packages/Audience/Runtime/Events/MessageBuilder.cs @@ -1,3 +1,5 @@ +#nullable enable + using System; using System.Collections.Generic; @@ -7,46 +9,52 @@ internal static class MessageBuilder { internal static Dictionary Track( string eventName, - string anonymousId, - string userId, + string? anonymousId, + string? userId, string packageVersion, - Dictionary properties = null) + Dictionary? properties = null) { - var msg = BuildBase("track", packageVersion); - msg["eventName"] = Truncate(eventName, 256); + var msg = BuildBase(MessageTypes.Track, packageVersion); + msg["eventName"] = Truncate(eventName, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(anonymousId)) - msg["anonymousId"] = Truncate(anonymousId, 256); + msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(userId)) - msg["userId"] = Truncate(userId, 256); + msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength); if (properties != null && properties.Count > 0) + { + TruncateStringValues(properties); msg["properties"] = properties; + } return msg; } internal static Dictionary Identify( - string anonymousId, - string userId, - string identityType, + string? anonymousId, + string? userId, + string? identityType, string packageVersion, - Dictionary traits = null) + Dictionary? traits = null) { - var msg = BuildBase("identify", packageVersion); + var msg = BuildBase(MessageTypes.Identify, packageVersion); if (!string.IsNullOrEmpty(anonymousId)) - msg["anonymousId"] = Truncate(anonymousId, 256); + msg["anonymousId"] = Truncate(anonymousId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(userId)) - msg["userId"] = Truncate(userId, 256); + msg[MessageFields.UserId] = Truncate(userId, Constants.MaxFieldLength); if (!string.IsNullOrEmpty(identityType)) - msg["identityType"] = Truncate(identityType, 256); + msg["identityType"] = Truncate(identityType, Constants.MaxFieldLength); if (traits != null && traits.Count > 0) + { + TruncateStringValues(traits); msg["traits"] = traits; + } return msg; } @@ -58,11 +66,11 @@ internal static Dictionary Alias( string toType, string packageVersion) { - var msg = BuildBase("alias", packageVersion); - msg["fromId"] = Truncate(fromId, 256); - msg["fromType"] = Truncate(fromType, 256); - msg["toId"] = Truncate(toId, 256); - msg["toType"] = Truncate(toType, 256); + 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); return msg; } @@ -70,13 +78,13 @@ private static Dictionary BuildBase(string type, string packageV { return new Dictionary { - ["type"] = type, + [MessageFields.Type] = type, ["messageId"] = Guid.NewGuid().ToString(), ["eventTimestamp"] = DateTime.UtcNow.ToString("o"), ["context"] = new Dictionary { ["library"] = Constants.LibraryName, - ["libraryVersion"] = Truncate(packageVersion, 256) + ["libraryVersion"] = Truncate(packageVersion, Constants.MaxFieldLength) }, ["surface"] = Constants.Surface }; @@ -84,9 +92,20 @@ private static Dictionary BuildBase(string type, string packageV private static string Truncate(string s, int maxLen) { - if (s == null || s.Length <= maxLen) + if (s.Length <= maxLen) return s; return s.Substring(0, maxLen); } + + private static void TruncateStringValues(Dictionary dict) + { + // Snapshot keys to avoid mutating the collection during iteration. + var keys = new List(dict.Keys); + foreach (var key in keys) + { + if (dict[key] is string s && s.Length > Constants.MaxFieldLength) + dict[key] = Truncate(s, Constants.MaxFieldLength); + } + } } } diff --git a/src/Packages/Audience/Runtime/Events/TypedEvents.cs b/src/Packages/Audience/Runtime/Events/TypedEvents.cs new file mode 100644 index 000000000..36d35d2e0 --- /dev/null +++ b/src/Packages/Audience/Runtime/Events/TypedEvents.cs @@ -0,0 +1,180 @@ +#nullable enable + +using System; +using System.Collections.Generic; + +namespace Immutable.Audience +{ + // Progression event state. + public enum ProgressionStatus + { + Start, + Complete, + Fail + } + + internal static class ProgressionStatusExtensions + { + // Throws on unknown casts. Progression.ToProperties propagates, and + // Track(IEvent) catches + drops with a warning. + internal static string ToLowercaseString(this ProgressionStatus status) => status switch + { + ProgressionStatus.Start => "start", + ProgressionStatus.Complete => "complete", + ProgressionStatus.Fail => "fail", + _ => throw new ArgumentOutOfRangeException( + nameof(status), status, "Unhandled ProgressionStatus"), + }; + } + + // Player progressing through a world / level / stage. + public class Progression : IEvent + { + // Required. + public ProgressionStatus Status { get; set; } + // Optional. + public string? World { get; set; } + public string? Level { get; set; } + public string? Stage { get; set; } + public int? Score { get; set; } + public float? DurationSec { get; set; } + + public string EventName => "progression"; + + public Dictionary ToProperties() + { + var props = new Dictionary + { + ["status"] = Status.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; + + return props; + } + } + + // Resource flow direction. + public enum ResourceFlow + { + Source, + Sink + } + + internal static class ResourceFlowExtensions + { + // Throws on unknown casts. Resource.ToProperties propagates, and + // Track(IEvent) catches + drops with a warning. + internal static string ToLowercaseString(this ResourceFlow flow) => flow switch + { + ResourceFlow.Source => "source", + ResourceFlow.Sink => "sink", + _ => throw new ArgumentOutOfRangeException( + nameof(flow), flow, "Unhandled ResourceFlow"), + }; + } + + // In-game currency earned or spent. + public class Resource : IEvent + { + // Required. + public ResourceFlow Flow { get; set; } + public string? Currency { get; set; } + public float Amount { get; set; } + // Optional. + public string? ItemType { get; set; } + public string? ItemId { get; set; } + + public string EventName => "resource"; + + public Dictionary ToProperties() + { + if (string.IsNullOrEmpty(Currency)) + throw new ArgumentException("Resource.Currency must not be null or empty"); + + var props = new Dictionary + { + ["flow"] = Flow.ToLowercaseString(), + ["currency"] = Currency, + ["amount"] = Amount + }; + + if (ItemType != null) props["itemType"] = ItemType; + if (ItemId != null) props["itemId"] = ItemId; + + return props; + } + } + + // Real-money transaction. + public class Purchase : IEvent + { + // Required. ISO 4217 three-letter uppercase currency code. + public string? Currency { get; set; } + // Required. + public decimal Value { get; set; } + // Optional. + public string? ItemId { get; set; } + public string? ItemName { get; set; } + public int? Quantity { get; set; } + public string? TransactionId { get; set; } + + public string EventName => "purchase"; + + // Hand-rolled to avoid pulling System.Text.RegularExpressions into the IL2CPP build. + private static bool IsIso4217(string s) + { + if (s.Length != 3) return false; + for (var i = 0; i < 3; i++) + { + var c = s[i]; + if (c < 'A' || c > 'Z') return false; + } + return true; + } + + public Dictionary ToProperties() + { + if (Currency == null || !IsIso4217(Currency)) + throw new ArgumentException( + $"Purchase.Currency '{Currency}' must be a three-letter uppercase ISO 4217 code"); + + var props = new Dictionary + { + ["currency"] = Currency, + ["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; + + return props; + } + } + + // Named milestone or achievement. + public class MilestoneReached : IEvent + { + // Required. + public string? Name { get; set; } + + public string EventName => "milestone_reached"; + + public Dictionary ToProperties() + { + if (string.IsNullOrEmpty(Name)) + throw new ArgumentException("MilestoneReached.Name must not be null or empty"); + + return new Dictionary + { + ["name"] = Name + }; + } + } +} diff --git a/src/Packages/Audience/Runtime/IdentityType.cs b/src/Packages/Audience/Runtime/IdentityType.cs new file mode 100644 index 000000000..eed0312ea --- /dev/null +++ b/src/Packages/Audience/Runtime/IdentityType.cs @@ -0,0 +1,36 @@ +#nullable enable + +namespace Immutable.Audience +{ + // Identity provider accepted by the Audience backend. + public enum IdentityType + { + Passport, + Steam, + Epic, + Google, + Apple, + Discord, + Email, + Custom, + } + + internal static class IdentityTypeExtensions + { + // Returns null on unknown casts. The string overloads of Identify / + // Alias check for null/empty and drop + warn, so an out-of-range + // cast surfaces as a dropped event, not a corrupt wire payload. + internal static string? ToLowercaseString(this IdentityType type) => type switch + { + IdentityType.Passport => "passport", + IdentityType.Steam => "steam", + IdentityType.Epic => "epic", + IdentityType.Google => "google", + IdentityType.Apple => "apple", + IdentityType.Discord => "discord", + IdentityType.Email => "email", + IdentityType.Custom => "custom", + _ => null, + }; + } +} diff --git a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs index 7590e4229..67750ccb7 100644 --- a/src/Packages/Audience/Runtime/Transport/HttpTransport.cs +++ b/src/Packages/Audience/Runtime/Transport/HttpTransport.cs @@ -38,7 +38,7 @@ internal HttpTransport( { _store = store ?? throw new ArgumentNullException(nameof(store)); _publishableKey = publishableKey ?? throw new ArgumentNullException(nameof(publishableKey)); - _url = Constants.BaseUrl(publishableKey) + Constants.MessagesPath; + _url = Constants.MessagesUrl(publishableKey); _onError = onError; _client = handler != null ? new HttpClient(handler) : new HttpClient(); _client.Timeout = TimeSpan.FromSeconds(30); @@ -78,7 +78,7 @@ internal async Task SendBatchAsync(CancellationToken ct = default) try { using var request = new HttpRequestMessage(HttpMethod.Post, _url); - request.Headers.Add("x-immutable-publishable-key", _publishableKey); + request.Headers.Add(Constants.PublishableKeyHeader, _publishableKey); #if IMMUTABLE_AUDIENCE_GZIP var compressed = Gzip.Compress(payload); request.Content = new ByteArrayContent(compressed); diff --git a/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs new file mode 100644 index 000000000..b0af0ea8d --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/ConstantsTests.cs @@ -0,0 +1,33 @@ +using System.IO; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class ConstantsTests + { + [Test] + public void LibraryVersion_MatchesPackageJson() + { + // Fails the build if Constants.LibraryVersion and package.json + // "version" drift. context.libraryVersion on every outgoing event + // depends on them agreeing. + var packageJson = ReadPackageJson(); + var parsed = JsonReader.DeserializeObject(packageJson); + + Assert.IsTrue(parsed.TryGetValue("version", out var versionObj), + "package.json is missing a \"version\" field"); + Assert.AreEqual(Constants.LibraryVersion, versionObj, + "Constants.LibraryVersion must match package.json version"); + } + + private static string ReadPackageJson() + { + var testDir = TestContext.CurrentContext.TestDirectory; + // Tests/bin/Debug/net8.0/ → Tests/ → Audience/package.json + var packagePath = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", "package.json")); + Assert.IsTrue(File.Exists(packagePath), $"package.json not found at {packagePath}"); + return File.ReadAllText(packagePath); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/Core/ConsentStoreTests.cs b/src/Packages/Audience/Tests/Runtime/Core/ConsentStoreTests.cs new file mode 100644 index 000000000..85713b777 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Core/ConsentStoreTests.cs @@ -0,0 +1,70 @@ +using System.IO; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class ConsentStoreTests + { + private string _testDir; + + [SetUp] + public void SetUp() + { + _testDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_testDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + [Test] + public void Load_NoFile_ReturnsNull() + { + Assert.IsNull(ConsentStore.Load(_testDir)); + } + + [Test] + public void SaveThenLoad_RoundtripsValue([Values] ConsentLevel level) + { + ConsentStore.Save(_testDir, level); + Assert.AreEqual(level, ConsentStore.Load(_testDir)); + } + + [Test] + public void Save_OverwritesPreviousValue() + { + ConsentStore.Save(_testDir, ConsentLevel.Anonymous); + ConsentStore.Save(_testDir, ConsentLevel.Full); + + Assert.AreEqual(ConsentLevel.Full, ConsentStore.Load(_testDir)); + } + + [Test] + public void Load_MalformedFile_ReturnsNull() + { + // A garbage value that isn't a valid enum int. + var dir = AudiencePaths.AudienceDir(_testDir); + Directory.CreateDirectory(dir); + File.WriteAllText(AudiencePaths.ConsentFile(_testDir), "not-an-int"); + + Assert.IsNull(ConsentStore.Load(_testDir)); + } + + [Test] + public void Load_OutOfRangeIntInFile_ReturnsNull() + { + // 999 is parseable as int but not a defined ConsentLevel. + var dir = AudiencePaths.AudienceDir(_testDir); + Directory.CreateDirectory(dir); + File.WriteAllText(AudiencePaths.ConsentFile(_testDir), "999"); + + Assert.IsNull(ConsentStore.Load(_testDir)); + } + + } +} diff --git a/src/Packages/Audience/Tests/Runtime/IdentityTests.cs b/src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs similarity index 70% rename from src/Packages/Audience/Tests/Runtime/IdentityTests.cs rename to src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs index 1518d62d6..cd2cf5fd6 100644 --- a/src/Packages/Audience/Tests/Runtime/IdentityTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Core/IdentityTests.cs @@ -31,7 +31,7 @@ public void NewDirectory_GeneratesNonEmptyId_AndWritesFile() Assert.IsNotNull(id); Assert.IsNotEmpty(id); - var filePath = Path.Combine(_testDir, "imtbl_audience", "identity"); + var filePath = AudiencePaths.IdentityFile(_testDir); Assert.IsTrue(File.Exists(filePath), "identity file should exist on disk"); } @@ -40,9 +40,9 @@ public void ExistingFile_ReturnsPreviousId_WithoutGeneratingNew() { // Simulate a returning player by pre-writing an identity file (as a previous launch would have done). var expectedId = "pre-existing-id-from-last-launch"; - var dir = Path.Combine(_testDir, "imtbl_audience"); + var dir = AudiencePaths.AudienceDir(_testDir); Directory.CreateDirectory(dir); - File.WriteAllText(Path.Combine(dir, "identity"), expectedId); + File.WriteAllText(AudiencePaths.IdentityFile(_testDir), expectedId); var result = Identity.GetOrCreate(_testDir, ConsentLevel.Anonymous); @@ -76,8 +76,29 @@ public void ConsentNone_ReturnsNull_AndNoFileWritten() Assert.IsNull(id); - var filePath = Path.Combine(_testDir, "imtbl_audience", "identity"); + var filePath = AudiencePaths.IdentityFile(_testDir); Assert.IsFalse(File.Exists(filePath), "identity file must not be written when consent is None"); } + + [Test] + public void Get_NoExistingFile_ReturnsNull_AndDoesNotCreate() + { + var id = Identity.Get(_testDir); + + Assert.IsNull(id); + var filePath = AudiencePaths.IdentityFile(_testDir); + Assert.IsFalse(File.Exists(filePath), "Get must not create the identity file"); + } + + [Test] + public void Get_ExistingFile_ReturnsPersistedId() + { + var expectedId = "pre-existing-id"; + var dir = AudiencePaths.AudienceDir(_testDir); + Directory.CreateDirectory(dir); + File.WriteAllText(AudiencePaths.IdentityFile(_testDir), expectedId); + + Assert.AreEqual(expectedId, Identity.Get(_testDir)); + } } } diff --git a/src/Packages/Audience/Tests/Runtime/MessageBuilderTests.cs b/src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs similarity index 100% rename from src/Packages/Audience/Tests/Runtime/MessageBuilderTests.cs rename to src/Packages/Audience/Tests/Runtime/Events/MessageBuilderTests.cs diff --git a/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs b/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs new file mode 100644 index 000000000..67c4ad861 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs @@ -0,0 +1,132 @@ +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class TypedEventTests + { + [Test] + public void Progression_EventName_IsProgression() + { + Assert.AreEqual("progression", new Progression().EventName); + } + + [Test] + public void Progression_Complete_ProducesCorrectProperties() + { + var evt = new Progression + { + Status = ProgressionStatus.Complete, + World = "tutorial", + Level = "1", + Score = 1500, + DurationSec = 120.5f + }; + + var props = evt.ToProperties(); + + Assert.AreEqual("complete", props["status"]); + Assert.AreEqual("tutorial", props["world"]); + Assert.AreEqual("1", props["level"]); + Assert.AreEqual(1500, props["score"]); + Assert.AreEqual(120.5f, props["durationSec"]); + } + + [Test] + public void Progression_OptionalFieldsOmitted_WhenNull() + { + var props = new Progression { Status = ProgressionStatus.Start }.ToProperties(); + + Assert.IsTrue(props.ContainsKey("status")); + Assert.IsFalse(props.ContainsKey("world")); + Assert.IsFalse(props.ContainsKey("level")); + Assert.IsFalse(props.ContainsKey("stage")); + Assert.IsFalse(props.ContainsKey("score")); + Assert.IsFalse(props.ContainsKey("durationSec")); + } + + [Test] + public void Resource_Source_ProducesCorrectProperties() + { + var evt = new Resource + { + Flow = ResourceFlow.Source, + Currency = "gold", + Amount = 100, + ItemType = "quest_reward", + ItemId = "main_quest_01" + }; + + var props = evt.ToProperties(); + + Assert.AreEqual("source", props["flow"]); + Assert.AreEqual("gold", props["currency"]); + Assert.AreEqual(100m, props["amount"]); + Assert.AreEqual("quest_reward", props["itemType"]); + Assert.AreEqual("main_quest_01", props["itemId"]); + } + + [Test] + public void Resource_EventName_IsResource() + { + Assert.AreEqual("resource", new Resource().EventName); + } + + [Test] + public void Purchase_ProducesCorrectProperties() + { + var evt = new Purchase + { + Currency = "USD", + Value = 9.99m, + ItemId = "gem_pack_01", + ItemName = "Starter Gem Pack", + Quantity = 1, + TransactionId = "txn_abc123" + }; + + var props = evt.ToProperties(); + + Assert.AreEqual("USD", props["currency"]); + Assert.AreEqual(9.99m, props["value"]); + Assert.AreEqual("gem_pack_01", props["itemId"]); + Assert.AreEqual("Starter Gem Pack", props["itemName"]); + Assert.AreEqual(1, props["quantity"]); + Assert.AreEqual("txn_abc123", props["transactionId"]); + } + + [Test] + public void Purchase_OptionalFieldsOmitted_WhenNull() + { + var props = new Purchase { Currency = "EUR", Value = 5.00m }.ToProperties(); + + Assert.IsTrue(props.ContainsKey("currency")); + Assert.IsTrue(props.ContainsKey("value")); + Assert.IsFalse(props.ContainsKey("itemId")); + Assert.IsFalse(props.ContainsKey("itemName")); + Assert.IsFalse(props.ContainsKey("quantity")); + Assert.IsFalse(props.ContainsKey("transactionId")); + } + + [Test] + public void Purchase_EventName_IsPurchase() + { + Assert.AreEqual("purchase", new Purchase().EventName); + } + + [Test] + public void MilestoneReached_ProducesCorrectProperties() + { + var props = new MilestoneReached { Name = "first_boss_defeated" }.ToProperties(); + + Assert.AreEqual("first_boss_defeated", props["name"]); + Assert.AreEqual(1, props.Count); + } + + [Test] + public void MilestoneReached_EventName_IsMilestoneReached() + { + Assert.AreEqual("milestone_reached", new MilestoneReached().EventName); + } + } +} \ No newline at end of file diff --git a/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs b/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs new file mode 100644 index 000000000..7b2ed71c7 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/IdentityTypeTests.cs @@ -0,0 +1,32 @@ +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class IdentityTypeTests + { + [TestCase(IdentityType.Passport, "passport")] + [TestCase(IdentityType.Steam, "steam")] + [TestCase(IdentityType.Epic, "epic")] + [TestCase(IdentityType.Google, "google")] + [TestCase(IdentityType.Apple, "apple")] + [TestCase(IdentityType.Discord, "discord")] + [TestCase(IdentityType.Email, "email")] + [TestCase(IdentityType.Custom, "custom")] + public void ToLowercaseString_MapsEachEnumValueToLowercaseBackendString(IdentityType type, string expected) + { + Assert.AreEqual(expected, type.ToLowercaseString()); + } + + [Test] + public void ToLowercaseString_UnknownValue_ReturnsNull() + { + // Never-throw contract: an out-of-range cast should not surface an + // exception on the game thread. Callers drop the event via their + // null/empty check instead. + var invalid = (IdentityType)999; + + Assert.IsNull(invalid.ToLowercaseString()); + } + } +} diff --git a/src/Packages/Audience/Tests/Runtime/DiskStoreTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs similarity index 100% rename from src/Packages/Audience/Tests/Runtime/DiskStoreTests.cs rename to src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs diff --git a/src/Packages/Audience/Tests/Runtime/DiskStoreTests.cs.meta b/src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs.meta similarity index 100% rename from src/Packages/Audience/Tests/Runtime/DiskStoreTests.cs.meta rename to src/Packages/Audience/Tests/Runtime/Transport/DiskStoreTests.cs.meta diff --git a/src/Packages/Audience/Tests/Runtime/EventQueueTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs similarity index 100% rename from src/Packages/Audience/Tests/Runtime/EventQueueTests.cs rename to src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs diff --git a/src/Packages/Audience/Tests/Runtime/EventQueueTests.cs.meta b/src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs.meta similarity index 100% rename from src/Packages/Audience/Tests/Runtime/EventQueueTests.cs.meta rename to src/Packages/Audience/Tests/Runtime/Transport/EventQueueTests.cs.meta diff --git a/src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs b/src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs similarity index 100% rename from src/Packages/Audience/Tests/Runtime/HttpTransportTests.cs rename to src/Packages/Audience/Tests/Runtime/Transport/HttpTransportTests.cs diff --git a/src/Packages/Audience/Tests/Runtime/GzipTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs similarity index 100% rename from src/Packages/Audience/Tests/Runtime/GzipTests.cs rename to src/Packages/Audience/Tests/Runtime/Utility/GzipTests.cs diff --git a/src/Packages/Audience/Tests/Runtime/JsonTests.cs b/src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs similarity index 100% rename from src/Packages/Audience/Tests/Runtime/JsonTests.cs rename to src/Packages/Audience/Tests/Runtime/Utility/JsonTests.cs