diff --git a/src/Packages/Audience/Runtime/Events/TypedEvents.cs b/src/Packages/Audience/Runtime/Events/TypedEvents.cs index 36d35d2e0..5f137b30c 100644 --- a/src/Packages/Audience/Runtime/Events/TypedEvents.cs +++ b/src/Packages/Audience/Runtime/Events/TypedEvents.cs @@ -30,8 +30,9 @@ internal static class ProgressionStatusExtensions // Player progressing through a world / level / stage. public class Progression : IEvent { - // Required. - public ProgressionStatus Status { get; set; } + // Required. Nullable so an unset caller produces a clear validation + // error at send time instead of silently shipping the enum default. + public ProgressionStatus? Status { get; set; } // Optional. public string? World { get; set; } public string? Level { get; set; } @@ -43,9 +44,12 @@ public class Progression : IEvent public Dictionary ToProperties() { + if (Status is null) + throw new ArgumentException("Progression.Status is required — set it before calling Track(IEvent)"); + var props = new Dictionary { - ["status"] = Status.ToLowercaseString() + ["status"] = Status.Value.ToLowercaseString() }; if (World != null) props["world"] = World; @@ -81,10 +85,12 @@ internal static class ResourceFlowExtensions // In-game currency earned or spent. public class Resource : IEvent { - // Required. - public ResourceFlow Flow { get; set; } + // Required. Nullable so an unset caller produces a clear validation + // error at send time instead of silently shipping the enum / zero + // default. + public ResourceFlow? Flow { get; set; } public string? Currency { get; set; } - public float Amount { get; set; } + public float? Amount { get; set; } // Optional. public string? ItemType { get; set; } public string? ItemId { get; set; } @@ -93,14 +99,18 @@ public class Resource : IEvent public Dictionary ToProperties() { + if (Flow is null) + throw new ArgumentException("Resource.Flow is required — set it before calling Track(IEvent)"); if (string.IsNullOrEmpty(Currency)) - throw new ArgumentException("Resource.Currency must not be null or empty"); + throw new ArgumentException("Resource.Currency is required — set a non-empty string before calling Track(IEvent)"); + if (Amount is null) + throw new ArgumentException("Resource.Amount is required — set it before calling Track(IEvent)"); var props = new Dictionary { - ["flow"] = Flow.ToLowercaseString(), + ["flow"] = Flow.Value.ToLowercaseString(), ["currency"] = Currency, - ["amount"] = Amount + ["amount"] = Amount.Value }; if (ItemType != null) props["itemType"] = ItemType; @@ -115,8 +125,10 @@ public class Purchase : IEvent { // Required. ISO 4217 three-letter uppercase currency code. public string? Currency { get; set; } - // Required. - public decimal Value { get; set; } + // Required. Nullable so an unset caller produces a clear validation + // error at send time instead of silently shipping a zero-value + // purchase that breaks attribution and conversion reporting. + public decimal? Value { get; set; } // Optional. public string? ItemId { get; set; } public string? ItemName { get; set; } @@ -142,11 +154,13 @@ public Dictionary ToProperties() if (Currency == null || !IsIso4217(Currency)) throw new ArgumentException( $"Purchase.Currency '{Currency}' must be a three-letter uppercase ISO 4217 code"); + if (Value is null) + throw new ArgumentException("Purchase.Value is required — set it before calling Track(IEvent)"); var props = new Dictionary { ["currency"] = Currency, - ["value"] = Value + ["value"] = Value.Value }; if (ItemId != null) props["itemId"] = ItemId; diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index ae29c3f28..64930f5af 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -138,11 +138,14 @@ public static void Track(IEvent evt) // Send a custom event. // - // For predefined event names (e.g. purchase), prefer the typed - // overload Track(new Purchase { ... }) — it enforces required fields - // and value types at compile time. This overload does not validate - // property shapes, so missing or mistyped fields can break - // attribution/conversion reporting. + // For predefined event names (e.g. purchase, progression, resource, + // milestone_reached), prefer the typed overload — + // Track(new Purchase { Currency = "USD", Value = 9.99m }) — which + // validates required fields at send time. This overload accepts any + // property shape and does not: Track("purchase", new Dictionary...) + // that omits currency or value still enqueues and ships, but breaks + // attribution and conversion reporting downstream because the + // payload is missing the fields CDP needs to reconstruct the event. public static void Track(string eventName, Dictionary? properties = null) { if (!CanTrack()) return; diff --git a/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs b/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs index 67c4ad861..7f4a1a250 100644 --- a/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs +++ b/src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs @@ -1,3 +1,4 @@ +using System; using NUnit.Framework; namespace Immutable.Audience.Tests @@ -11,6 +12,15 @@ public void Progression_EventName_IsProgression() Assert.AreEqual("progression", new Progression().EventName); } + [Test] + public void Progression_WithoutStatus_ThrowsOnToProperties() + { + var evt = new Progression { World = "tutorial" }; + + var ex = Assert.Throws(() => evt.ToProperties()); + Assert.That(ex!.Message, Does.Contain("Status")); + } + [Test] public void Progression_Complete_ProducesCorrectProperties() { @@ -72,6 +82,33 @@ public void Resource_EventName_IsResource() Assert.AreEqual("resource", new Resource().EventName); } + [Test] + public void Resource_WithoutFlow_ThrowsOnToProperties() + { + var evt = new Resource { Currency = "gold", Amount = 100 }; + + var ex = Assert.Throws(() => evt.ToProperties()); + Assert.That(ex!.Message, Does.Contain("Flow")); + } + + [Test] + public void Resource_WithoutCurrency_ThrowsOnToProperties() + { + var evt = new Resource { Flow = ResourceFlow.Source, Amount = 100 }; + + var ex = Assert.Throws(() => evt.ToProperties()); + Assert.That(ex!.Message, Does.Contain("Currency")); + } + + [Test] + public void Resource_WithoutAmount_ThrowsOnToProperties() + { + var evt = new Resource { Flow = ResourceFlow.Source, Currency = "gold" }; + + var ex = Assert.Throws(() => evt.ToProperties()); + Assert.That(ex!.Message, Does.Contain("Amount")); + } + [Test] public void Purchase_ProducesCorrectProperties() { @@ -114,6 +151,24 @@ public void Purchase_EventName_IsPurchase() Assert.AreEqual("purchase", new Purchase().EventName); } + [Test] + public void Purchase_WithoutCurrency_ThrowsOnToProperties() + { + var evt = new Purchase { Value = 9.99m }; + + var ex = Assert.Throws(() => evt.ToProperties()); + Assert.That(ex!.Message, Does.Contain("Currency")); + } + + [Test] + public void Purchase_WithoutValue_ThrowsOnToProperties() + { + var evt = new Purchase { Currency = "USD" }; + + var ex = Assert.Throws(() => evt.ToProperties()); + Assert.That(ex!.Message, Does.Contain("Value")); + } + [Test] public void MilestoneReached_ProducesCorrectProperties() { diff --git a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs index 49250a8fc..d4364afb5 100644 --- a/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs +++ b/src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs @@ -105,6 +105,31 @@ public void Track_NullEvent_DoesNotThrow_AndLogsWarning() finally { Log.Writer = null; } } + [Test] + public void Track_IEventMissingRequiredField_DropsWithWarn() + { + ImmutableAudience.Init(MakeConfig()); + + var lines = new List(); + Log.Writer = lines.Add; + try + { + // Purchase with no Value set — ToProperties throws; Track must + // catch, warn, and drop rather than ship an incomplete event. + Assert.DoesNotThrow(() => ImmutableAudience.Track(new Purchase { Currency = "USD" })); + Assert.That(lines, Has.Some.Contains("Purchase")); + Assert.That(lines, Has.Some.Contains("Dropping")); + } + finally { Log.Writer = null; } + + ImmutableAudience.Shutdown(); + var queueDir = AudiencePaths.QueueDir(_testDir); + var contents = Directory.GetFiles(queueDir, "*.json") + .Select(File.ReadAllText).ToList(); + Assert.IsFalse(contents.Any(c => c.Contains("\"purchase\"")), + "purchase event with missing required Value must be dropped, not enqueued"); + } + [Test] public void Track_NullOrEmptyEventName_DoesNotEnqueue() {