using Newtonsoft.Json.Linq; using RudderSdk.Core; using RudderSdk.Core.Abstractions; using RudderSdk.Core.Models; using RudderSdk.Core.Models.Scenarios; using Xunit; namespace RudderSdk.Core.Tests; public sealed class ScenarioServiceTests { [Fact] public async Task TriggerAsync_Starts_All_Returned_Plans() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Plans = new List { Plan("plan-1", Node("n1", "notification")), Plan("plan-2", Node("n2", "notification")) } }); var client = CreateClient(transport); var notifications = new List(); client.Scenario.OnNotification += notifications.Add; var started = await client.Scenario.TriggerAsync("login"); Assert.Equal(2, notifications.Count); Assert.Equal(2, started.Count); Assert.Equal(2, client.Scenario.ActiveRuns.Count); } [Fact] public async Task Completing_Node_Activates_All_Matching_Client_Edges() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Plans = new List { Plan( "plan", new[] { Node("start", "notification"), Node("store", "store"), Node("wait", "wait", new { duration = 1, unit = "minutes" }) }, new[] { Edge("start", "output", "store"), Edge("start", "output", "wait") }) } }); var client = CreateClient(transport); NotificationSession? notification = null; var stores = 0; var waits = 0; client.Scenario.OnNotification += session => notification = session; client.Scenario.OnStoreOffer += _ => stores++; client.Scenario.OnWait += _ => waits++; await client.Scenario.TriggerAsync("login"); await notification!.CompleteAsync(); Assert.Equal(1, stores); Assert.Equal(1, waits); Assert.Equal(2, client.Scenario.ActiveRuns.Single().ActiveNodeIds.Count); } [Fact] public async Task Emits_Typed_Events_For_All_Supported_Interactive_Node_Types() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Plans = new List { Plan( "plan", new[] { Node("start", "notification"), Node("store", "store"), Node("quest", "quest"), Node("leaderboard", "leaderboard"), Node("battlepass", "battlepass"), Node("battlepass-level", "battlepass_level"), }, new[] { Edge("start", "output", "store"), Edge("start", "output", "quest"), Edge("start", "output", "leaderboard"), Edge("start", "output", "battlepass"), Edge("start", "output", "battlepass-level"), }) } }); var client = CreateClient(transport); NotificationSession? notification = null; var store = 0; var quest = 0; var leaderboard = 0; var battlePass = 0; var battlePassLevel = 0; client.Scenario.OnNotification += session => notification = session; client.Scenario.OnStoreOffer += _ => store++; client.Scenario.OnQuest += _ => quest++; client.Scenario.OnLeaderboard += _ => leaderboard++; client.Scenario.OnBattlePass += _ => battlePass++; client.Scenario.OnBattlePassLevel += _ => battlePassLevel++; await client.Scenario.TriggerAsync("login"); await notification!.CompleteAsync(); Assert.Equal(1, store); Assert.Equal(1, quest); Assert.Equal(1, leaderboard); Assert.Equal(1, battlePass); Assert.Equal(1, battlePassLevel); } [Fact] public async Task Wait_Persists_Deadline_And_Completes_After_Restore() { var clock = new FakeClock(new DateTimeOffset(2026, 5, 30, 10, 0, 0, TimeSpan.Zero)); var stateStore = new FakePlanStateStore(); var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Plans = new List { Plan( "plan", new[] { Node("wait", "wait", new { duration = 30, unit = "minutes" }), Node("done", "notification") }, new[] { Edge("wait", "onComplete", "done") }) } }); var client = CreateClient(transport, clock: clock, stateStore: stateStore); await client.Scenario.TriggerAsync("login"); Assert.False(string.IsNullOrEmpty(stateStore.State)); clock.UtcNow = clock.UtcNow.AddMinutes(31); var restoredClient = CreateClient(new FakeTransport(), clock: clock, stateStore: stateStore); var completed = 0; restoredClient.Scenario.OnNotification += _ => completed++; await restoredClient.Scenario.RestoreAsync(); restoredClient.Update(0); await Task.Delay(20); Assert.Equal(1, completed); var run = Assert.Single(restoredClient.Scenario.ActiveRuns); Assert.Equal("done", Assert.Single(run.ActiveNodeIds)); Assert.Contains("\"done\"", stateStore.State); } [Fact] public async Task RemoteConfigOverride_Applies_Patches_And_Continues() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Plans = new List { Plan( "plan", new[] { Node("override", "remote_config_override", new { patches = new object[] { new { path = "difficulty", valueType = "string", value = "hard" }, new { path = "enemy_count", valueType = "int", value = 12 } } }), Node("done", "notification") }, new[] { Edge("override", "output", "done") }) } }); var client = CreateClient(transport); var notifications = 0; var configChanges = 0; client.Scenario.OnNotification += _ => notifications++; client.Scenario.OnConfigChanged += _ => configChanges++; await client.Scenario.TriggerAsync("login"); Assert.Equal("hard", client.RemoteConfig.Get("difficulty", "normal")); Assert.Equal(12, client.RemoteConfig.Get("enemy_count", 0)); Assert.Equal(1, configChanges); Assert.Equal(1, notifications); } [Fact] public async Task Boundary_Callback_Sends_Source_Node_And_Starts_Continuation_Plan() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Plans = new List { Plan( "plan", new[] { Node("store", "store") }, Array.Empty(), new[] { Boundary("store", "onPurchase", "server-condition") }, "run-1") } }); transport.Enqueue(new HandleScenarioCallbackResponse { Plan = Plan("continuation", Node("done", "notification"), "run-1") }); var client = CreateClient(transport); StoreOfferSession? store = null; var notifications = 0; client.Scenario.OnStoreOffer += session => store = session; client.Scenario.OnNotification += _ => notifications++; await client.Scenario.TriggerAsync("login"); await store!.PurchaseAsync(); var callback = Assert.IsType( transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request); Assert.Equal("store", callback.NodeId); Assert.Equal("onPurchase", callback.Handle); Assert.Equal(1, notifications); } [Fact] public async Task Boundary_Wins_Over_Local_Edges() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Plans = new List { Plan( "plan", new[] { Node("store", "store"), Node("local", "notification") }, new[] { Edge("store", "onPurchase", "local") }, new[] { Boundary("store", "onPurchase", "server-condition") }, "run-1") } }); transport.Enqueue(new HandleScenarioCallbackResponse { Plan = Plan("continuation", Node("server", "notification"), "run-1") }); var client = CreateClient(transport); StoreOfferSession? store = null; var notificationIds = new List(); client.Scenario.OnStoreOffer += session => store = session; client.Scenario.OnNotification += session => notificationIds.Add(session.Id); await client.Scenario.TriggerAsync("login"); await store!.PurchaseAsync(); Assert.Equal(new[] { "server" }, notificationIds); Assert.Contains(transport.Calls, call => call.Path == "/sdk/v1/scenarios/callback"); } [Fact] public async Task Quest_Progress_Completed_Response_Completes_The_Node() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Plans = new List { Plan("plan", Node("quest", "quest")) } }); transport.Enqueue(new UpdateScenarioCounterResponse { Completed = true }); var client = CreateClient(transport); QuestSession? quest = null; var completed = 0; client.Scenario.OnQuest += session => quest = session; client.Scenario.OnScenarioCompleted += _ => completed++; await client.Scenario.TriggerAsync("login"); await quest!.AddProgressAsync("wins", 1); var counter = Assert.IsType( transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/counter").Request); Assert.Equal("quest", counter.NodeId); Assert.Equal("wins", counter.CounterKey); Assert.Equal(1, completed); Assert.Empty(client.Scenario.ActiveRuns); } [Fact] public async Task Quest_Progress_Failure_Does_Not_Fail_The_Run() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Plans = new List { Plan("plan", Node("quest", "quest")) } }); transport.Enqueue(new InvalidOperationException("boom")); var logger = new FakeLogger(); var client = CreateClient(transport, logger: logger); QuestSession? quest = null; var failed = 0; client.Scenario.OnQuest += session => quest = session; client.Scenario.OnScenarioFailed += _ => failed++; await client.Scenario.TriggerAsync("login"); await quest!.AddProgressAsync("wins", 1); Assert.Equal(0, failed); Assert.Single(client.Scenario.ActiveRuns); Assert.Contains(logger.Messages, m => m.Level == RudderLogLevel.Warning && m.Message.Contains("counter update failed")); } [Fact] public async Task Unknown_Nodes_Fail_The_Run() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Plans = new List { Plan("plan", Node("future", "future_node")) } }); var logger = new FakeLogger(); var client = CreateClient(transport, logger: logger); ScenarioFailedEvent? failure = null; client.Scenario.OnScenarioFailed += e => failure = e; await client.Scenario.TriggerAsync("login"); Assert.NotNull(failure); Assert.Equal("future", failure!.NodeId); Assert.Empty(client.Scenario.ActiveRuns); Assert.Contains(logger.Messages, m => m.Level == RudderLogLevel.Warning && m.Message.Contains("Unsupported scenario node type 'future_node'")); } private static RudderClient CreateClient( FakeTransport transport, FakeClock? clock = null, FakePlanStateStore? stateStore = null, IRudderLogger? logger = null) { return new RudderClient(new RudderClientOptions { BaseUrl = "http://localhost:8082", RealtimeUrl = "ws://localhost:8090/api/realtime/ws", ProjectKey = "project-key", Transport = transport, TokenStore = new FakeTokenStore { AccessToken = "access-token" }, DeviceIdProvider = new FakeDeviceIdProvider(), Clock = clock ?? new FakeClock(DateTimeOffset.UtcNow), PlanStateStore = stateStore ?? new FakePlanStateStore(), RealtimeTransportFactory = new FakeRealtimeTransportFactory(), Logger = logger }); } private static ExecutionPlan Plan(string id, ExecutionPlanNode node, string? runId = null) => Plan(id, new[] { node }, Array.Empty(), runId: runId); private static ExecutionPlan Plan( string id, IEnumerable nodes, IEnumerable edges, IEnumerable? boundaries = null, string? runId = null) { var nodeList = nodes.ToList(); return new ExecutionPlan { PlanId = id, ScenarioId = "scenario-" + id, UserId = "user", StartNodeId = nodeList[0].Id, Nodes = nodeList, Edges = edges.ToList(), BoundaryNodes = boundaries?.ToList() ?? new List(), RunId = runId!, Context = new JObject() }; } private static ExecutionPlanNode Node(string id, string type, object? data = null) { return new ExecutionPlanNode { Id = id, Type = type, Data = data == null ? new JObject() : JObject.FromObject(data) }; } private static PlanEdge Edge(string source, string handle, string target) { return new PlanEdge { Id = source + "-" + handle + "-" + target, Source = source, SourceHandle = handle, Target = target, TargetHandle = "in" }; } private static BoundaryNode Boundary(string source, string handle, string target) { return new BoundaryNode { SourceNodeId = source, SourceHandle = handle, NodeId = target, CallbackUrl = "/sdk/v1/scenarios/callback" }; } private sealed class FakeTransport : IRudderTransport { private readonly Queue _responses = new(); public List Calls { get; } = new(); public void Enqueue(object response) => _responses.Enqueue(response); public Task SendAsync( 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)!); return Task.FromResult((TResponse)_responses.Dequeue()); } } 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 FakePlanStateStore : IPlanStateStore { public string? State { 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)); } private sealed class FakeRealtimeTransportFactory : IRealtimeTransportFactory { public IRealtimeTransport Create() => new FakeRealtimeTransport(); } #pragma warning disable CS0067 private sealed class FakeRealtimeTransport : IRealtimeTransport { public bool IsConnected { get; private set; } public event Action? Closed; public event Action? Error; public event Action>? Received; public Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default) { IsConnected = true; return Task.CompletedTask; } public Task SendAsync(ArraySegment payload, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task CloseAsync(CancellationToken cancellationToken = default) { IsConnected = false; Closed?.Invoke(); return Task.CompletedTask; } public void Update(float deltaTime) { } } #pragma warning restore CS0067 }