Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions src/Packages/Audience/Runtime/Events/TypedEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -43,9 +44,12 @@ public class Progression : IEvent

public Dictionary<string, object> ToProperties()
{
if (Status is null)
throw new ArgumentException("Progression.Status is required — set it before calling Track(IEvent)");

var props = new Dictionary<string, object>
{
["status"] = Status.ToLowercaseString()
["status"] = Status.Value.ToLowercaseString()
};

if (World != null) props["world"] = World;
Expand Down Expand Up @@ -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; }
Expand All @@ -93,14 +99,18 @@ public class Resource : IEvent

public Dictionary<string, object> 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<string, object>
{
["flow"] = Flow.ToLowercaseString(),
["flow"] = Flow.Value.ToLowercaseString(),
["currency"] = Currency,
["amount"] = Amount
["amount"] = Amount.Value
};

if (ItemType != null) props["itemType"] = ItemType;
Expand All @@ -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; }
Expand All @@ -142,11 +154,13 @@ public Dictionary<string, object> 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<string, object>
{
["currency"] = Currency,
["value"] = Value
["value"] = Value.Value
};

if (ItemId != null) props["itemId"] = ItemId;
Expand Down
13 changes: 8 additions & 5 deletions src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object>? properties = null)
{
if (!CanTrack()) return;
Expand Down
55 changes: 55 additions & 0 deletions src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using NUnit.Framework;

namespace Immutable.Audience.Tests
Expand All @@ -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<ArgumentException>(() => evt.ToProperties());
Assert.That(ex!.Message, Does.Contain("Status"));
}

[Test]
public void Progression_Complete_ProducesCorrectProperties()
{
Expand Down Expand Up @@ -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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => evt.ToProperties());
Assert.That(ex!.Message, Does.Contain("Amount"));
}

[Test]
public void Purchase_ProducesCorrectProperties()
{
Expand Down Expand Up @@ -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<ArgumentException>(() => 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<ArgumentException>(() => evt.ToProperties());
Assert.That(ex!.Message, Does.Contain("Value"));
}

[Test]
public void MilestoneReached_ProducesCorrectProperties()
{
Expand Down
25 changes: 25 additions & 0 deletions src/Packages/Audience/Tests/Runtime/ImmutableAudienceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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()
{
Expand Down
Loading