Files
rudder-csharp-sdk/tests/Rudder.Core.Tests/ScenarioServiceTests.cs
T
edmand46 2a8d5d4f2f
CI / check (push) Successful in 50s
CI / publish (push) Failing after 37s
0.3.0: UGC removed, battlepass context-carrying API, scenario behavior aligned with web SDK, CI publish
2026-08-19 17:48:58 +03:00

534 lines
19 KiB
C#

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<ExecutionPlan>
{
Plan("plan-1", Node("n1", "notification")),
Plan("plan-2", Node("n2", "notification"))
}
});
var client = CreateClient(transport);
var notifications = new List<NotificationSession>();
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<ExecutionPlan>
{
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<ExecutionPlan>
{
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<ExecutionPlan>
{
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<ExecutionPlan>
{
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<ExecutionPlan>
{
Plan(
"plan",
new[] { Node("store", "store") },
Array.Empty<PlanEdge>(),
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<HandleScenarioCallbackRequest>(
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<ExecutionPlan>
{
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<string>();
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<ExecutionPlan>
{
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<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, 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<ExecutionPlan>
{
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<ExecutionPlan>
{
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<PlanEdge>(), runId: runId);
private static ExecutionPlan Plan(
string id,
IEnumerable<ExecutionPlanNode> nodes,
IEnumerable<PlanEdge> edges,
IEnumerable<BoundaryNode>? 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<BoundaryNode>(),
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<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)!);
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<Exception>? Error;
public event Action<ArraySegment<byte>>? Received;
public Task ConnectAsync(Uri uri, TimeSpan timeout, CancellationToken cancellationToken = default)
{
IsConnected = true;
return Task.CompletedTask;
}
public Task SendAsync(ArraySegment<byte> 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
}