Files
rudder-csharp-sdk/tests/Rudder.Core.Tests/ScenarioServiceTests.cs
T

610 lines
23 KiB
C#
Raw Normal View History

2026-08-12 14:04:55 +03:00
using Newtonsoft.Json.Linq;
using RudderSdk.Core;
using RudderSdk.Core.Abstractions;
using RudderSdk.Core.Models;
using RudderSdk.Core.Models.Auth;
2026-08-12 14:04:55 +03:00
using RudderSdk.Core.Models.Scenarios;
using Xunit;
namespace RudderSdk.Core.Tests;
public sealed class ScenarioServiceTests
{
[Fact]
public async Task TriggerAsync_Emits_Returned_Effects()
2026-08-12 14:04:55 +03:00
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect>
2026-08-12 14:04:55 +03:00
{
Effect("run-1", "n1", "notification"),
Effect("run-2", "n2", "notification")
2026-08-12 14:04:55 +03:00
}
});
var client = CreateClient(transport);
var notifications = new List<NotificationEffect>();
client.Effects.OnNotification += notifications.Add;
2026-08-12 14:04:55 +03:00
await client.Scenario.TriggerAsync("login");
2026-08-12 14:04:55 +03:00
Assert.Equal(2, notifications.Count);
Assert.Equal("n1", notifications[0].NodeId);
Assert.Equal("n2", notifications[1].NodeId);
}
[Fact]
public async Task TriggerAsync_Dedups_By_RunId_And_NodeId()
{
var transport = new FakeTransport();
var effect = Effect("run-1", "n1", "notification");
transport.Enqueue(new TriggerScenarioResponse { Effects = new List<PendingEffect> { effect } });
transport.Enqueue(new TriggerScenarioResponse { Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") } });
var client = CreateClient(transport);
var notifications = 0;
client.Effects.OnNotification += _ => notifications++;
await client.Scenario.TriggerAsync("login");
await client.Scenario.TriggerAsync("login");
Assert.Equal(1, notifications);
2026-08-12 14:04:55 +03:00
}
[Fact]
public async Task Completing_Notification_Posts_Output_And_Ingests_Next_Effect()
2026-08-12 14:04:55 +03:00
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification", new { title = "hi", message = "there" }) }
});
transport.Enqueue(new HandleScenarioCallbackResponse
{
Effect = JObject.FromObject(Effect("run-1", "store", "store"))
});
var client = CreateClient(transport);
NotificationEffect? notification = null;
StoreOfferEffect? store = null;
client.Effects.OnNotification += e => notification = e;
client.Effects.OnStoreOffer += e => store = e;
await client.Scenario.TriggerAsync("login");
Assert.Equal("hi", notification!.Title);
Assert.Equal("there", notification.Message);
await notification.DoneAsync();
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("n1", callback.NodeId);
Assert.Equal("output", callback.Handle);
Assert.Equal("run-1", callback.RunId);
Assert.NotNull(store);
Assert.Equal("store", store!.NodeId);
}
[Fact]
public async Task Completing_Last_Effect_Raises_ScenarioCompleted()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") }
2026-08-12 14:04:55 +03:00
});
transport.Enqueue(new HandleScenarioCallbackResponse());
2026-08-12 14:04:55 +03:00
var client = CreateClient(transport);
NotificationEffect? notification = null;
ScenarioCompletedEffect? completed = null;
client.Effects.OnNotification += e => notification = e;
client.Effects.OnScenarioCompleted += e => completed = e;
2026-08-12 14:04:55 +03:00
await client.Scenario.TriggerAsync("login");
await notification!.DoneAsync();
2026-08-12 14:04:55 +03:00
Assert.NotNull(completed);
Assert.Equal("run-1", completed!.RunId);
Assert.Equal("scenario-1", completed.ScenarioId);
2026-08-12 14:04:55 +03:00
}
[Fact]
public async Task Emits_Typed_Events_For_All_Supported_Interactive_Node_Types()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect>
2026-08-12 14:04:55 +03:00
{
Effect("run-1", "n1", "notification"),
Effect("run-2", "n2", "store"),
Effect("run-3", "n3", "quest"),
Effect("run-4", "n4", "leaderboard"),
Effect("run-5", "n5", "battlepass"),
Effect("run-6", "n6", "battlepass_level", new { levelNumber = 3 }),
Effect("run-7", "n7", "wait", waitDeadline: new DateTimeOffset(2026, 5, 30, 10, 30, 0, TimeSpan.Zero))
2026-08-12 14:04:55 +03:00
}
});
var client = CreateClient(transport);
var notification = 0;
2026-08-12 14:04:55 +03:00
var store = 0;
var quest = 0;
var leaderboard = 0;
var battlePass = 0;
var battlePassLevel = 0;
var wait = 0;
WaitEffect? waitEffect = null;
BattlePassLevelEffect? levelEffect = null;
client.Effects.OnNotification += _ => notification++;
client.Effects.OnStoreOffer += _ => store++;
client.Effects.OnQuest += _ => quest++;
client.Effects.OnLeaderboard += _ => leaderboard++;
client.Effects.OnBattlePass += _ => battlePass++;
client.Effects.OnBattlePassLevel += e => { battlePassLevel++; levelEffect = e; };
client.Effects.OnWait += e => { wait++; waitEffect = e; };
2026-08-12 14:04:55 +03:00
await client.Scenario.TriggerAsync("login");
Assert.Equal(1, notification);
2026-08-12 14:04:55 +03:00
Assert.Equal(1, store);
Assert.Equal(1, quest);
Assert.Equal(1, leaderboard);
Assert.Equal(1, battlePass);
Assert.Equal(1, battlePassLevel);
Assert.Equal(1, wait);
Assert.Equal(3, levelEffect!.Level);
Assert.Equal(new DateTimeOffset(2026, 5, 30, 10, 30, 0, TimeSpan.Zero), waitEffect!.DeadlineUtc);
2026-08-12 14:04:55 +03:00
}
[Fact]
public async Task Store_Purchase_And_Decline_Post_Matching_Handles()
2026-08-12 14:04:55 +03:00
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "store", "store") }
2026-08-12 14:04:55 +03:00
});
transport.Enqueue(new HandleScenarioCallbackResponse());
var client = CreateClient(transport);
StoreOfferEffect? store = null;
client.Effects.OnStoreOffer += e => store = e;
2026-08-12 14:04:55 +03:00
await client.Scenario.TriggerAsync("login");
await store!.PurchaseAsync();
2026-08-12 14:04:55 +03:00
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("onPurchase", callback.Handle);
Assert.True(store.IsResolved);
2026-08-12 14:04:55 +03:00
store.Decline();
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/callback"));
2026-08-12 14:04:55 +03:00
}
[Fact]
public async Task Leaderboard_End_Posts_OnEnd()
2026-08-12 14:04:55 +03:00
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "lb", "leaderboard") }
2026-08-12 14:04:55 +03:00
});
transport.Enqueue(new HandleScenarioCallbackResponse());
2026-08-12 14:04:55 +03:00
var client = CreateClient(transport);
LeaderboardEffect? leaderboard = null;
client.Effects.OnLeaderboard += e => leaderboard = e;
2026-08-12 14:04:55 +03:00
await client.Scenario.TriggerAsync("login");
await leaderboard!.EndAsync();
2026-08-12 14:04:55 +03:00
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("onEnd", callback.Handle);
2026-08-12 14:04:55 +03:00
}
[Fact]
public async Task Leaderboard_Claim_Posts_OnClaim()
2026-08-12 14:04:55 +03:00
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "lb", "leaderboard") }
2026-08-12 14:04:55 +03:00
});
transport.Enqueue(new HandleScenarioCallbackResponse());
2026-08-12 14:04:55 +03:00
var client = CreateClient(transport);
LeaderboardEffect? leaderboard = null;
client.Effects.OnLeaderboard += e => leaderboard = e;
2026-08-12 14:04:55 +03:00
await client.Scenario.TriggerAsync("login");
await leaderboard!.ClaimAsync();
2026-08-12 14:04:55 +03:00
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("onClaim", callback.Handle);
2026-08-12 14:04:55 +03:00
}
[Fact]
public async Task Quest_Progress_Completed_Response_Ingests_Next_Effect()
2026-08-12 14:04:55 +03:00
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "quest", "quest", new { name = "wins" }) }
2026-08-12 14:04:55 +03:00
});
transport.Enqueue(new UpdateScenarioCounterResponse
2026-08-12 14:04:55 +03:00
{
Completed = true,
Effect = JObject.FromObject(Effect("run-1", "done", "notification"))
2026-08-12 14:04:55 +03:00
});
var client = CreateClient(transport);
QuestEffect? quest = null;
NotificationEffect? notification = null;
client.Effects.OnQuest += e => quest = e;
client.Effects.OnNotification += e => notification = e;
2026-08-12 14:04:55 +03:00
await client.Scenario.TriggerAsync("login");
Assert.Equal("wins", quest!.Name);
await quest.ReportProgressAsync("wins", 1);
2026-08-12 14:04:55 +03:00
var counter = Assert.IsType<UpdateScenarioCounterRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/counter").Request);
Assert.Equal("quest", counter.NodeId);
Assert.Equal("wins", counter.CounterKey);
Assert.Equal(1, counter.Amount);
Assert.NotNull(notification);
Assert.Equal("done", notification!.NodeId);
2026-08-12 14:04:55 +03:00
}
[Fact]
public async Task Quest_Progress_Completed_Without_Next_Raises_ScenarioCompleted()
2026-08-12 14:04:55 +03:00
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "quest", "quest") }
2026-08-12 14:04:55 +03:00
});
transport.Enqueue(new UpdateScenarioCounterResponse { Completed = true });
2026-08-12 14:04:55 +03:00
var client = CreateClient(transport);
QuestEffect? quest = null;
var completed = 0;
client.Effects.OnQuest += e => quest = e;
client.Effects.OnScenarioCompleted += _ => completed++;
2026-08-12 14:04:55 +03:00
await client.Scenario.TriggerAsync("login");
await quest!.ReportProgressAsync("wins", 1);
2026-08-12 14:04:55 +03:00
Assert.Equal(1, completed);
}
[Fact]
public async Task Quest_Progress_Failure_Does_Not_Fail_The_Run()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "quest", "quest") }
});
transport.Enqueue(new InvalidOperationException("boom"));
var logger = new FakeLogger();
var client = CreateClient(transport, logger: logger);
QuestEffect? quest = null;
var failed = 0;
client.Effects.OnQuest += e => quest = e;
client.Effects.OnScenarioFailed += _ => failed++;
await client.Scenario.TriggerAsync("login");
await quest!.ReportProgressAsync("wins", 1);
Assert.Equal(0, failed);
Assert.Contains(logger.Messages, m =>
m.Level == RudderLogLevel.Warning && m.Message.Contains("counter update failed"));
2026-08-12 14:04:55 +03:00
}
[Fact]
public async Task Unknown_Run_Drops_Effects_And_Raises_Failed()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") }
});
transport.Enqueue(new RudderNotFoundException(404, RudderErrorCodes.UnknownRun, "unknown"));
var client = CreateClient(transport);
NotificationEffect? notification = null;
ScenarioFailedEffect? failure = null;
client.Effects.OnNotification += e => notification = e;
client.Effects.OnScenarioFailed += e => failure = e;
await client.Scenario.TriggerAsync("login");
await notification!.DoneAsync();
Assert.NotNull(failure);
Assert.Equal("run-1", failure!.RunId);
Assert.Equal("n1", failure.NodeId);
Assert.IsType<RudderNotFoundException>(failure.Exception);
}
[Fact]
public async Task Expired_Run_Drops_Effects()
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") }
});
transport.Enqueue(new RudderApiException(400, RudderErrorCodes.RunExpired, "expired"));
var client = CreateClient(transport);
NotificationEffect? notification = null;
ScenarioFailedEffect? failure = null;
client.Effects.OnNotification += e => notification = e;
client.Effects.OnScenarioFailed += e => failure = e;
await client.Scenario.TriggerAsync("login");
await notification!.DoneAsync();
Assert.NotNull(failure);
Assert.Equal("run-1", failure!.RunId);
}
2026-08-12 14:04:55 +03:00
[Fact]
public async Task Unknown_Nodes_Fail_The_Run()
2026-08-12 14:04:55 +03:00
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "future", "future_node") }
2026-08-12 14:04:55 +03:00
});
var logger = new FakeLogger();
var client = CreateClient(transport, logger: logger);
ScenarioFailedEffect? failure = null;
client.Effects.OnScenarioFailed += e => failure = e;
2026-08-12 14:04:55 +03:00
await client.Scenario.TriggerAsync("login");
Assert.NotNull(failure);
Assert.Equal("future", failure!.NodeId);
Assert.Contains(logger.Messages, m =>
m.Level == RudderLogLevel.Warning && m.Message.Contains("Unsupported scenario node type 'future_node'"));
2026-08-12 14:04:55 +03:00
}
[Fact]
public async Task Wait_Deadline_Refreshes_Pending_Without_Posting_Callback()
2026-08-12 14:04:55 +03:00
{
var clock = new FakeClock(new DateTimeOffset(2026, 5, 30, 10, 0, 0, TimeSpan.Zero));
var transport = new FakeTransport();
transport.Enqueue(new ListPendingScenarioEffectsResponse { Effects = new List<PendingEffect>() });
transport.Enqueue(new TriggerScenarioResponse
2026-08-12 14:04:55 +03:00
{
Effects = new List<PendingEffect>
{
Effect("run-1", "wait", "wait", waitDeadline: clock.UtcNow.AddMinutes(30))
}
});
var client = CreateClient(transport, clock: clock);
client.Update(0);
await Task.Delay(50);
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
await client.Scenario.TriggerAsync("login");
client.Update(0);
await Task.Delay(50);
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
Assert.DoesNotContain(transport.Calls, call => call.Path == "/sdk/v1/scenarios/callback");
transport.Enqueue(new ListPendingScenarioEffectsResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "done", "notification") }
2026-08-12 14:04:55 +03:00
});
var notifications = 0;
client.Effects.OnNotification += _ => notifications++;
clock.UtcNow = clock.UtcNow.AddMinutes(31);
client.Update(0);
await Task.Delay(50);
Assert.Equal(2, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
Assert.Equal(1, notifications);
Assert.DoesNotContain(transport.Calls, call => call.Path == "/sdk/v1/scenarios/callback");
2026-08-12 14:04:55 +03:00
}
[Fact]
public async Task Heartbeat_Refreshes_Pending_After_30_Seconds()
{
var clock = new FakeClock(new DateTimeOffset(2026, 5, 30, 10, 0, 0, TimeSpan.Zero));
var transport = new FakeTransport();
transport.Enqueue(new ListPendingScenarioEffectsResponse { Effects = new List<PendingEffect>() });
var client = CreateClient(transport, clock: clock);
client.Update(0);
await Task.Delay(50);
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
clock.UtcNow = clock.UtcNow.AddSeconds(29);
client.Update(0);
await Task.Delay(50);
Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
transport.Enqueue(new ListPendingScenarioEffectsResponse { Effects = new List<PendingEffect>() });
clock.UtcNow = clock.UtcNow.AddSeconds(1);
client.Update(0);
await Task.Delay(50);
Assert.Equal(2, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/pending"));
}
2026-08-12 14:04:55 +03:00
[Fact]
public async Task Login_Fetches_Pending_On_Next_Update()
2026-08-12 14:04:55 +03:00
{
var transport = new FakeTransport();
transport.Enqueue(new LoginViaDeviceResponse
2026-08-12 14:04:55 +03:00
{
AccessToken = "access-token",
RefreshToken = "refresh-token"
});
transport.Enqueue(new ListPendingScenarioEffectsResponse
{
Effects = new List<PendingEffect> { Effect("run-1", "n1", "notification") }
});
var tokenStore = new FakeTokenStore();
var client = CreateClient(transport, tokenStore: tokenStore);
var notifications = 0;
client.Effects.OnNotification += _ => notifications++;
await client.Auth.LoginWithDeviceAsync("en", "en");
Assert.Equal(0, notifications);
client.Update(0);
await Task.Delay(50);
Assert.Equal(1, notifications);
Assert.Contains(transport.Calls, call => call.Method == "GET" && call.Path == "/sdk/v1/scenarios/pending");
2026-08-12 14:04:55 +03:00
}
[Fact]
public async Task BattlePass_LevelUp_Posts_OnLevelUp()
2026-08-12 14:04:55 +03:00
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
2026-08-12 14:04:55 +03:00
{
Effects = new List<PendingEffect> { Effect("run-1", "bp", "battlepass") }
});
transport.Enqueue(new HandleScenarioCallbackResponse());
var client = CreateClient(transport);
BattlePassEffect? battlePass = null;
client.Effects.OnBattlePass += e => battlePass = e;
await client.Scenario.TriggerAsync("login");
await battlePass!.LevelUpAsync();
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("onLevelUp", callback.Handle);
2026-08-12 14:04:55 +03:00
}
[Fact]
public async Task BattlePassLevel_Claim_Posts_OnComplete()
2026-08-12 14:04:55 +03:00
{
var transport = new FakeTransport();
transport.Enqueue(new TriggerScenarioResponse
2026-08-12 14:04:55 +03:00
{
Effects = new List<PendingEffect> { Effect("run-1", "bpl", "battlepass_level", new { levelNumber = 2 }) }
});
transport.Enqueue(new HandleScenarioCallbackResponse());
var client = CreateClient(transport);
BattlePassLevelEffect? level = null;
client.Effects.OnBattlePassLevel += e => level = e;
await client.Scenario.TriggerAsync("login");
await level!.ClaimAsync();
var callback = Assert.IsType<HandleScenarioCallbackRequest>(
transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request);
Assert.Equal("onComplete", callback.Handle);
Assert.Equal(2, level.Level);
2026-08-12 14:04:55 +03:00
}
private static RudderClient CreateClient(
FakeTransport transport,
FakeClock? clock = null,
IRudderLogger? logger = null,
FakeTokenStore? tokenStore = null)
2026-08-12 14:04:55 +03:00
{
return new RudderClient(new RudderClientOptions
2026-08-12 14:04:55 +03:00
{
BaseUrl = "http://localhost:8082",
ProjectKey = "project-key",
Transport = transport,
TokenStore = tokenStore ?? new FakeTokenStore { AccessToken = "access-token" },
DeviceIdProvider = new FakeDeviceIdProvider(),
Clock = clock ?? new FakeClock(DateTimeOffset.UtcNow),
Logger = logger
});
}
private static PendingEffect Effect(string runId, string nodeId, string type, object? data = null, DateTimeOffset? waitDeadline = null)
{
return new PendingEffect
{
RunId = runId,
ScenarioId = "scenario-1",
NodeId = nodeId,
Type = type,
Data = data == null ? new JObject() : JObject.FromObject(data),
WaitDeadline = waitDeadline
2026-08-12 14:04:55 +03:00
};
}
private sealed class FakeTransport : IRudderTransport
{
private readonly Queue<object> _responses = new();
public List<FakeCall> Calls { get; } = new();
public void Enqueue(object response) => _responses.Enqueue(response);
public Task<TResponse> SendAsync<TRequest, TResponse>(
string method,
string path,
TRequest? request,
string? accessToken,
CancellationToken cancellationToken = default)
{
Calls.Add(new FakeCall(method, path, request, accessToken));
if (_responses.Count == 0)
return Task.FromResult(default(TResponse)!);
var next = _responses.Dequeue();
if (next is Exception ex)
return Task.FromException<TResponse>(ex);
return Task.FromResult((TResponse)next);
2026-08-12 14:04:55 +03:00
}
}
private sealed class FakeCall
{
public FakeCall(string method, string path, object? request, string? accessToken)
{
Method = method;
Path = path;
Request = request;
AccessToken = accessToken;
}
public string Method { get; }
public string Path { get; }
public object? Request { get; }
public string? AccessToken { get; }
}
private sealed class FakeTokenStore : ITokenStore
{
public string? AccessToken { get; set; }
public string? RefreshToken { get; set; }
public string? GetAccessToken() => AccessToken;
public string? GetRefreshToken() => RefreshToken;
public void SaveTokens(string accessToken, string refreshToken)
{
AccessToken = accessToken;
RefreshToken = refreshToken;
}
public void Clear()
{
AccessToken = null;
RefreshToken = null;
}
}
private sealed class FakeDeviceIdProvider : IDeviceIdProvider
{
public string DeviceId => "device-id";
}
private sealed class FakeClock : IClock
{
public FakeClock(DateTimeOffset utcNow) => UtcNow = utcNow;
public DateTimeOffset UtcNow { get; set; }
}
private sealed class FakeLogger : IRudderLogger
{
public List<(RudderLogLevel Level, string Message)> Messages { get; } = new();
public void Log(RudderLogLevel level, string message) => Messages.Add((level, message));
}
}