using Newtonsoft.Json.Linq; using RudderSdk.Core; using RudderSdk.Core.Abstractions; using RudderSdk.Core.Models; using RudderSdk.Core.Models.Auth; using RudderSdk.Core.Models.Scenarios; using Xunit; namespace RudderSdk.Core.Tests; public sealed class ScenarioServiceTests { [Fact] public async Task TriggerAsync_Emits_Returned_Effects() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { Effect("run-1", "n1", "notification"), Effect("run-2", "n2", "notification") } }); var client = CreateClient(transport); var notifications = new List(); client.Effects.OnNotification += notifications.Add; await client.Scenario.TriggerAsync("login"); 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 { effect } }); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { 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); } [Fact] public async Task Completing_Notification_Posts_Output_And_Ingests_Next_Effect() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { 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( 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 { Effect("run-1", "n1", "notification") } }); transport.Enqueue(new HandleScenarioCallbackResponse()); var client = CreateClient(transport); NotificationEffect? notification = null; ScenarioCompletedEffect? completed = null; client.Effects.OnNotification += e => notification = e; client.Effects.OnScenarioCompleted += e => completed = e; await client.Scenario.TriggerAsync("login"); await notification!.DoneAsync(); Assert.NotNull(completed); Assert.Equal("run-1", completed!.RunId); Assert.Equal("scenario-1", completed.ScenarioId); } [Fact] public async Task Emits_Typed_Events_For_All_Supported_Interactive_Node_Types() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { 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)) } }); var client = CreateClient(transport); var notification = 0; 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; }; await client.Scenario.TriggerAsync("login"); Assert.Equal(1, notification); 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); } [Fact] public async Task Store_Purchase_And_Decline_Post_Matching_Handles() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { Effect("run-1", "store", "store") } }); transport.Enqueue(new HandleScenarioCallbackResponse()); var client = CreateClient(transport); StoreOfferEffect? store = null; client.Effects.OnStoreOffer += e => store = e; 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("onPurchase", callback.Handle); Assert.True(store.IsResolved); store.Decline(); Assert.Equal(1, transport.Calls.Count(call => call.Path == "/sdk/v1/scenarios/callback")); } [Fact] public async Task Leaderboard_End_Posts_OnEnd() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { Effect("run-1", "lb", "leaderboard") } }); transport.Enqueue(new HandleScenarioCallbackResponse()); var client = CreateClient(transport); LeaderboardEffect? leaderboard = null; client.Effects.OnLeaderboard += e => leaderboard = e; await client.Scenario.TriggerAsync("login"); await leaderboard!.EndAsync(); var callback = Assert.IsType( transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request); Assert.Equal("onEnd", callback.Handle); } [Fact] public async Task Leaderboard_Claim_Posts_OnClaim() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { Effect("run-1", "lb", "leaderboard") } }); transport.Enqueue(new HandleScenarioCallbackResponse()); var client = CreateClient(transport); LeaderboardEffect? leaderboard = null; client.Effects.OnLeaderboard += e => leaderboard = e; await client.Scenario.TriggerAsync("login"); await leaderboard!.ClaimAsync(); var callback = Assert.IsType( transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request); Assert.Equal("onClaim", callback.Handle); } [Fact] public async Task Quest_Progress_Completed_Response_Ingests_Next_Effect() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { Effect("run-1", "quest", "quest", new { name = "wins" }) } }); transport.Enqueue(new UpdateScenarioCounterResponse { Completed = true, Effect = JObject.FromObject(Effect("run-1", "done", "notification")) }); var client = CreateClient(transport); QuestEffect? quest = null; NotificationEffect? notification = null; client.Effects.OnQuest += e => quest = e; client.Effects.OnNotification += e => notification = e; await client.Scenario.TriggerAsync("login"); Assert.Equal("wins", quest!.Name); await quest.ReportProgressAsync("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, counter.Amount); Assert.NotNull(notification); Assert.Equal("done", notification!.NodeId); } [Fact] public async Task Quest_Progress_Completed_Without_Next_Raises_ScenarioCompleted() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { Effect("run-1", "quest", "quest") } }); transport.Enqueue(new UpdateScenarioCounterResponse { Completed = true }); var client = CreateClient(transport); QuestEffect? quest = null; var completed = 0; client.Effects.OnQuest += e => quest = e; client.Effects.OnScenarioCompleted += _ => completed++; await client.Scenario.TriggerAsync("login"); await quest!.ReportProgressAsync("wins", 1); 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 { 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")); } [Fact] public async Task Unknown_Run_Drops_Effects_And_Raises_Failed() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { 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(failure.Exception); } [Fact] public async Task Expired_Run_Drops_Effects() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { 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); } [Fact] public async Task Unknown_Nodes_Fail_The_Run() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { Effect("run-1", "future", "future_node") } }); var logger = new FakeLogger(); var client = CreateClient(transport, logger: logger); ScenarioFailedEffect? failure = null; client.Effects.OnScenarioFailed += e => failure = e; 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'")); } [Fact] public async Task Wait_Deadline_Refreshes_Pending_Without_Posting_Callback() { 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() }); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { 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 { Effect("run-1", "done", "notification") } }); 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"); } [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() }); 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() }); 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")); } [Fact] public async Task Login_Fetches_Pending_On_Next_Update() { var transport = new FakeTransport(); transport.Enqueue(new LoginViaDeviceResponse { AccessToken = "access-token", RefreshToken = "refresh-token" }); transport.Enqueue(new ListPendingScenarioEffectsResponse { Effects = new List { 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"); } [Fact] public async Task BattlePass_LevelUp_Posts_OnLevelUp() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { 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( transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request); Assert.Equal("onLevelUp", callback.Handle); } [Fact] public async Task BattlePassLevel_Claim_Posts_OnComplete() { var transport = new FakeTransport(); transport.Enqueue(new TriggerScenarioResponse { Effects = new List { 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( transport.Calls.Single(call => call.Path == "/sdk/v1/scenarios/callback").Request); Assert.Equal("onComplete", callback.Handle); Assert.Equal(2, level.Level); } private static RudderClient CreateClient( FakeTransport transport, FakeClock? clock = null, IRudderLogger? logger = null, FakeTokenStore? tokenStore = null) { return new RudderClient(new RudderClientOptions { 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 }; } 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)!); var next = _responses.Dequeue(); if (next is Exception ex) return Task.FromException(ex); return Task.FromResult((TResponse)next); } } 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)); } }