Initial commit
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
using RudderSdk.Core;
|
||||
using RudderSdk.Core.Abstractions;
|
||||
using RudderSdk.Core.Models.Auth;
|
||||
using RudderSdk.Core.Models.Player;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace RudderSdk.Core.Tests;
|
||||
|
||||
public sealed class AuthSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Request_401_Refreshes_Tokens_And_Retries_Once()
|
||||
{
|
||||
var tokenStore = new FakeTokenStore { AccessToken = "old-access", RefreshToken = "refresh" };
|
||||
var transport = new FakeTransport();
|
||||
var profileCalls = 0;
|
||||
transport.Handler = (method, path, accessToken) =>
|
||||
{
|
||||
if (path == "/sdk/v1/authorization/refresh")
|
||||
{
|
||||
var request = new RefreshAccessTokenResponse { AccessToken = "new-access", RefreshToken = "new-refresh" };
|
||||
return Task.FromResult<object?>(request);
|
||||
}
|
||||
|
||||
profileCalls++;
|
||||
if (profileCalls == 1)
|
||||
throw new RudderAuthException(401, "unauthorized", "Unauthorized");
|
||||
|
||||
Assert.Equal("new-access", accessToken);
|
||||
return Task.FromResult<object?>(new PlayerProfile());
|
||||
};
|
||||
var client = CreateClient(transport, tokenStore);
|
||||
|
||||
var profile = await client.Player.GetProfileAsync();
|
||||
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal(2, profileCalls);
|
||||
Assert.Equal("new-access", tokenStore.AccessToken);
|
||||
Assert.Equal("new-refresh", tokenStore.RefreshToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_401s_Share_One_Refresh_Request()
|
||||
{
|
||||
var tokenStore = new FakeTokenStore { AccessToken = "old-access", RefreshToken = "refresh" };
|
||||
var transport = new FakeTransport();
|
||||
var refreshCalls = 0;
|
||||
transport.Handler = async (method, path, accessToken) =>
|
||||
{
|
||||
if (path == "/sdk/v1/authorization/refresh")
|
||||
{
|
||||
refreshCalls++;
|
||||
await Task.Delay(50);
|
||||
return new RefreshAccessTokenResponse { AccessToken = "new-access", RefreshToken = "new-refresh" };
|
||||
}
|
||||
|
||||
if (accessToken != "new-access")
|
||||
throw new RudderAuthException(401, "unauthorized", "Unauthorized");
|
||||
|
||||
return new PlayerProfile();
|
||||
};
|
||||
var client = CreateClient(transport, tokenStore);
|
||||
|
||||
await Task.WhenAll(
|
||||
client.Player.GetProfileAsync(),
|
||||
client.Player.GetProfileAsync(),
|
||||
client.Player.GetProfileAsync());
|
||||
|
||||
Assert.Equal(1, refreshCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_Refresh_Clears_Tokens_Raises_SignedOut_And_Rethrows()
|
||||
{
|
||||
var tokenStore = new FakeTokenStore { AccessToken = "old-access", RefreshToken = "refresh" };
|
||||
var transport = new FakeTransport();
|
||||
transport.Handler = (method, path, accessToken) =>
|
||||
throw new RudderAuthException(401, "unauthorized", "Unauthorized");
|
||||
var client = CreateClient(transport, tokenStore);
|
||||
var states = new List<RudderAuthState>();
|
||||
client.Auth.AuthStateChanged += states.Add;
|
||||
|
||||
await Assert.ThrowsAsync<RudderAuthException>(() => client.Player.GetProfileAsync());
|
||||
|
||||
Assert.Null(tokenStore.AccessToken);
|
||||
Assert.Null(tokenStore.RefreshToken);
|
||||
Assert.Equal(new[] { RudderAuthState.SignedOut }, states);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_Refresh_Token_Fails_Without_Refresh_Call()
|
||||
{
|
||||
var tokenStore = new FakeTokenStore { AccessToken = "old-access" };
|
||||
var transport = new FakeTransport();
|
||||
var refreshCalls = 0;
|
||||
transport.Handler = (method, path, accessToken) =>
|
||||
{
|
||||
if (path == "/sdk/v1/authorization/refresh")
|
||||
{
|
||||
refreshCalls++;
|
||||
return Task.FromResult<object?>(new RefreshAccessTokenResponse());
|
||||
}
|
||||
|
||||
throw new RudderAuthException(401, "unauthorized", "Unauthorized");
|
||||
};
|
||||
var client = CreateClient(transport, tokenStore);
|
||||
var states = new List<RudderAuthState>();
|
||||
client.Auth.AuthStateChanged += states.Add;
|
||||
|
||||
await Assert.ThrowsAsync<RudderAuthException>(() => client.Player.GetProfileAsync());
|
||||
|
||||
Assert.Equal(0, refreshCalls);
|
||||
Assert.Equal(new[] { RudderAuthState.SignedOut }, states);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_Stores_New_Token_Pair()
|
||||
{
|
||||
var tokenStore = new FakeTokenStore { AccessToken = "old-access", RefreshToken = "refresh" };
|
||||
var transport = new FakeTransport();
|
||||
transport.Handler = (method, path, accessToken) =>
|
||||
{
|
||||
Assert.Equal("/sdk/v1/authorization/refresh", path);
|
||||
Assert.Null(accessToken);
|
||||
return Task.FromResult<object?>(new RefreshAccessTokenResponse
|
||||
{
|
||||
AccessToken = "new-access",
|
||||
RefreshToken = "new-refresh"
|
||||
});
|
||||
};
|
||||
var client = CreateClient(transport, tokenStore);
|
||||
|
||||
var refreshed = await client.Auth.RefreshAsync();
|
||||
|
||||
Assert.True(refreshed);
|
||||
Assert.Equal("new-access", tokenStore.AccessToken);
|
||||
Assert.Equal("new-refresh", tokenStore.RefreshToken);
|
||||
}
|
||||
|
||||
private static RudderClient CreateClient(FakeTransport transport, FakeTokenStore tokenStore)
|
||||
{
|
||||
return new RudderClient(new RudderClientOptions
|
||||
{
|
||||
BaseUrl = "http://localhost:8082",
|
||||
ProjectKey = "project-key",
|
||||
Transport = transport,
|
||||
TokenStore = tokenStore,
|
||||
DeviceIdProvider = new FakeDeviceIdProvider()
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class FakeTransport : IRudderTransport
|
||||
{
|
||||
public Func<string, string, string?, Task<object?>>? Handler;
|
||||
|
||||
public Task<TResponse> SendAsync<TRequest, TResponse>(
|
||||
string method,
|
||||
string path,
|
||||
TRequest? request,
|
||||
string? accessToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Handle<TResponse>(method, path, accessToken);
|
||||
}
|
||||
|
||||
private async Task<TResponse> Handle<TResponse>(string method, string path, string? accessToken)
|
||||
{
|
||||
var result = await Handler!(method, path, accessToken);
|
||||
return (TResponse)result!;
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using RudderSdk.Core;
|
||||
using RudderSdk.Core.Models.Player;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace RudderSdk.Core.Tests;
|
||||
|
||||
public sealed class HttpClientTransportTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Maps_404_To_NotFoundException_With_Code_And_RequestId()
|
||||
{
|
||||
var transport = CreateTransport(_ => Error(HttpStatusCode.NotFound, """{"code":"store_not_found","error":"Store not found","requestId":"req-123"}"""));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<RudderNotFoundException>(
|
||||
() => transport.SendAsync<object, PlayerProfile>("GET", "/sdk/v1/stores/main", null, "token"));
|
||||
|
||||
Assert.Equal(404, exception.StatusCode);
|
||||
Assert.Equal("store_not_found", exception.Code);
|
||||
Assert.Equal("req-123", exception.RequestId);
|
||||
Assert.Equal("Store not found", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_429_To_RateLimitException()
|
||||
{
|
||||
var transport = CreateTransport(_ => Error(HttpStatusCode.TooManyRequests, """{"code":"rate_limited","error":"Slow down"}"""));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<RudderRateLimitException>(
|
||||
() => transport.SendAsync<object, PlayerProfile>("GET", "/sdk/v1/player/information", null, null));
|
||||
|
||||
Assert.Equal(429, exception.StatusCode);
|
||||
Assert.Equal("rate_limited", exception.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_401_To_AuthException()
|
||||
{
|
||||
var transport = CreateTransport(_ => Error(HttpStatusCode.Unauthorized, """{"error":"Unauthorized"}"""));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<RudderAuthException>(
|
||||
() => transport.SendAsync<object, PlayerProfile>("GET", "/sdk/v1/player/information", null, null));
|
||||
|
||||
Assert.Equal(401, exception.StatusCode);
|
||||
Assert.Null(exception.RequestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Maps_500_To_Base_ApiException()
|
||||
{
|
||||
var transport = CreateTransport(_ => Error(HttpStatusCode.InternalServerError, """{"code":"internal","error":"Boom"}"""));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<RudderApiException>(
|
||||
() => transport.SendAsync<object, PlayerProfile>("GET", "/sdk/v1/player/information", null, null));
|
||||
|
||||
Assert.Equal(typeof(RudderApiException), exception.GetType());
|
||||
Assert.Equal(500, exception.StatusCode);
|
||||
Assert.Equal("internal", exception.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_Json_Error_Body_Falls_Back_To_Reason_Phrase()
|
||||
{
|
||||
var transport = CreateTransport(_ => new HttpResponseMessage(HttpStatusCode.BadGateway)
|
||||
{
|
||||
Content = new StringContent("<html>bad gateway</html>", Encoding.UTF8, "text/html"),
|
||||
ReasonPhrase = "Bad Gateway"
|
||||
});
|
||||
|
||||
var exception = await Assert.ThrowsAsync<RudderApiException>(
|
||||
() => transport.SendAsync<object, PlayerProfile>("GET", "/sdk/v1/player/information", null, null));
|
||||
|
||||
Assert.Equal(502, exception.StatusCode);
|
||||
Assert.Equal(string.Empty, exception.Code);
|
||||
Assert.Equal("Bad Gateway", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Network_Failure_Maps_To_NetworkException()
|
||||
{
|
||||
var transport = CreateTransport(_ => throw new HttpRequestException("Connection refused"));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<RudderNetworkException>(
|
||||
() => transport.SendAsync<object, PlayerProfile>("GET", "/sdk/v1/player/information", null, null));
|
||||
|
||||
Assert.Equal(0, exception.StatusCode);
|
||||
Assert.Equal("Connection refused", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Success_Deserializes_Response_And_Sends_Bearer_Token()
|
||||
{
|
||||
StubHandler? handler = null;
|
||||
var transport = CreateTransport(request =>
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"player":{"id":"p1"},"wallets":[]}""", Encoding.UTF8, "application/json")
|
||||
};
|
||||
}, h => handler = h);
|
||||
|
||||
var profile = await transport.SendAsync<object, PlayerProfile>("GET", "/sdk/v1/player/information", null, "access-token");
|
||||
|
||||
Assert.Equal("p1", profile.Player.Id);
|
||||
Assert.Equal("Bearer", handler!.LastRequest!.Headers.Authorization!.Scheme);
|
||||
Assert.Equal("access-token", handler.LastRequest.Headers.Authorization.Parameter);
|
||||
Assert.Equal("http://localhost:8082/sdk/v1/player/information", handler.LastRequest.RequestUri!.ToString());
|
||||
}
|
||||
|
||||
private static HttpClientTransport CreateTransport(
|
||||
Func<HttpRequestMessage, HttpResponseMessage> respond,
|
||||
Action<StubHandler>? capture = null)
|
||||
{
|
||||
var handler = new StubHandler(respond);
|
||||
capture?.Invoke(handler);
|
||||
return new HttpClientTransport("http://localhost:8082/", new HttpClient(handler));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Error(HttpStatusCode statusCode, string json)
|
||||
{
|
||||
return new HttpResponseMessage(statusCode)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
|
||||
|
||||
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
|
||||
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(_respond(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="xunit" Version="2.8.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Rudder.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,215 @@
|
||||
using RudderSdk.Core;
|
||||
using RudderSdk.Core.Abstractions;
|
||||
using RudderSdk.Core.Models.Auth;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace RudderSdk.Core.Tests;
|
||||
|
||||
public sealed class RudderClientTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_Composes_All_Runtime_Services()
|
||||
{
|
||||
var client = CreateClient();
|
||||
|
||||
Assert.NotNull(client.Auth);
|
||||
Assert.NotNull(client.Player);
|
||||
Assert.NotNull(client.RemoteConfig);
|
||||
Assert.NotNull(client.Storage);
|
||||
Assert.NotNull(client.Stores);
|
||||
Assert.NotNull(client.Leaderboards);
|
||||
Assert.NotNull(client.Inventory);
|
||||
Assert.NotNull(client.BattlePass);
|
||||
Assert.NotNull(client.Quests);
|
||||
Assert.NotNull(client.Ugc);
|
||||
Assert.NotNull(client.Scenario);
|
||||
Assert.NotNull(client.Realtime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Requires_BaseUrl_And_ProjectKey()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new RudderClient(new RudderClientOptions { ProjectKey = "key" }));
|
||||
Assert.Throws<ArgumentException>(() => new RudderClient(new RudderClientOptions { BaseUrl = "http://localhost" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Uses_HttpClientTransport_By_Default()
|
||||
{
|
||||
var client = new RudderClient(new RudderClientOptions
|
||||
{
|
||||
BaseUrl = "http://localhost:8082",
|
||||
ProjectKey = "project-key"
|
||||
});
|
||||
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auth_LoginWithDevice_Saves_Tokens_And_Raises_SignedIn()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
transport.Responses[typeof(LoginViaDeviceResponse)] = new LoginViaDeviceResponse
|
||||
{
|
||||
AccessToken = "access-token",
|
||||
RefreshToken = "refresh-token"
|
||||
};
|
||||
var tokenStore = new FakeTokenStore();
|
||||
var client = CreateClient(transport: transport, tokenStore: tokenStore);
|
||||
var states = new List<RudderAuthState>();
|
||||
client.Auth.AuthStateChanged += states.Add;
|
||||
|
||||
await client.Auth.LoginWithDeviceAsync("en", "en", "nickname");
|
||||
|
||||
var request = Assert.IsType<LoginViaDeviceRequest>(transport.LastRequest);
|
||||
Assert.Equal("POST", transport.LastMethod);
|
||||
Assert.Equal("/sdk/v1/authorization/device", transport.LastPath);
|
||||
Assert.Equal("nickname", request.Nickname);
|
||||
Assert.Equal("access-token", tokenStore.AccessToken);
|
||||
Assert.Equal("refresh-token", tokenStore.RefreshToken);
|
||||
Assert.Equal(new[] { RudderAuthState.SignedIn }, states);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Auth_Logout_Clears_Tokens_And_Raises_SignedOut()
|
||||
{
|
||||
var tokenStore = new FakeTokenStore { AccessToken = "a", RefreshToken = "r" };
|
||||
var client = CreateClient(tokenStore: tokenStore);
|
||||
var states = new List<RudderAuthState>();
|
||||
client.Auth.AuthStateChanged += states.Add;
|
||||
|
||||
client.Auth.Logout();
|
||||
|
||||
Assert.Null(tokenStore.AccessToken);
|
||||
Assert.Null(tokenStore.RefreshToken);
|
||||
Assert.Equal(new[] { RudderAuthState.SignedOut }, states);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Realtime_Connect_Returns_Session_From_Injected_Transport()
|
||||
{
|
||||
var realtimeTransport = new FakeRealtimeTransport();
|
||||
var tokenStore = new FakeTokenStore { AccessToken = "access-token" };
|
||||
var client = CreateClient(
|
||||
tokenStore: tokenStore,
|
||||
realtimeTransportFactory: new FakeRealtimeTransportFactory(realtimeTransport));
|
||||
|
||||
var session = await client.Realtime.ConnectAsync();
|
||||
|
||||
Assert.NotNull(session);
|
||||
Assert.True(session.IsConnected);
|
||||
Assert.Equal("access-token", session.AccessToken);
|
||||
Assert.Equal(new Uri("ws://localhost:8090/api/realtime/ws"), realtimeTransport.Uri);
|
||||
}
|
||||
|
||||
private static RudderClient CreateClient(
|
||||
FakeTransport? transport = null,
|
||||
FakeTokenStore? tokenStore = null,
|
||||
IRealtimeTransportFactory? realtimeTransportFactory = null)
|
||||
{
|
||||
return new RudderClient(new RudderClientOptions
|
||||
{
|
||||
BaseUrl = "http://localhost:8082",
|
||||
RealtimeUrl = "ws://localhost:8090/api/realtime/ws",
|
||||
ProjectKey = "project-key",
|
||||
Transport = transport ?? new FakeTransport(),
|
||||
TokenStore = tokenStore ?? new FakeTokenStore(),
|
||||
DeviceIdProvider = new FakeDeviceIdProvider(),
|
||||
RealtimeTransportFactory = realtimeTransportFactory ?? new FakeRealtimeTransportFactory(new FakeRealtimeTransport())
|
||||
});
|
||||
}
|
||||
|
||||
internal sealed class FakeTransport : IRudderTransport
|
||||
{
|
||||
public Dictionary<Type, object> Responses { get; } = new();
|
||||
public string? LastMethod { get; private set; }
|
||||
public string? LastPath { get; private set; }
|
||||
public object? LastRequest { get; private set; }
|
||||
public string? LastAccessToken { get; private set; }
|
||||
|
||||
public Task<TResponse> SendAsync<TRequest, TResponse>(
|
||||
string method,
|
||||
string path,
|
||||
TRequest? request,
|
||||
string? accessToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
LastMethod = method;
|
||||
LastPath = path;
|
||||
LastRequest = request;
|
||||
LastAccessToken = accessToken;
|
||||
|
||||
if (Responses.TryGetValue(typeof(TResponse), out var response))
|
||||
return Task.FromResult((TResponse)response);
|
||||
|
||||
return Task.FromResult(default(TResponse)!);
|
||||
}
|
||||
}
|
||||
|
||||
internal 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 FakeRealtimeTransportFactory : IRealtimeTransportFactory
|
||||
{
|
||||
private readonly IRealtimeTransport _transport;
|
||||
|
||||
public FakeRealtimeTransportFactory(IRealtimeTransport transport) => _transport = transport;
|
||||
|
||||
public IRealtimeTransport Create() => _transport;
|
||||
}
|
||||
|
||||
private sealed class FakeRealtimeTransport : IRealtimeTransport
|
||||
{
|
||||
public Uri? Uri { get; private set; }
|
||||
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)
|
||||
{
|
||||
Uri = uri;
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
public void EmitError(Exception ex) => Error?.Invoke(ex);
|
||||
public void EmitReceived(ArraySegment<byte> data) => Received?.Invoke(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
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_Response_Can_Start_Continuation_Plan()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
transport.Enqueue(new TriggerScenarioResponse
|
||||
{
|
||||
Plans = new List<ExecutionPlan>
|
||||
{
|
||||
Plan("plan", Node("quest", "quest"))
|
||||
}
|
||||
});
|
||||
transport.Enqueue(new UpdateScenarioCounterResponse
|
||||
{
|
||||
Completed = true,
|
||||
Plan = Plan("continuation", Node("done", "notification"))
|
||||
});
|
||||
var client = CreateClient(transport);
|
||||
QuestSession? quest = null;
|
||||
var notifications = 0;
|
||||
client.Scenario.OnQuest += session => quest = session;
|
||||
client.Scenario.OnNotification += _ => notifications++;
|
||||
|
||||
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, notifications);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_Nodes_Are_Logged_And_Ignored()
|
||||
{
|
||||
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);
|
||||
|
||||
await client.Scenario.TriggerAsync("login");
|
||||
|
||||
var (level, message) = Assert.Single(logger.Messages);
|
||||
Assert.Equal(RudderLogLevel.Warning, level);
|
||||
Assert.Contains("Unsupported scenario node type 'future_node'", message);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user