diff --git a/all.sln b/all.sln index d81fcf391..8c15f50b6 100644 --- a/all.sln +++ b/all.sln @@ -251,6 +251,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Messag EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Cryptography", "test\Dapr.IntegrationTest.Cryptography\Dapr.IntegrationTest.Cryptography.csproj", "{7B14879F-156B-417E-ACA3-0B5A69CC2F39}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Actors", "test\Dapr.IntegrationTest.Actors\Dapr.IntegrationTest.Actors.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -669,6 +671,10 @@ Global {7B14879F-156B-417E-ACA3-0B5A69CC2F39}.Debug|Any CPU.Build.0 = Debug|Any CPU {7B14879F-156B-417E-ACA3-0B5A69CC2F39}.Release|Any CPU.ActiveCfg = Release|Any CPU {7B14879F-156B-417E-ACA3-0B5A69CC2F39}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -791,6 +797,7 @@ Global {97CAEE0B-4020-4A86-97DA-9900FDF4DFC6} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} {01A20A89-53A1-4D5B-B563-89E157718474} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} {7B14879F-156B-417E-ACA3-0B5A69CC2F39} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} diff --git a/src/Dapr.Actors.AspNetCore/ActorsServiceCollectionExtensions.cs b/src/Dapr.Actors.AspNetCore/ActorsServiceCollectionExtensions.cs index 120579892..e51a8c45c 100644 --- a/src/Dapr.Actors.AspNetCore/ActorsServiceCollectionExtensions.cs +++ b/src/Dapr.Actors.AspNetCore/ActorsServiceCollectionExtensions.cs @@ -107,6 +107,6 @@ private static void ConfigureActorOptions(IServiceProvider serviceProvider, Acto : DaprDefaults.GetDefaultDaprApiToken(configuration); options.HttpEndpoint = !string.IsNullOrWhiteSpace(options.HttpEndpoint) ? options.HttpEndpoint - : DaprDefaults.GetDefaultHttpEndpoint(); + : DaprDefaults.GetDefaultHttpEndpoint(configuration); } } \ No newline at end of file diff --git a/src/Dapr.Testcontainers/Common/DaprHarnessBuilder.cs b/src/Dapr.Testcontainers/Common/DaprHarnessBuilder.cs index 3488da86c..139cd23c9 100644 --- a/src/Dapr.Testcontainers/Common/DaprHarnessBuilder.cs +++ b/src/Dapr.Testcontainers/Common/DaprHarnessBuilder.cs @@ -113,20 +113,12 @@ public CryptographyHarness BuildCryptography(string keysDir) => /// /// Builds a PubSub harness. /// - /// The path to the Dapr resources. - public PubSubHarness BuildPubSub(string componentsDir) => new(_componentsDirectory, _startApp, _options, _environment); + public PubSubHarness BuildPubSub() => new(_componentsDirectory, _startApp, _options, _environment); - // /// - // /// Builds a state management harness. - // /// - // /// The path to the Dapr resources. - // public StateManagementHarness BuildStateManagement(string componentsDir) => new(_componentsDirectory, _startApp, _options, _environment); - // - // /// - // /// Builds an actor harness. - // /// - // /// The path to the Dapr resources. - // public ActorHarness BuildActors(string componentsDir) => new(_componentsDirectory, _startApp, _options, _environment); + /// + /// Builds an actor harness. + /// + public ActorHarness BuildActors() => new(_componentsDirectory, _startApp, _options, _environment); /// /// Creates a test application builder for the specified harness. diff --git a/src/Dapr.Testcontainers/Containers/Dapr/DaprdContainer.cs b/src/Dapr.Testcontainers/Containers/Dapr/DaprdContainer.cs index d29d3b621..0b7ea4d48 100644 --- a/src/Dapr.Testcontainers/Containers/Dapr/DaprdContainer.cs +++ b/src/Dapr.Testcontainers/Containers/Dapr/DaprdContainer.cs @@ -71,6 +71,7 @@ public sealed class DaprdContainer : IAsyncStartable /// The host HTTP port to bind to. /// The host gRPC port to bind to. /// The directory to write container logs to. + /// The path inside the container of an optional Dapr configuration YAML file. public DaprdContainer( string appId, string componentsHostFolder, @@ -80,7 +81,8 @@ public DaprdContainer( HostPortPair? schedulerHostAndPort = null, int? daprHttpPort = null, int? daprGrpcPort = null, - string? logDirectory = null + string? logDirectory = null, + string? configFilePath = null ) { _requestedHttpPort = daprHttpPort; @@ -101,6 +103,12 @@ public DaprdContainer( "-resources-path", componentsPath }; + if (configFilePath is not null) + { + cmd.Add("-config"); + cmd.Add(configFilePath); + } + if (placementHostAndPort is not null) { cmd.Add("-placement-host-address"); diff --git a/src/Dapr.Testcontainers/Harnesses/ActorHarness.cs b/src/Dapr.Testcontainers/Harnesses/ActorHarness.cs index 9acc3a7cc..1a7fb8266 100644 --- a/src/Dapr.Testcontainers/Harnesses/ActorHarness.cs +++ b/src/Dapr.Testcontainers/Harnesses/ActorHarness.cs @@ -12,11 +12,11 @@ // ------------------------------------------------------------------------ using System; +using System.IO; using System.Threading; using System.Threading.Tasks; using Dapr.Testcontainers.Common.Options; using Dapr.Testcontainers.Containers; -using Dapr.Testcontainers.Containers.Dapr; namespace Dapr.Testcontainers.Harnesses; @@ -26,45 +26,74 @@ namespace Dapr.Testcontainers.Harnesses; public sealed class ActorHarness : BaseHarness { private readonly RedisContainer _redis; - private readonly DaprPlacementContainer _placement; - private readonly DaprSchedulerContainer _schedueler; - private readonly string componentsDir; + private readonly bool _isSelfHostedRedis; /// /// Provides an implementation harness for Dapr's actor building block. /// /// The directory to Dapr components. /// The test app to validate in the harness. - /// The dapr runtime options. - /// The isolated environment instance. - public ActorHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options, DaprTestEnvironment? environment = null) : base(componentsDir, startApp, options, environment) + /// The Dapr runtime options. + /// + /// An optional shared . When provided the harness reuses + /// its Redis, Placement, and Scheduler services instead of starting its own. + /// + public ActorHarness(string componentsDir, Func? startApp, DaprRuntimeOptions options, DaprTestEnvironment? environment = null) + : base(componentsDir, startApp, options, environment) { - this.componentsDir = componentsDir; - _placement = new DaprPlacementContainer(options, Network, ContainerLogsDirectory); - _schedueler = new DaprSchedulerContainer(options, Network, ContainerLogsDirectory); - _redis = new RedisContainer(Network, ContainerLogsDirectory); + _redis = environment?.RedisContainer ?? new RedisContainer(Network, ContainerLogsDirectory); + _isSelfHostedRedis = environment?.RedisContainer is null; } /// - protected override async Task OnInitializeAsync(CancellationToken cancellationToken) - { - // Start infrastructure - await _redis.StartAsync(cancellationToken); - await _placement.StartAsync(cancellationToken); - await _schedueler.StartAsync(cancellationToken); - - // Emit component YAMLs pointing to Redis - RedisContainer.Yaml.WriteStateStoreYamlToFolder(componentsDir, redisHost: $"{_redis.NetworkAlias}:{RedisContainer.ContainerPort}"); + protected override async Task OnInitializeAsync(CancellationToken cancellationToken) + { + // Only start Redis if it is not provided by a shared environment. + if (_isSelfHostedRedis) + { + await _redis.StartAsync(cancellationToken); + } + + // Write the state-store component YAML that points to the Redis instance. + RedisContainer.Yaml.WriteStateStoreYamlToFolder( + ComponentsDirectory, + redisHost: $"{_redis.NetworkAlias}:{RedisContainer.ContainerPort}"); + + // Write a Dapr configuration file that enables the ActorStateTTL feature. + WriteActorConfigYaml(ComponentsDirectory); + DaprConfigFilePath = "/components/actor-config.yaml"; - DaprPlacementExternalPort = _placement.ExternalPort; - DaprSchedulerExternalPort = _schedueler.ExternalPort; + // Forward placement and scheduler coordinates from the environment. + DaprPlacementExternalPort = Environment.PlacementExternalPort; + DaprPlacementAlias = Environment.PlacementAlias; + DaprSchedulerExternalPort = Environment.SchedulerExternalPort; + DaprSchedulerAlias = Environment.SchedulerAlias; } - + + private static void WriteActorConfigYaml(string componentsDirectory) + { + const string yaml = """ + apiVersion: dapr.io/v1alpha1 + kind: Configuration + metadata: + name: actorConfig + spec: + features: + - name: "ActorStateTTL" + enabled: true + """; + Directory.CreateDirectory(componentsDirectory); + File.WriteAllText( + Path.Combine(componentsDirectory, "actor-config.yaml"), + yaml); + } + /// - protected override async ValueTask OnDisposeAsync() - { - await _redis.DisposeAsync(); - await _placement.DisposeAsync(); - await _schedueler.DisposeAsync(); - } + protected override async ValueTask OnDisposeAsync() + { + if (_isSelfHostedRedis) + { + await _redis.DisposeAsync(); + } + } } diff --git a/src/Dapr.Testcontainers/Harnesses/BaseHarness.cs b/src/Dapr.Testcontainers/Harnesses/BaseHarness.cs index 240d6e463..50c361c8a 100644 --- a/src/Dapr.Testcontainers/Harnesses/BaseHarness.cs +++ b/src/Dapr.Testcontainers/Harnesses/BaseHarness.cs @@ -160,6 +160,13 @@ public void SetAppPort(int appPort) /// protected string? DaprSchedulerAlias { get; set; } + /// + /// The path inside the Dapr sidecar container of an optional Dapr configuration YAML file. + /// Set this in before the base class creates the sidecar. + /// When set, daprd is started with -config <path>. + /// + protected string? DaprConfigFilePath { get; set; } + /// /// Pre-assigns the Dapr ports to use. This is useful when the app starts before the Dapr container. /// @@ -222,7 +229,8 @@ DaprSchedulerExternalPort is null || DaprSchedulerAlias is null ? null : new HostPortPair(DaprSchedulerAlias, DaprSchedulerContainer.InternalPort), _daprHttpPortOverride, _daprGrpcPortOverride, - _logDirectory); + _logDirectory, + DaprConfigFilePath); var daprdTask = Task.Run(async () => { diff --git a/src/Dapr.Testcontainers/Harnesses/WorkflowHarness.cs b/src/Dapr.Testcontainers/Harnesses/WorkflowHarness.cs index f352ee140..763d919fd 100644 --- a/src/Dapr.Testcontainers/Harnesses/WorkflowHarness.cs +++ b/src/Dapr.Testcontainers/Harnesses/WorkflowHarness.cs @@ -60,5 +60,11 @@ protected override async Task OnInitializeAsync(CancellationToken cancellationTo } /// - protected override ValueTask OnDisposeAsync() => ValueTask.CompletedTask; + protected override async ValueTask OnDisposeAsync() + { + if (_isSelfHostedRedis) + { + await _redis.DisposeAsync(); + } + } } diff --git a/test/Dapr.Actors.AspNetCore.IntegrationTest/ActivationTests.cs b/test/Dapr.Actors.AspNetCore.IntegrationTest/ActivationTests.cs index 2f60c6c52..f4d23e1eb 100644 --- a/test/Dapr.Actors.AspNetCore.IntegrationTest/ActivationTests.cs +++ b/test/Dapr.Actors.AspNetCore.IntegrationTest/ActivationTests.cs @@ -12,6 +12,7 @@ // ------------------------------------------------------------------------ using System; +using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; @@ -45,6 +46,44 @@ public async Task CanActivateActorWithDependencyInjection() await DeactivateActor(httpClient, "A"); } + [Fact] + public async Task InvokeMethod_UnregisteredActorType_ReturnsNonSuccessResponse() + { + await using var factory = new AppWebApplicationFactory(); + var httpClient = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions { HandleCookies = false }); + + var request = new HttpRequestMessage(HttpMethod.Put, + "http://localhost/actors/NoSuchActorType/someId/method/SomeMethod"); + var response = await httpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.False(response.IsSuccessStatusCode, + $"Expected a non-success status code for an unregistered actor type, but got {response.StatusCode}"); + } + + [Fact] + public async Task ConcurrentIncrement_SameActorId_RetainsConsistentState() + { + await using var factory = new AppWebApplicationFactory(); + var httpClient = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions { HandleCookies = false }); + + const string actorId = "concurrent-actor"; + const int concurrency = 5; + + // Fire `concurrency` increments at the same actor ID in parallel. + var tasks = Enumerable.Range(0, concurrency) + .Select(_ => IncrementCounterAsync(httpClient, actorId)) + .ToArray(); + + var results = await Task.WhenAll(tasks); + + // Each call should have received a distinct integer from 0 to concurrency-1, proving + // that the actor serialised the concurrent requests and no two calls returned the same value. + var distinct = results.Select(int.Parse).OrderBy(x => x).ToArray(); + Assert.Equal(Enumerable.Range(0, concurrency).ToArray(), distinct); + + await DeactivateActor(httpClient, actorId); + } + private async Task IncrementCounterAsync(HttpClient httpClient, string actorId) { const string actorTypeName = nameof(DependencyInjectionActor); diff --git a/test/Dapr.Actors.AspNetCore.Test/ActorHostingTest.cs b/test/Dapr.Actors.AspNetCore.Test/ActorHostingTest.cs index 53c8ec9e0..fda040f4a 100644 --- a/test/Dapr.Actors.AspNetCore.Test/ActorHostingTest.cs +++ b/test/Dapr.Actors.AspNetCore.Test/ActorHostingTest.cs @@ -11,10 +11,13 @@ // limitations under the License. // ------------------------------------------------------------------------ +using System; +using System.Collections.Generic; using System.Linq; using System.Text.Json; using Dapr.Actors.Client; using Dapr.Actors.Runtime; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -107,4 +110,75 @@ public TestActor2(ActorHost host) { } } +} + +/// +/// Tests for AddActors — verifying HttpEndpoint and DaprApiToken options are propagated to the +/// resolved . +/// +public class ActorServiceCollectionExtensionsOptionsTests +{ + [Fact] + public void AddActors_HttpEndpoint_IsReflectedInProxyFactory() + { + const string endpoint = "http://my-custom-dapr:3501"; + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddActors(options => + { + options.HttpEndpoint = endpoint; + }); + + var factory = (ActorProxyFactory)services.BuildServiceProvider().GetRequiredService(); + Assert.Equal(endpoint, factory.DefaultOptions.HttpEndpoint); + } + + [Fact] + public void AddActors_DaprApiToken_IsReflectedInProxyFactory() + { + const string token = "super-secret-token"; + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + services.AddActors(options => + { + options.DaprApiToken = token; + }); + + var factory = (ActorProxyFactory)services.BuildServiceProvider().GetRequiredService(); + Assert.Equal(token, factory.DefaultOptions.DaprApiToken); + } + + [Fact] + public void AddActors_HttpEndpointFromConfiguration_IsReflectedInProxyFactory() + { + // Verify that when no explicit HttpEndpoint is set, AddActors falls back to + // the DAPR_HTTP_ENDPOINT key in IConfiguration (e.g. in-memory config, not env var). + const string endpoint = "http://dapr-from-config:3502"; + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + + // Populate IConfiguration with the endpoint — simulates what DaprTestApplicationBuilder does. + services.AddSingleton( + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "DAPR_HTTP_ENDPOINT", endpoint } + }) + .Build()); + + services.AddActors(options => + { + // HttpEndpoint is intentionally NOT set; the fallback should read from IConfiguration. + }); + + var factory = (ActorProxyFactory)services.BuildServiceProvider().GetRequiredService(); + // GetDefaultHttpEndpoint normalizes the URL and may append a trailing slash. + Assert.StartsWith(endpoint, factory.DefaultOptions.HttpEndpoint, StringComparison.Ordinal); + } } \ No newline at end of file diff --git a/test/Dapr.Actors.AspNetCore.Test/Runtime/ActorsEndpointRouteBuilderExtensionsTests.cs b/test/Dapr.Actors.AspNetCore.Test/Runtime/ActorsEndpointRouteBuilderExtensionsTests.cs index 0bd685d48..5923c3c82 100644 --- a/test/Dapr.Actors.AspNetCore.Test/Runtime/ActorsEndpointRouteBuilderExtensionsTests.cs +++ b/test/Dapr.Actors.AspNetCore.Test/Runtime/ActorsEndpointRouteBuilderExtensionsTests.cs @@ -41,6 +41,61 @@ public async Task MapActorsHandlers_MapDaprConfigEndpoint() Assert.Equal(@"{""entities"":[""TestActor""],""reentrancy"":{""enabled"":false}}", text); } + [Fact] + public async Task MapActorsHandlers_HealthzEndpointResponds() + { + using var host = CreateHost(options => + { + options.Actors.RegisterActor(); + }); + var server = host.GetTestServer(); + var httpClient = server.CreateClient(); + + var response = await httpClient.GetAsync("/healthz", TestContext.Current.CancellationToken); + Assert.True(response.IsSuccessStatusCode, $"Expected 2xx but got {response.StatusCode}"); + } + + [Fact] + public async Task MapActorsHandlers_InvokeMethodRouteReturnsForRegisteredActor() + { + using var host = CreateHost(options => + { + options.Actors.RegisterActor(); + }); + var server = host.GetTestServer(); + var httpClient = server.CreateClient(); + + // PUT /actors/{actorTypeName}/{actorId}/method/{methodName} + var request = new System.Net.Http.HttpRequestMessage( + System.Net.Http.HttpMethod.Put, + $"/actors/RealMethodActor/actor1/method/{nameof(IRealMethodActor.PingAsync)}"); + var response = await httpClient.SendAsync(request, TestContext.Current.CancellationToken); + + // Not a 404 — route was matched and the method was invoked. + Assert.NotEqual(System.Net.HttpStatusCode.NotFound, response.StatusCode); + Assert.True(response.IsSuccessStatusCode, $"Expected 2xx but got {response.StatusCode}"); + } + + [Fact] + public async Task MapActorsHandlers_UnregisteredActorType_ThrowsInvalidOperationException() + { + using var host = CreateHost(options => + { + options.Actors.RegisterActor(); + }); + var server = host.GetTestServer(); + var httpClient = server.CreateClient(); + + // PUT /actors/{unknownType}/{id}/method/{method} — should throw because the type is not registered. + var request = new System.Net.Http.HttpRequestMessage( + System.Net.Http.HttpMethod.Put, + "/actors/DoesNotExist/id1/method/Foo"); + + // The TestServer propagates the unhandled exception from the route handler. + await Assert.ThrowsAsync( + () => httpClient.SendAsync(request, TestContext.Current.CancellationToken)); + } + private static IHost CreateHost(Action configure) where TStartup : class { var builder = Host @@ -98,4 +153,15 @@ private class TestActor : Actor, ITestActor { public TestActor(ActorHost host) : base(host) { } } + + private interface IRealMethodActor : IActor + { + Task PingAsync(); + } + + private class RealMethodActor : Actor, IRealMethodActor + { + public RealMethodActor(ActorHost host) : base(host) { } + public Task PingAsync() => Task.CompletedTask; + } } diff --git a/test/Dapr.Actors.AspNetCore.Test/Runtime/DependencyInjectionActorActivatorTests.cs b/test/Dapr.Actors.AspNetCore.Test/Runtime/DependencyInjectionActorActivatorTests.cs index 0ebb8d592..3b4d12269 100644 --- a/test/Dapr.Actors.AspNetCore.Test/Runtime/DependencyInjectionActorActivatorTests.cs +++ b/test/Dapr.Actors.AspNetCore.Test/Runtime/DependencyInjectionActorActivatorTests.cs @@ -180,4 +180,32 @@ public ValueTask DisposeAsync() return new ValueTask(); } } + + [Fact] + public async Task CreateAsync_ActorNotRegisteredWithDI_ThrowsException() + { + // The services container has no registration for ActorWithUnregisteredDep, + // so attempting to activate it should raise a meaningful exception rather than a NullRef. + var services = new ServiceCollection(); + // Intentionally do NOT register UnregisteredService. + var serviceProvider = services.BuildServiceProvider(new ServiceProviderOptions() { ValidateScopes = true, }); + var activator = new DependencyInjectionActorActivator( + serviceProvider, + ActorTypeInformation.Get(typeof(ActorWithUnregisteredDep), actorTypeName: null)); + + var host = ActorHost.CreateForTest(); + + var ex = await Assert.ThrowsAnyAsync(() => activator.CreateAsync(host)); + Assert.NotNull(ex); + } + + private class UnregisteredService { } + + private class ActorWithUnregisteredDep : Actor, ITestActor + { + public ActorWithUnregisteredDep(ActorHost host, UnregisteredService svc) + : base(host) + { + } + } } \ No newline at end of file diff --git a/test/Dapr.Actors.Test/ActorStateManagerTest.cs b/test/Dapr.Actors.Test/ActorStateManagerTest.cs index b5ae157a2..60e376c3d 100644 --- a/test/Dapr.Actors.Test/ActorStateManagerTest.cs +++ b/test/Dapr.Actors.Test/ActorStateManagerTest.cs @@ -22,6 +22,7 @@ namespace Dapr.Actors.Test; using Dapr.Actors.Communication; using Dapr.Actors.Runtime; using Moq; +using System.Linq; /// /// Contains tests for ActorStateManager. @@ -188,4 +189,633 @@ public async Task RemoveState() Assert.Equal("value1", await mngr.GetStateAsync("key1", token)); Assert.Equal("value2", await mngr.GetStateAsync("key2", token)); } + + // ----- TryAddStateAsync ----- + + [Fact] + public async Task TryAddStateAsync_ReturnsTrueForNewKey() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + Assert.True(await mngr.TryAddStateAsync("k1", "v1", token)); + Assert.Equal("v1", await mngr.GetStateAsync("k1", token)); + } + + [Fact] + public async Task TryAddStateAsync_ReturnsFalseForExistingKey() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + await mngr.AddStateAsync("k1", "v1", token); + + Assert.False(await mngr.TryAddStateAsync("k1", "v2", token)); + // Original value preserved. + Assert.Equal("v1", await mngr.GetStateAsync("k1", token)); + } + + [Fact] + public async Task TryAddStateAsync_WithTTL_ReturnsTrueForNewKey() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + Assert.True(await mngr.TryAddStateAsync("k1", "v1", TimeSpan.FromSeconds(10), token)); + Assert.Equal("v1", await mngr.GetStateAsync("k1", token)); + } + + [Fact] + public async Task TryAddStateAsync_AfterRemove_ReturnsTrueAndRestoresKey() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + await mngr.AddStateAsync("k1", "old", token); + await mngr.RemoveStateAsync("k1", token); + + // After remove the tracker marks it as Remove, so TryAdd should succeed again. + Assert.True(await mngr.TryAddStateAsync("k1", "new", token)); + Assert.Equal("new", await mngr.GetStateAsync("k1", token)); + } + + // ----- ContainsStateAsync ----- + + [Fact] + public async Task ContainsStateAsync_ReturnsTrueWhenKeyInCache() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + await mngr.AddStateAsync("k1", "v1", token); + Assert.True(await mngr.ContainsStateAsync("k1", token)); + } + + [Fact] + public async Task ContainsStateAsync_ReturnsFalseAfterRemove() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + // The store holds "stored" — a GetState causes it to enter the cache as ChangeKind.None. + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("\"stored\"", null))); + + // Load the key into the tracker (ChangeKind.None). + await mngr.GetStateAsync("k1", token); + + // Now remove — this marks the tracker entry as ChangeKind.Remove. + await mngr.RemoveStateAsync("k1", token); + + // Capture the number of store hits so far. + var callsBefore = interactor.Invocations + .Count(i => i.Method.Name == nameof(TestDaprInteractor.GetStateAsync)); + + // ContainsStateAsync with Remove in cache must return false WITHOUT contacting the store. + Assert.False(await mngr.ContainsStateAsync("k1", token)); + + var callsAfter = interactor.Invocations + .Count(i => i.Method.Name == nameof(TestDaprInteractor.GetStateAsync)); + Assert.Equal(callsBefore, callsAfter); + } + + // ----- TryRemoveStateAsync ----- + + [Fact] + public async Task TryRemoveStateAsync_ReturnsFalseForAbsentKey() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + Assert.False(await mngr.TryRemoveStateAsync("missing", token)); + } + + [Fact] + public async Task TryRemoveStateAsync_ReturnsTrueForExistingCachedKey() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + await mngr.AddStateAsync("k1", "v1", token); + Assert.True(await mngr.TryRemoveStateAsync("k1", token)); + Assert.False(await mngr.ContainsStateAsync("k1", token)); + } + + [Fact] + public async Task TryRemoveStateAsync_ReturnsFalseWhenAlreadyMarkedRemove() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + // The store holds "stored" — load it into cache as ChangeKind.None. + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("\"stored\"", null))); + + await mngr.GetStateAsync("k1", token); + + // First remove: changes ChangeKind to Remove. + Assert.True(await mngr.TryRemoveStateAsync("k1", token)); + + // Second remove: key is already marked Remove — should return false. + Assert.False(await mngr.TryRemoveStateAsync("k1", token)); + } + + // ----- GetOrAddStateAsync ----- + + [Fact] + public async Task GetOrAddStateAsync_ReturnsExistingValueWithoutOverwrite() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + await mngr.AddStateAsync("k1", "original", token); + var result = await mngr.GetOrAddStateAsync("k1", "default", token); + + Assert.Equal("original", result); + Assert.Equal("original", await mngr.GetStateAsync("k1", token)); + } + + [Fact] + public async Task GetOrAddStateAsync_AddsAndReturnsDefaultWhenAbsent() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + var result = await mngr.GetOrAddStateAsync("k1", "default", token); + + Assert.Equal("default", result); + Assert.Equal("default", await mngr.GetStateAsync("k1", token)); + } + + [Fact] + public async Task GetOrAddStateAsync_WithTTL_PreservesExistingEntryWithoutApplyingTTL() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + // Add without TTL — entry will never expire. + await mngr.AddStateAsync("k1", "original", token); + + // GetOrAdd with a very short TTL should NOT apply that TTL to the existing entry. + var result = await mngr.GetOrAddStateAsync("k1", "default", TimeSpan.FromMilliseconds(1), token); + Assert.Equal("original", result); + + // Wait past what would be the TTL and confirm the entry is still accessible. + await Task.Delay(50, TestContext.Current.CancellationToken); + Assert.Equal("original", await mngr.GetStateAsync("k1", token)); + } + + [Fact] + public async Task GetOrAddStateAsync_WithTTL_AddsWithTTLWhenAbsent() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + var result = await mngr.GetOrAddStateAsync("k1", "default", TimeSpan.FromSeconds(1), token); + Assert.Equal("default", result); + + // Should be present immediately. + Assert.Equal("default", await mngr.GetStateAsync("k1", token)); + } + + // ----- AddOrUpdateStateAsync ----- + + [Fact] + public async Task AddOrUpdateStateAsync_AddsWhenKeyAbsent() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + var result = await mngr.AddOrUpdateStateAsync("k1", "added", (k, old) => old + "_updated", token); + + Assert.Equal("added", result); + Assert.Equal("added", await mngr.GetStateAsync("k1", token)); + } + + [Fact] + public async Task AddOrUpdateStateAsync_UpdatesWhenKeyPresentInCache() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + await mngr.AddStateAsync("k1", "original", token); + var result = await mngr.AddOrUpdateStateAsync("k1", "added", (k, old) => old + "_updated", token); + + Assert.Equal("original_updated", result); + Assert.Equal("original_updated", await mngr.GetStateAsync("k1", token)); + } + + [Fact] + public async Task AddOrUpdateStateAsync_AddsValueWhenKeyMarkedForRemove() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + // Load "stored" from store (ChangeKind.None) then mark it for remove (ChangeKind.Remove). + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("\"stored\"", null))); + + await mngr.GetStateAsync("k1", token); + await mngr.RemoveStateAsync("k1", token); + + // Key is marked Remove — should use addValue (not call the update factory). + var result = await mngr.AddOrUpdateStateAsync("k1", "added", (k, old) => old + "_updated", token); + Assert.Equal("added", result); + Assert.Equal("added", await mngr.GetStateAsync("k1", token)); + } + + [Fact] + public async Task AddOrUpdateStateAsync_PromotesNoneToUpdate() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + // Pretend the store holds "stored" — TryGet loads it into cache with ChangeKind.None. + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("\"stored\"", null))); + + // Prime the cache: TryGetStateAsync loads it with ChangeKind.None. + await mngr.GetStateAsync("k1", token); + + // Now AddOrUpdate; the key is in cache with ChangeKind.None so it should be promoted to Update. + string capturedData = null; + interactor + .Setup(d => d.SaveStateTransactionallyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, data, _) => capturedData = data) + .Returns(Task.CompletedTask); + + await mngr.AddOrUpdateStateAsync("k1", "added", (k, old) => old + "_updated", token); + await mngr.SaveStateAsync(token); + + Assert.NotNull(capturedData); + Assert.Contains("upsert", capturedData); + } + + [Fact] + public async Task AddOrUpdateStateAsync_WithTTL_AddsWhenKeyAbsent() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + var result = await mngr.AddOrUpdateStateAsync("k1", "added", (k, old) => old + "_updated", TimeSpan.FromSeconds(10), token); + Assert.Equal("added", result); + Assert.Equal("added", await mngr.GetStateAsync("k1", token)); + } + + // ----- ClearCacheAsync ----- + + [Fact] + public async Task ClearCacheAsync_DiscardsUnpersistedWrites() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + // Store returns empty by default. + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + await mngr.AddStateAsync("k1", "v1", token); + + // Clear the in-memory cache before saving. + await mngr.ClearCacheAsync(token); + + // After clear the store still returns empty, so the key should be absent. + await Assert.ThrowsAsync(() => mngr.GetStateAsync("k1", token)); + } + + [Fact] + public async Task ClearCacheAsync_AllowsRereadingFromStore() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + // The store holds "persisted". + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("\"persisted\"", null))); + + // Write something different to cache but don't save. + await mngr.SetStateAsync("k1", "in-memory", token); + + // Clear cache — the next read should re-fetch from the store. + await mngr.ClearCacheAsync(token); + Assert.Equal("persisted", await mngr.GetStateAsync("k1", token)); + } + + // ----- SaveStateAsync correctness ----- + + [Fact] + public async Task SaveStateAsync_DoesNotCallStoreWhenNothingChanged() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + // Nothing added or changed — save should be a no-op. + await mngr.SaveStateAsync(token); + + interactor.Verify( + d => d.SaveStateTransactionallyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SaveStateAsync_SecondSaveIsNoOpAfterFirstSave() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + interactor + .Setup(d => d.SaveStateTransactionallyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + await mngr.AddStateAsync("k1", "v1", token); + await mngr.SaveStateAsync(token); + + // First save should call the store exactly once. + interactor.Verify( + d => d.SaveStateTransactionallyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + + // Second save with no additional changes must be a no-op. + await mngr.SaveStateAsync(token); + interactor.Verify( + d => d.SaveStateTransactionallyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task SaveStateAsync_RemoveEvictsEntryFromTracker() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + // The store holds "stored" — load it into cache with ChangeKind.None. + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("\"stored\"", null))); + interactor + .Setup(d => d.SaveStateTransactionallyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + await mngr.GetStateAsync("k1", token); + + // Mark for removal. + await mngr.RemoveStateAsync("k1", token); + await mngr.SaveStateAsync(token); + + // After save the tracker evicts the key, so TryAdd with the store returning empty succeeds. + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + Assert.True(await mngr.TryAddStateAsync("k1", "v2", token)); + } + + // ----- SetState with TTL on cached entry ----- + + [Fact] + public async Task SetStateAsync_WithTTL_UpdatesTTLOnCachedNoneEntry() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + // Load "stored" into cache with ChangeKind.None. + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("\"stored\"", null))); + await mngr.GetStateAsync("k1", token); + + // Now set with a TTL — should promote to Update and set TTLExpireTime. + string capturedData = null; + interactor + .Setup(d => d.SaveStateTransactionallyAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, data, _) => capturedData = data) + .Returns(Task.CompletedTask); + + await mngr.SetStateAsync("k1", "updated", TimeSpan.FromSeconds(60), token); + await mngr.SaveStateAsync(token); + + Assert.NotNull(capturedData); + Assert.Contains("upsert", capturedData); + Assert.Contains("ttlInSeconds", capturedData); + } + + // ----- SetStateContext (reentrancy) ----- + + [Fact] + public async Task SetStateContext_IsolatesContextualTrackerFromDefaultTracker() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var actor = new TestActor(host); + var mngr = new ActorStateManager(actor); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + // Write to the default (non-contextual) tracker. + await mngr.AddStateAsync("default-key", "default-value", token); + + // Switch to a contextual tracker (simulate reentrancy context). + await ((IActorContextualState)mngr).SetStateContext("ctx1"); + + // The contextual tracker is empty — default-key is not visible. + await Assert.ThrowsAsync(() => mngr.GetStateAsync("default-key", token)); + + // Write to the contextual tracker. + await mngr.AddStateAsync("ctx-key", "ctx-value", token); + + // Clear context — revert to default tracker. + await ((IActorContextualState)mngr).SetStateContext(null); + + // Default tracker still has default-key but not ctx-key. + Assert.Equal("default-value", await mngr.GetStateAsync("default-key", token)); + await Assert.ThrowsAsync(() => mngr.GetStateAsync("ctx-key", token)); + } + + [Fact] + public async Task SetStateContext_TwoContextsHaveIndependentTrackers() + { + var interactor = new Mock(); + var host = ActorHost.CreateForTest(); + host.StateProvider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var mngr = new ActorStateManager(new TestActor(host)); + var token = CancellationToken.None; + + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("", null))); + + // Two Tasks represent two concurrent reentrant calls, each with their own context. + string ctx1Result = null; + string ctx2Result = null; + bool ctx1Saw = false; + + var ct = TestContext.Current.CancellationToken; + var t1 = Task.Run(async () => + { + await ((IActorContextualState)mngr).SetStateContext("ctx1"); + await mngr.AddStateAsync("shared-key", "from-ctx1", ct); + ctx1Result = await mngr.GetStateAsync("shared-key", ct); + // ctx2's value should NOT be visible here. + ctx1Saw = await mngr.ContainsStateAsync("ctx2-only", ct); + }, ct); + + var t2 = Task.Run(async () => + { + await ((IActorContextualState)mngr).SetStateContext("ctx2"); + await mngr.AddStateAsync("shared-key", "from-ctx2", ct); + await mngr.AddStateAsync("ctx2-only", "yes", ct); + ctx2Result = await mngr.GetStateAsync("shared-key", ct); + }, ct); + + await Task.WhenAll(t1, t2); + + Assert.Equal("from-ctx1", ctx1Result); + Assert.Equal("from-ctx2", ctx2Result); + Assert.False(ctx1Saw); + } } diff --git a/test/Dapr.Actors.Test/DaprStateProviderTest.cs b/test/Dapr.Actors.Test/DaprStateProviderTest.cs index 5c6839f72..370f284c4 100644 --- a/test/Dapr.Actors.Test/DaprStateProviderTest.cs +++ b/test/Dapr.Actors.Test/DaprStateProviderTest.cs @@ -126,4 +126,75 @@ public async Task TryLoadStateAsync() resp = await provider.TryLoadStateAsync("actorType", "actorId", "key", token); Assert.False(resp.HasValue); } + + [Fact] + public async Task SaveStateAsync_Remove_EmitsDeleteOperation() + { + var interactor = new Mock(); + var provider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var token = new CancellationToken(); + + var stateChanges = new List + { + new ActorStateChange("key1", typeof(string), null, StateChangeKind.Remove, null), + }; + + string capturedContent = null; + interactor + .Setup(d => d.SaveStateTransactionallyAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, data, _) => capturedContent = data) + .Returns(Task.CompletedTask); + + await provider.SaveStateAsync("actorType", "actorId", stateChanges, token); + + Assert.NotNull(capturedContent); + Assert.Equal( + "[{\"operation\":\"delete\",\"request\":{\"key\":\"key1\"}}]", + capturedContent); + } + + [Fact] + public async Task SaveStateAsync_Update_EmitsUpsertOperation() + { + var interactor = new Mock(); + var provider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var token = new CancellationToken(); + + var stateChanges = new List + { + new ActorStateChange("key1", typeof(string), "updated", StateChangeKind.Update, null), + }; + + string capturedContent = null; + interactor + .Setup(d => d.SaveStateTransactionallyAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, data, _) => capturedContent = data) + .Returns(Task.CompletedTask); + + await provider.SaveStateAsync("actorType", "actorId", stateChanges, token); + + Assert.NotNull(capturedContent); + Assert.Equal( + "[{\"operation\":\"upsert\",\"request\":{\"key\":\"key1\",\"value\":\"updated\"}}]", + capturedContent); + } + + [Fact] + public async Task TryLoadStateAsync_ReturnsFalseWhenTTLExpireTimeIsExactlyNow() + { + var interactor = new Mock(); + var provider = new DaprStateProvider(interactor.Object, new JsonSerializerOptions()); + var token = new CancellationToken(); + + // TTL exactly equal to UtcNow means the entry has just expired (boundary condition). + var ttl = DateTime.UtcNow; + interactor + .Setup(d => d.GetStateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ActorStateResponse("\"value\"", ttl))); + + var resp = await provider.TryLoadStateAsync("actorType", "actorId", "key", token); + Assert.False(resp.HasValue); + } } \ No newline at end of file diff --git a/test/Dapr.Actors.Test/Runtime/ActorRuntimeTests.cs b/test/Dapr.Actors.Test/Runtime/ActorRuntimeTests.cs index 8db7ac25e..965ede5bd 100644 --- a/test/Dapr.Actors.Test/Runtime/ActorRuntimeTests.cs +++ b/test/Dapr.Actors.Test/Runtime/ActorRuntimeTests.cs @@ -474,4 +474,112 @@ public override Task DeleteAsync(ActorActivatorState state) return base.DeleteAsync(state); } } + + // ----- SerializeSettingsAndRegisteredTypes gaps ----- + + [Fact] + public async Task SerializeSettingsAndRegisteredTypes_ZeroActors_EmptyEntitiesArray() + { + var options = new ActorRuntimeOptions(); + var runtime = new ActorRuntime(options, loggerFactory, activatorFactory, proxyFactory); + + ArrayBufferWriter writer = new ArrayBufferWriter(); + await runtime.SerializeSettingsAndRegisteredTypes(writer); + + var json = Encoding.UTF8.GetString(writer.WrittenSpan.ToArray()); + var document = JsonDocument.Parse(json); + var entities = document.RootElement.GetProperty("entities"); + Assert.Equal(0, entities.GetArrayLength()); + } + + [Fact] + public async Task SerializeSettingsAndRegisteredTypes_DefaultOptions_OmitsTimeoutAndDrainFields() + { + var options = new ActorRuntimeOptions(); + options.Actors.RegisterActor(); + var runtime = new ActorRuntime(options, loggerFactory, activatorFactory, proxyFactory); + + ArrayBufferWriter writer = new ArrayBufferWriter(); + await runtime.SerializeSettingsAndRegisteredTypes(writer); + + var json = Encoding.UTF8.GetString(writer.WrittenSpan.ToArray()); + var document = JsonDocument.Parse(json); + var root = document.RootElement; + + Assert.False(root.TryGetProperty("actorIdleTimeout", out _), + "actorIdleTimeout should not be serialized when not set"); + Assert.False(root.TryGetProperty("actorScanInterval", out _), + "actorScanInterval should not be serialized when not set"); + Assert.False(root.TryGetProperty("drainOngoingCallTimeout", out _), + "drainOngoingCallTimeout should not be serialized when not set"); + Assert.False(root.TryGetProperty("drainRebalancedActors", out _), + "drainRebalancedActors should not be serialized when false (default)"); + } + + [Fact] + public async Task SerializeSettingsAndRegisteredTypes_PerActorReentrancyConfig() + { + var options = new ActorRuntimeOptions(); + var perActorOptions = new ActorRuntimeOptions(); + perActorOptions.ReentrancyConfig.Enabled = true; + perActorOptions.ReentrancyConfig.MaxStackDepth = 8; + options.Actors.RegisterActor(perActorOptions); + + var runtime = new ActorRuntime(options, loggerFactory, activatorFactory, proxyFactory); + + ArrayBufferWriter writer = new ArrayBufferWriter(); + await runtime.SerializeSettingsAndRegisteredTypes(writer); + + var json = Encoding.UTF8.GetString(writer.WrittenSpan.ToArray()); + var document = JsonDocument.Parse(json); + var root = document.RootElement; + + // Per-actor reentrancy should appear in entitiesConfig. + var entitiesConfig = root.GetProperty("entitiesConfig"); + Assert.Equal(1, entitiesConfig.GetArrayLength()); + + var perActor = entitiesConfig[0]; + Assert.True(perActor.GetProperty("reentrancy").GetProperty("enabled").GetBoolean()); + Assert.Equal(8, perActor.GetProperty("reentrancy").GetProperty("maxStackDepth").GetInt32()); + } + + // ----- DispatchWithoutRemotingAsync with unknown actor type ----- + + [Fact] + public async Task DispatchWithoutRemotingAsync_UnknownActorType_ThrowsInvalidOperationException() + { + var options = new ActorRuntimeOptions(); + options.Actors.RegisterActor(); + var runtime = new ActorRuntime(options, loggerFactory, activatorFactory, proxyFactory); + + await Assert.ThrowsAsync(async () => + { + await runtime.DispatchWithoutRemotingAsync( + "NonExistentActorType", + "someId", + "MyMethod", + new MemoryStream(), + new MemoryStream(), + TestContext.Current.CancellationToken); + }); + } + + [Fact] + public async Task DispatchWithoutRemotingAsync_UnknownMethod_ThrowsException() + { + var options = new ActorRuntimeOptions(); + options.Actors.RegisterActor(); + var runtime = new ActorRuntime(options, loggerFactory, activatorFactory, proxyFactory); + + await Assert.ThrowsAsync(async () => + { + await runtime.DispatchWithoutRemotingAsync( + nameof(MyActor), + "someId", + "NoSuchMethod", + new MemoryStream(), + new MemoryStream(), + TestContext.Current.CancellationToken); + }); + } } diff --git a/test/Dapr.IntegrationTest.Actors/Actors/ExceptionTesting/ExceptionActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/ExceptionTesting/ExceptionActor.cs new file mode 100644 index 000000000..b16171639 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/ExceptionTesting/ExceptionActor.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors.Runtime; + +namespace Dapr.IntegrationTest.Actors.ExceptionTesting; + +/// +/// Implementation of that unconditionally throws to +/// validate that the Dapr runtime correctly propagates remote exceptions to callers. +/// +public class ExceptionActor(ActorHost host) : Actor(host), IExceptionActor +{ + /// + public Task Ping(CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + public Task ExceptionExample() => + throw new InvalidOperationException("This exception is intentional."); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/ExceptionTesting/IExceptionActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/ExceptionTesting/IExceptionActor.cs new file mode 100644 index 000000000..750137c63 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/ExceptionTesting/IExceptionActor.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading.Tasks; +using Dapr.Actors; + +namespace Dapr.IntegrationTest.Actors.ExceptionTesting; + +/// +/// Actor interface that deliberately throws an exception to exercise error propagation. +/// +public interface IExceptionActor : IPingActor, IActor +{ + /// + /// Always throws an to validate remote exception handling. + /// + Task ExceptionExample(); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/IPingActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/IPingActor.cs new file mode 100644 index 000000000..ab9908f46 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/IPingActor.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; + +namespace Dapr.IntegrationTest.Actors; + +/// +/// Minimal actor interface used as a readiness probe. +/// +public interface IPingActor : IActor +{ + /// + /// Pings the actor to verify that the runtime is available. + /// + /// + /// A token that cancels the underlying HTTP request, allowing the caller to impose a + /// per-attempt timeout instead of waiting for the HttpClient default (100 s). + /// + Task Ping(CancellationToken cancellationToken = default); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/Reentrancy/IReentrantActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/Reentrancy/IReentrantActor.cs new file mode 100644 index 000000000..6a64de057 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/Reentrancy/IReentrantActor.cs @@ -0,0 +1,85 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Dapr.Actors; + +namespace Dapr.IntegrationTest.Actors.Reentrancy; + +/// +/// Options controlling how a reentrant call chain is executed. +/// +public sealed class ReentrantCallOptions +{ + /// + /// Gets or sets the number of additional reentrant calls remaining to make. + /// + public int CallsRemaining { get; set; } + + /// + /// Gets or sets the zero-based sequence number of the current call. + /// + public int CallNumber { get; set; } +} + +/// +/// Records a single enter or exit event within a reentrant call chain. +/// +public sealed class CallRecord +{ + /// + /// Gets or sets a value indicating whether this record represents an entry () or exit (). + /// + public bool IsEnter { get; set; } + + /// + /// Gets or sets the wall-clock time of this event. + /// + public DateTime Timestamp { get; set; } + + /// + /// Gets or sets the sequence number of the call that produced this record. + /// + public int CallNumber { get; set; } +} + +/// +/// Per-call state kept by . +/// +public sealed class ReentrantCallState +{ + /// + /// Gets the ordered list of enter/exit records for a single call number. + /// + public List Records { get; init; } = []; +} + +/// +/// Actor interface that exercises Dapr actor reentrancy. +/// +public interface IReentrantActor : IPingActor, IActor +{ + /// + /// Initiates a reentrant call chain as described by . + /// + /// Controls the depth and sequence number of the reentrant chain. + Task ReentrantCall(ReentrantCallOptions callOptions); + + /// + /// Returns the enter/exit records accumulated for the given . + /// + /// The zero-based call number to retrieve state for. + Task GetState(int callNumber); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/Reentrancy/ReentrantActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/Reentrancy/ReentrantActor.cs new file mode 100644 index 000000000..4e269bbc1 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/Reentrancy/ReentrantActor.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors.Runtime; + +namespace Dapr.IntegrationTest.Actors.Reentrancy; + +/// +/// Implementation of that recursively calls itself +/// to produce a chain of reentrant invocations. +/// +public class ReentrantActor(ActorHost host) : Actor(host), IReentrantActor +{ + /// + public Task Ping(CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + public async Task ReentrantCall(ReentrantCallOptions callOptions) + { + await UpdateState(isEnter: true, callOptions.CallNumber); + + var self = ProxyFactory.CreateActorProxy(Id, "ReentrantActor"); + if (callOptions.CallsRemaining <= 1) + { + await self.Ping(); + } + else + { + await self.ReentrantCall(new ReentrantCallOptions + { + CallsRemaining = callOptions.CallsRemaining - 1, + CallNumber = callOptions.CallNumber + 1, + }); + } + + await UpdateState(isEnter: false, callOptions.CallNumber); + } + + /// + public Task GetState(int callNumber) => + StateManager.GetOrAddStateAsync($"reentrant-record{callNumber}", new ReentrantCallState()); + + private async Task UpdateState(bool isEnter, int callNumber) + { + var stateKey = $"reentrant-record{callNumber}"; + var state = await StateManager.GetOrAddStateAsync(stateKey, new ReentrantCallState()); + state.Records.Add(new CallRecord + { + IsEnter = isEnter, + Timestamp = DateTime.Now, + CallNumber = callNumber, + }); + await StateManager.SetStateAsync(stateKey, state); + + if (!isEnter) + { + await StateManager.SaveStateAsync(); + } + } +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/Regression/IRegressionActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/Regression/IRegressionActor.cs new file mode 100644 index 000000000..3a0ed00bf --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/Regression/IRegressionActor.cs @@ -0,0 +1,65 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading.Tasks; +using Dapr.Actors; + +namespace Dapr.IntegrationTest.Actors.Regression; + +/// +/// Describes a state operation to be executed by . +/// +public sealed class StateCall +{ + /// + /// Gets or sets the state key to operate on. + /// + public string? Key { get; set; } + + /// + /// Gets or sets the value to write, if applicable. + /// + public string? Value { get; set; } + + /// + /// Gets or sets the operation to perform. + /// Valid values are "SetState", "SaveState", and "ThrowException". + /// + public string? Operation { get; set; } +} + +/// +/// Actor interface used to reproduce regression #762, which validated that an exception +/// thrown mid-method correctly rolls back pending state changes instead of persisting them. +/// +public interface IRegressionActor : IPingActor, IActor +{ + /// + /// Returns the value stored under , or an empty string when absent. + /// + /// The state key to retrieve. + Task GetState(string id); + + /// + /// Executes the state operation described by . + /// Throws when is "ThrowException". + /// + /// The operation to execute. + Task SaveState(StateCall call); + + /// + /// Removes the state entry identified by . + /// + /// The state key to remove. + Task RemoveState(string id); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/Regression/RegressionActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/Regression/RegressionActor.cs new file mode 100644 index 000000000..0f77404e4 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/Regression/RegressionActor.cs @@ -0,0 +1,59 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors.Runtime; + +namespace Dapr.IntegrationTest.Actors.Regression; + +/// +/// Implementation of that reproduces the scenario from +/// GitHub issue #762: an exception thrown mid-method must roll back pending state changes. +/// +public class RegressionActor(ActorHost host) : Actor(host), IRegressionActor +{ + /// + public Task Ping(CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + public async Task GetState(string id) + { + var data = await StateManager.TryGetStateAsync(id); + return data.HasValue ? data.Value : string.Empty; + } + + /// + public async Task RemoveState(string id) + { + await StateManager.TryRemoveStateAsync(id); + } + + /// + public async Task SaveState(StateCall call) + { + switch (call.Operation) + { + case "ThrowException": + await StateManager.SetStateAsync(call.Key!, call.Value!); + throw new NotImplementedException("Intentional exception to test state rollback."); + case "SetState": + await StateManager.SetStateAsync(call.Key!, call.Value!); + break; + case "SaveState": + await StateManager.SaveStateAsync(); + break; + } + } +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/Reminders/IReminderActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/Reminders/IReminderActor.cs new file mode 100644 index 000000000..16a264170 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/Reminders/IReminderActor.cs @@ -0,0 +1,91 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading.Tasks; +using Dapr.Actors; + +namespace Dapr.IntegrationTest.Actors.Reminders; + +/// +/// Options for starting a reminder with a finite count. +/// +public sealed class StartReminderOptions +{ + /// + /// Gets or sets the total number of times the reminder should fire before stopping itself. + /// + public int Total { get; set; } +} + +/// +/// Captures the state maintained by . +/// +public sealed class ReminderState +{ + /// + /// Gets or sets the number of times the reminder has fired. + /// + public int Count { get; set; } + + /// + /// Gets or sets a value indicating whether the reminder is currently running. + /// + public bool IsReminderRunning { get; set; } + + /// + /// Gets or sets the timestamp of the last reminder invocation. + /// + public DateTime Timestamp { get; set; } +} + +/// +/// Actor interface that exercises Dapr reminder registration and management. +/// +public interface IReminderActor : IPingActor, IActor +{ + /// + /// Starts a self-limiting reminder that fires times. + /// + /// Reminder configuration. + Task StartReminder(StartReminderOptions options); + + /// + /// Starts a reminder that expires after . + /// + /// The time-to-live for the reminder. + Task StartReminderWithTtl(TimeSpan ttl); + + /// + /// Starts a reminder that fires exactly times. + /// + /// The maximum number of reminder invocations. + Task StartReminderWithRepetitions(int repetitions); + + /// + /// Starts a reminder that fires at most times and expires after . + /// + /// The time-to-live for the reminder. + /// The maximum number of reminder invocations. + Task StartReminderWithTtlAndRepetitions(TimeSpan ttl, int repetitions); + + /// + /// Returns the current reminder state. + /// + Task GetState(); + + /// + /// Returns the serialized JSON representation of the active reminder, or "null" when none is registered. + /// + Task GetReminder(); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/Reminders/ReminderActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/Reminders/ReminderActor.cs new file mode 100644 index 000000000..243d5f06f --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/Reminders/ReminderActor.cs @@ -0,0 +1,122 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors.Runtime; +namespace Dapr.IntegrationTest.Actors.Reminders; + +/// +/// Implementation of that manages Dapr reminders and tracks +/// invocation counts in actor state. +/// +public class ReminderActor(ActorHost host) : Actor(host), IReminderActor, IRemindable +{ + private const string StateKey = "reminder-state"; + + /// + public Task Ping(CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + public Task GetState() => + StateManager.GetOrAddStateAsync(StateKey, new ReminderState()); + + /// + public async Task GetReminder() + { + try + { + var reminder = await GetReminderAsync("test-reminder"); + return JsonSerializer.Serialize(reminder, Host.JsonSerializerOptions); + } + catch (DaprApiException ex) when (ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase)) + { + // Dapr 1.12+ returns a 404 error when the reminder does not exist; earlier + // versions returned 500 which the SDK silently mapped to null. Return "null" + // to match the pre-registered / post-stopped state that the test polls for. + return "null"; + } + } + + /// + public async Task StartReminder(StartReminderOptions options) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(options, Host.JsonSerializerOptions); + await RegisterReminderAsync( + "test-reminder", bytes, + dueTime: TimeSpan.Zero, + period: TimeSpan.FromMilliseconds(50)); + await StateManager.SetStateAsync(StateKey, new ReminderState { IsReminderRunning = true }); + } + + /// + public async Task StartReminderWithTtl(TimeSpan ttl) + { + var options = new StartReminderOptions { Total = 100 }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(options, Host.JsonSerializerOptions); + await RegisterReminderAsync( + "test-reminder-ttl", bytes, + dueTime: TimeSpan.Zero, + period: TimeSpan.FromSeconds(1), + ttl: ttl); + await StateManager.SetStateAsync(StateKey, new ReminderState { IsReminderRunning = true }); + } + + /// + public async Task StartReminderWithRepetitions(int repetitions) + { + var options = new StartReminderOptions { Total = 100 }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(options, Host.JsonSerializerOptions); + await RegisterReminderAsync( + "test-reminder-repetition", bytes, + dueTime: TimeSpan.Zero, + period: TimeSpan.FromSeconds(1), + repetitions: repetitions); + await StateManager.SetStateAsync(StateKey, new ReminderState { IsReminderRunning = true }); + } + + /// + public async Task StartReminderWithTtlAndRepetitions(TimeSpan ttl, int repetitions) + { + var options = new StartReminderOptions { Total = 100 }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(options, Host.JsonSerializerOptions); + await RegisterReminderAsync( + "test-reminder-ttl-repetition", bytes, + dueTime: TimeSpan.Zero, + period: TimeSpan.FromSeconds(1), + repetitions: repetitions, + ttl: ttl); + await StateManager.SetStateAsync(StateKey, new ReminderState { IsReminderRunning = true }); + } + + /// + public async Task ReceiveReminderAsync(string reminderName, byte[] state, TimeSpan dueTime, TimeSpan period) + { + if (!reminderName.StartsWith("test-reminder", StringComparison.Ordinal)) + return; + + var options = JsonSerializer.Deserialize(state, Host.JsonSerializerOptions)!; + var current = await StateManager.GetStateAsync(StateKey); + + if (++current.Count == options.Total) + { + await UnregisterReminderAsync("test-reminder"); + current.IsReminderRunning = false; + } + + current.Timestamp = DateTime.Now; + await StateManager.SetStateAsync(StateKey, current); + } +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/Serialization/ISerializationActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/Serialization/ISerializationActor.cs new file mode 100644 index 000000000..7e473d4fe --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/Serialization/ISerializationActor.cs @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; + +namespace Dapr.IntegrationTest.Actors.Serialization; + +/// +/// A round-trip payload used to validate custom JSON serialization via actor remoting. +/// +/// The primary message string. +public record SerializationPayload(string Message) +{ + /// + /// Gets or sets an arbitrary JSON element carried inside the payload. + /// + public JsonElement Value { get; set; } + + /// + /// Gets or sets extension data that should survive a round-trip through the actor runtime. + /// + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } +} + +/// +/// Actor interface that validates custom JSON serialization behaviour when invoking actor methods. +/// +public interface ISerializationActor : IActor, IPingActor +{ + /// + /// Echoes back to the caller to verify that custom + /// JSON serializer options are respected during remoting. + /// + /// An arbitrary label for the operation. + /// The payload to echo. + /// A token to cancel the call. + Task SendAsync(string name, SerializationPayload payload, CancellationToken cancellationToken = default); + + /// + /// Echoes as a to verify that + /// multiple method overloads are dispatched correctly with the custom serializer. + /// + /// The date/time value to echo. + Task AnotherMethod(DateTime payload); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/Serialization/SerializationActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/Serialization/SerializationActor.cs new file mode 100644 index 000000000..0cd5af05c --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/Serialization/SerializationActor.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors.Runtime; + +namespace Dapr.IntegrationTest.Actors.Serialization; + +/// +/// Implementation of that echoes its inputs back +/// to the caller, allowing tests to verify custom JSON serializer round-trips. +/// +public class SerializationActor(ActorHost host) : Actor(host), ISerializationActor +{ + /// + public Task Ping(CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + public Task SendAsync( + string name, + SerializationPayload payload, + CancellationToken cancellationToken = default) => + Task.FromResult(payload); + + /// + public Task AnotherMethod(DateTime payload) => + Task.FromResult(payload); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/State/AdvancedStateActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/State/AdvancedStateActor.cs new file mode 100644 index 000000000..cc4d6515e --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/State/AdvancedStateActor.cs @@ -0,0 +1,102 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors.Runtime; + +namespace Dapr.IntegrationTest.Actors.State; + +/// +/// Implementation of that exercises the full +/// breadth of the Dapr state manager API in a way that validates in-memory caching +/// behaviour, concurrent key operations, and correct GetOrAdd / AddOrUpdate +/// semantics. +/// +public class AdvancedStateActor(ActorHost host) : Actor(host), IAdvancedStateActor +{ + /// + public Task Ping(CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + public async Task SetAndGetWithinSameActivation(string key, string value) + { + // Write without saving — the value must be readable from cache immediately. + await StateManager.SetStateAsync(key, value); + return await StateManager.GetStateAsync(key); + } + + /// + public Task ContainsKey(string key) => + StateManager.ContainsStateAsync(key); + + /// + public async Task RemoveAndCheckExists(string key) + { + await StateManager.TryRemoveStateAsync(key); + var exists = await StateManager.ContainsStateAsync(key); + return new StateCheckResult { Exists = exists }; + } + + /// + public Task GetOrAdd(string key, string defaultValue) => + StateManager.GetOrAddStateAsync(key, defaultValue); + + /// + public Task AddOrUpdate(string key, string addValue, string updateValue) => + StateManager.AddOrUpdateStateAsync(key, addValue, (_, _) => updateValue); + + /// + public async Task TryAdd(string key, string value) => + await StateManager.TryAddStateAsync(key, value); + + /// + public async Task TryGet(string key) + { + var result = await StateManager.TryGetStateAsync(key); + return new StateCheckResult + { + Exists = result.HasValue, + Value = result.HasValue ? result.Value : null, + }; + } + + /// + public async Task SetMultipleAndGetAll( + string key1, string value1, + string key2, string value2) + { + await StateManager.SetStateAsync(key1, value1); + await StateManager.SetStateAsync(key2, value2); + return + [ + await StateManager.GetStateAsync(key1), + await StateManager.GetStateAsync(key2), + ]; + } + + /// + public async Task OverwriteAndRead(string key, string value1, string value2) + { + await StateManager.SetStateAsync(key, value1); + await StateManager.SetStateAsync(key, value2); + return await StateManager.GetStateAsync(key); + } + + /// + public async Task Read(string key) + { + var result = await StateManager.TryGetStateAsync(key); + return result.HasValue ? result.Value : null; + } +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/State/IAdvancedStateActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/State/IAdvancedStateActor.cs new file mode 100644 index 000000000..c57b4f192 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/State/IAdvancedStateActor.cs @@ -0,0 +1,125 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading.Tasks; +using Dapr.Actors; + +namespace Dapr.IntegrationTest.Actors.State; + +/// +/// Result returned by state existence and try-get operations. +/// +public sealed class StateCheckResult +{ + /// + /// Gets or sets a value indicating whether the state key exists in the store. + /// + public bool Exists { get; set; } + + /// + /// Gets or sets the stored value, or when the key does not exist. + /// + public string? Value { get; set; } +} + +/// +/// Actor interface that exercises the full breadth of state management operations, +/// including AddState, GetOrAdd, AddOrUpdate, ContainsState, +/// TryGetState, RemoveState, and SaveState — both individually and in +/// combinations that validate the in-memory caching behaviour of the state manager. +/// +public interface IAdvancedStateActor : IPingActor, IActor +{ + /// + /// Sets to without calling + /// , then immediately returns the cached value. + /// This verifies that the in-memory write-through cache makes a just-set value readable + /// within the same activation without a round-trip to the state store. + /// + /// The state key. + /// The value to write. + Task SetAndGetWithinSameActivation(string key, string value); + + /// + /// Returns whether currently exists in state. + /// + /// The state key to check. + Task ContainsKey(string key); + + /// + /// Removes from state, then immediately checks whether it still + /// exists, validating that a removal is reflected in the cache before it is persisted. + /// + /// The state key to remove. + Task RemoveAndCheckExists(string key); + + /// + /// Calls GetOrAdd with the supplied default. If the key already exists the stored + /// value is returned; otherwise the default is stored and returned. + /// + /// The state key. + /// The default value to use when the key does not exist. + Task GetOrAdd(string key, string defaultValue); + + /// + /// Calls AddOrUpdate: adds the key with when absent, + /// or replaces the existing value with when present. + /// + /// The state key. + /// The value to write when the key does not yet exist. + /// The value to write when the key already exists. + Task AddOrUpdate(string key, string addValue, string updateValue); + + /// + /// Attempts to add via AddStateAsync when the key does + /// not already exist; returns when the add succeeds, or + /// when the key was already present. + /// + /// The state key. + /// The value to write if the key is absent. + Task TryAdd(string key, string value); + + /// + /// Tries to retrieve and returns a + /// describing whether the key exists and, if so, its value. + /// + /// The state key. + Task TryGet(string key); + + /// + /// Sets multiple independent keys in a single actor activation and returns all their values, + /// verifying that independent keys do not interfere with each other in the cache. + /// + /// First state key. + /// First value. + /// Second state key. + /// Second value. + Task SetMultipleAndGetAll(string key1, string value1, string key2, string value2); + + /// + /// Sets to , then overwrites it with + /// in the same activation, and returns the final value. + /// Verifies that a second SetStateAsync correctly replaces the first cached value. + /// + /// The state key. + /// The initial value. + /// The overwrite value. + Task OverwriteAndRead(string key, string value1, string value2); + + /// + /// Returns the current value stored under , or + /// when the key does not exist. + /// + /// The state key to read. + Task Read(string key); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/State/IStateActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/State/IStateActor.cs new file mode 100644 index 000000000..9a872fef2 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/State/IStateActor.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading.Tasks; +using Dapr.Actors; + +namespace Dapr.IntegrationTest.Actors.State; + +/// +/// Actor interface that exposes basic key–value state operations with optional TTL support. +/// +public interface IStateActor : IPingActor, IActor +{ + /// + /// Returns the value associated with . + /// + /// The state key to retrieve. + Task GetState(string key); + + /// + /// Sets or overwrites the value for , optionally with a TTL. + /// + /// The state key to set. + /// The value to store. + /// Optional time-to-live after which the entry expires. + Task SetState(string key, string value, TimeSpan? ttl); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/State/StateActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/State/StateActor.cs new file mode 100644 index 000000000..1efb5be8d --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/State/StateActor.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors.Runtime; + +namespace Dapr.IntegrationTest.Actors.State; + +/// +/// Implementation of that stores and retrieves string values +/// from the Dapr state store, with optional TTL support. +/// +public class StateActor(ActorHost host) : Actor(host), IStateActor +{ + /// + public Task Ping(CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + public Task GetState(string key) => + StateManager.GetStateAsync(key); + + /// + public Task SetState(string key, string value, TimeSpan? ttl) => + ttl.HasValue + ? StateManager.SetStateAsync(key, value, ttl: ttl.Value) + : StateManager.SetStateAsync(key, value); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/Timers/ITimerActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/Timers/ITimerActor.cs new file mode 100644 index 000000000..52a443f79 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/Timers/ITimerActor.cs @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading.Tasks; +using Dapr.Actors; + +namespace Dapr.IntegrationTest.Actors.Timers; + +/// +/// Options used to configure a timer started by . +/// +public sealed class StartTimerOptions +{ + /// + /// Gets or sets the total number of ticks after which the timer self-cancels. + /// + public int Total { get; set; } +} + +/// +/// Captures the state maintained by . +/// +public sealed class TimerState +{ + /// + /// Gets or sets the number of times the timer callback has fired. + /// + public int Count { get; set; } + + /// + /// Gets or sets a value indicating whether the timer is currently active. + /// + public bool IsTimerRunning { get; set; } + + /// + /// Gets or sets the timestamp of the last timer invocation. + /// + public DateTime Timestamp { get; set; } + + /// + /// Gets or sets the name of the currently registered timer, used for self-cancellation. + /// + public string? ActiveTimerName { get; set; } +} + +/// +/// Actor interface that exercises Dapr timer registration and management. +/// +public interface ITimerActor : IPingActor, IActor +{ + /// + /// Starts a self-limiting timer that fires times. + /// + /// Timer configuration. + Task StartTimer(StartTimerOptions options); + + /// + /// Starts a timer that expires after . + /// + /// The time-to-live for the timer. + Task StartTimerWithTtl(TimeSpan ttl); + + /// + /// Returns the current timer state. + /// + Task GetState(); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/Timers/TimerActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/Timers/TimerActor.cs new file mode 100644 index 000000000..b77d1ba06 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/Timers/TimerActor.cs @@ -0,0 +1,92 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors.Runtime; + +namespace Dapr.IntegrationTest.Actors.Timers; + +/// +/// Implementation of that manages Dapr timers and tracks +/// invocation counts in actor state. +/// +public class TimerActor(ActorHost host) : Actor(host), ITimerActor +{ + private const string StateKey = "timer-state"; + + /// + public Task Ping(CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + public Task GetState() => + StateManager.GetOrAddStateAsync(StateKey, new TimerState()); + + /// + public async Task StartTimer(StartTimerOptions options) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(options, Host.JsonSerializerOptions); + const string timerName = "test-timer"; + await RegisterTimerAsync( + timerName, + nameof(Tick), + bytes, + dueTime: TimeSpan.Zero, + period: TimeSpan.FromMilliseconds(100)); + await StateManager.SetStateAsync(StateKey, new TimerState + { + IsTimerRunning = true, + ActiveTimerName = timerName, + }); + } + + /// + public async Task StartTimerWithTtl(TimeSpan ttl) + { + var options = new StartTimerOptions { Total = 100 }; + var bytes = JsonSerializer.SerializeToUtf8Bytes(options, Host.JsonSerializerOptions); + const string timerName = "test-timer-ttl"; + await RegisterTimerAsync( + timerName, + nameof(Tick), + bytes, + TimeSpan.Zero, + TimeSpan.FromSeconds(1), + ttl); + await StateManager.SetStateAsync(StateKey, new TimerState + { + IsTimerRunning = true, + ActiveTimerName = timerName, + }); + } + + private async Task Tick(byte[] bytes) + { + var options = JsonSerializer.Deserialize(bytes, Host.JsonSerializerOptions)!; + var state = await StateManager.GetStateAsync(StateKey); + + if (++state.Count == options.Total) + { + // Unregister the timer by the name tracked in state. + if (!string.IsNullOrEmpty(state.ActiveTimerName)) + await UnregisterTimerAsync(state.ActiveTimerName); + + state.IsTimerRunning = false; + } + + state.Timestamp = DateTime.Now; + await StateManager.SetStateAsync(StateKey, state); + } +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/WeaklyTyped/IWeaklyTypedTestingActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/WeaklyTyped/IWeaklyTypedTestingActor.cs new file mode 100644 index 000000000..3e7dd42b8 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/WeaklyTyped/IWeaklyTypedTestingActor.cs @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading.Tasks; +using Dapr.Actors; + +namespace Dapr.IntegrationTest.Actors.WeaklyTyped; + +/// +/// Base response type used to test polymorphic actor responses. +/// +public class ResponseBase +{ + /// + /// Gets or sets a property defined on the base response type. + /// + public string? BaseProperty { get; set; } +} + +/// +/// Derived response type used to exercise polymorphic deserialization. +/// +public class DerivedResponse : ResponseBase +{ + /// + /// Gets or sets a property defined only on the derived type. + /// + public string? DerivedProperty { get; set; } +} + +/// +/// Actor interface that returns polymorphic response objects via weakly-typed invocation. +/// +public interface IWeaklyTypedTestingActor : IPingActor, IActor +{ + /// + /// Returns a instance to test polymorphic deserialization. + /// + Task GetPolymorphicResponse(); + + /// + /// Returns to verify that null responses are handled correctly. + /// + Task GetNullResponse(); +} diff --git a/test/Dapr.IntegrationTest.Actors/Actors/WeaklyTyped/WeaklyTypedTestingActor.cs b/test/Dapr.IntegrationTest.Actors/Actors/WeaklyTyped/WeaklyTypedTestingActor.cs new file mode 100644 index 000000000..faae7e59b --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Actors/WeaklyTyped/WeaklyTypedTestingActor.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors.Runtime; + +namespace Dapr.IntegrationTest.Actors.WeaklyTyped; + +/// +/// Implementation of that returns polymorphic +/// and null responses for weakly-typed actor invocation testing. +/// +public class WeaklyTypedTestingActor(ActorHost host) : Actor(host), IWeaklyTypedTestingActor +{ + /// + public Task Ping(CancellationToken cancellationToken = default) => Task.CompletedTask; + + /// + public Task GetNullResponse() => + Task.FromResult(null); + + /// + public Task GetPolymorphicResponse() => + Task.FromResult(new DerivedResponse + { + BaseProperty = "Base property value", + DerivedProperty = "Derived property value" + }); +} diff --git a/test/Dapr.IntegrationTest.Actors/Dapr.IntegrationTest.Actors.csproj b/test/Dapr.IntegrationTest.Actors/Dapr.IntegrationTest.Actors.csproj new file mode 100644 index 000000000..8729e59f3 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Dapr.IntegrationTest.Actors.csproj @@ -0,0 +1,30 @@ + + + + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + diff --git a/test/Dapr.IntegrationTest.Actors/ExceptionTests.cs b/test/Dapr.IntegrationTest.Actors/ExceptionTests.cs new file mode 100644 index 000000000..1d0b68128 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/ExceptionTests.cs @@ -0,0 +1,89 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; +using Dapr.Actors.Client; +using Dapr.IntegrationTest.Actors.ExceptionTesting; +using Dapr.IntegrationTest.Actors.Infrastructure; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Harnesses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Actors; + +/// +/// Integration tests that verify Dapr actor exception propagation back to the caller. +/// +public sealed class ExceptionTests +{ + /// + /// Verifies that an is raised on the client + /// when the actor method throws, and that the exception message includes diagnostic details. + /// + [Fact] + public async Task ActorCanProvideExceptionDetails() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "ExceptionActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + var ex = await Assert.ThrowsAsync( + () => proxy.ExceptionExample()); + + Assert.Contains("Remote Actor Method Exception", ex.Message); + Assert.Contains("ExceptionExample", ex.Message); + } + + // ------------------------------------------------------------------ + // Test infrastructure helpers + // ------------------------------------------------------------------ + + private static async Task CreateTestAppAsync( + CancellationToken cancellationToken) + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("actor-exception-components"); + + var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync( + needsActorState: true, + cancellationToken: cancellationToken); + await environment.StartAsync(cancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildActors(); + + var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + }); + }) + .ConfigureApp(app => + { + app.MapActorsHandlers(); + }) + .BuildAndStartAsync(); + return new ActorTestContext(environment, testApp); + } +} diff --git a/test/Dapr.IntegrationTest.Actors/Infrastructure/ActorRuntimeHelper.cs b/test/Dapr.IntegrationTest.Actors/Infrastructure/ActorRuntimeHelper.cs new file mode 100644 index 000000000..777b7c22e --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Infrastructure/ActorRuntimeHelper.cs @@ -0,0 +1,81 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Dapr.IntegrationTest.Actors; + +namespace Dapr.IntegrationTest.Actors.Infrastructure; + +/// +/// Provides helpers for waiting until the Dapr actor runtime is ready to process requests. +/// +public static class ActorRuntimeHelper +{ + // Per-attempt timeout for each Ping call. Keeping this well below the HttpClient + // default (100 s) prevents a hung placement-registration request from stalling + // the poll loop for a full 100 seconds before the next retry. + private static readonly TimeSpan PingAttemptTimeout = TimeSpan.FromSeconds(5); + + /// + /// Polls until a call succeeds, + /// indicating that the actor runtime has registered the actor type with the placement service. + /// + /// + /// The actor proxy to use for health probing. + /// + /// + /// A token that cancels the polling loop. + /// + /// + /// Thrown when is cancelled before the runtime is ready. + /// + public static async Task WaitForActorRuntimeAsync(IPingActor pingActor, CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + // Use a short per-attempt timeout so a hung request (e.g. while the Dapr + // placement service is still registering the actor type) does not stall the + // poll loop for the full HttpClient default of 100 seconds. + using var attemptCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + attemptCts.CancelAfter(PingAttemptTimeout); + + await pingActor.Ping(attemptCts.Token); + return; + } + catch (DaprApiException) + { + // The actor runtime returned an error response – placement may not have + // registered the actor type yet. Retry after a short pause. + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // The per-attempt timeout fired (not the outer cancellation token). The + // sidecar accepted the TCP connection but did not respond in time – this + // happens while Dapr is still acquiring a placement token. Retry. + } + catch (HttpRequestException) + { + // Connection-level error – the sidecar may still be starting up. + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + } + } +} diff --git a/test/Dapr.IntegrationTest.Actors/Infrastructure/ActorTestContext.cs b/test/Dapr.IntegrationTest.Actors/Infrastructure/ActorTestContext.cs new file mode 100644 index 000000000..76d472bfe --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/Infrastructure/ActorTestContext.cs @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading.Tasks; +using Dapr.Testcontainers.Common.Testing; +using Dapr.Testcontainers.Harnesses; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Actors.Infrastructure; + +/// +/// Combines a with its owning +/// so that both are disposed together +/// when the test ends. The must outlive +/// the application (placement / scheduler must stay up while the test runs). +/// +public sealed class ActorTestContext : IAsyncDisposable +{ + private readonly DaprTestEnvironment _environment; + private readonly DaprTestApplication _app; + + /// + /// Initializes a new . + /// + internal ActorTestContext(DaprTestEnvironment environment, DaprTestApplication app) + { + _environment = environment; + _app = app; + } + + /// + /// Creates a DI service scope from the running test application. + /// + public IServiceScope CreateScope() => _app.CreateScope(); + + /// + public async ValueTask DisposeAsync() + { + // Dispose the application (and its harness) before shutting down the environment + // so the Dapr sidecar can drain cleanly before placement/scheduler stop. + await _app.DisposeAsync(); + await _environment.DisposeAsync(); + } +} diff --git a/test/Dapr.IntegrationTest.Actors/ReentrancyTests.cs b/test/Dapr.IntegrationTest.Actors/ReentrancyTests.cs new file mode 100644 index 000000000..09448d9b5 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/ReentrancyTests.cs @@ -0,0 +1,111 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; +using Dapr.Actors.Client; +using Dapr.Actors.Runtime; +using Dapr.IntegrationTest.Actors.Infrastructure; +using Dapr.IntegrationTest.Actors.Reentrancy; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Harnesses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Actors; + +/// +/// Integration tests that verify Dapr actor reentrancy: all enters must happen before any exits +/// in a recursively re-entering call chain. +/// +public sealed class ReentrancyTests +{ + private const int NumCalls = 10; + + /// + /// Verifies that a reentrant actor can make nested self-calls, and + /// that the resulting enter/exit records confirm proper reentrant execution ordering. + /// + [Fact] + public async Task ActorCanPerformReentrantCalls() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "ReentrantActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + await proxy.ReentrantCall(new ReentrantCallOptions { CallsRemaining = NumCalls }); + + var records = new List(); + for (var i = 0; i < NumCalls; i++) + { + var state = await proxy.GetState(i); + records.AddRange(state.Records); + } + + var enterRecords = records.FindAll(r => r.IsEnter); + var exitRecords = records.FindAll(r => !r.IsEnter); + + Assert.Equal(NumCalls * 2, records.Count); + + for (var i = 0; i < NumCalls; i++) + for (var j = 0; j < NumCalls; j++) + { + // All enters must precede all exits. + Assert.True(enterRecords[i].Timestamp < exitRecords[j].Timestamp, + $"Enter record [{i}] did not precede exit record [{j}]."); + } + } + + // ------------------------------------------------------------------ + // Test infrastructure helpers + // ------------------------------------------------------------------ + + private static async Task CreateTestAppAsync( + CancellationToken cancellationToken) + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("actor-reentrancy-components"); + + var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync( + needsActorState: true, + cancellationToken: cancellationToken); + await environment.StartAsync(cancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildActors(); + + var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddActors(options => + { + options.ReentrancyConfig = new ActorReentrancyConfig { Enabled = true }; + options.Actors.RegisterActor(); + }); + }) + .ConfigureApp(app => + { + app.MapActorsHandlers(); + }) + .BuildAndStartAsync(); + return new ActorTestContext(environment, testApp); + } +} diff --git a/test/Dapr.IntegrationTest.Actors/RegressionTests.cs b/test/Dapr.IntegrationTest.Actors/RegressionTests.cs new file mode 100644 index 000000000..307e34d36 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/RegressionTests.cs @@ -0,0 +1,139 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; +using Dapr.Actors.Client; +using Dapr.IntegrationTest.Actors.Infrastructure; +using Dapr.IntegrationTest.Actors.Regression; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Harnesses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Actors; + +/// +/// Integration tests that reproduce regressions to prevent reintroduction of fixed bugs. +/// +public sealed class RegressionTests +{ + /// + /// Regression test for GitHub issue #762: an exception thrown mid-method must not persist + /// state changes made prior to the exception when using actor remoting. + /// + [Fact] + public async Task ActorSuccessfullyClearsStateAfterErrorWithRemoting() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "RegressionActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + var key = Guid.NewGuid().ToString(); + var throwingCall = new StateCall { Key = key, Value = "Throw value", Operation = "ThrowException" }; + var setCall = new StateCall { Key = key, Value = "Real value", Operation = "SetState" }; + var savingCall = new StateCall { Operation = "SaveState" }; + + await proxy.RemoveState(key); + + // A call that sets state then throws – the state must be rolled back. + await Assert.ThrowsAsync(() => proxy.SaveState(throwingCall)); + + // SaveState without setting a value – nothing should be persisted from the failed call. + await proxy.SaveState(savingCall); + var errorResp = await proxy.GetState(key); + Assert.Equal(string.Empty, errorResp); + + // Normal set + save – state should now be persisted. + await proxy.SaveState(setCall); + var resp = await proxy.GetState(key); + Assert.Equal("Real value", resp); + } + + /// + /// Regression test for GitHub issue #762 exercised through the weakly-typed (non-remoting) proxy path. + /// + [Fact] + public async Task ActorSuccessfullyClearsStateAfterErrorWithoutRemoting() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var pingProxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "RegressionActor"); + var proxy = proxyFactory.Create(ActorId.CreateRandom(), "RegressionActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(pingProxy, cts.Token); + + var key = Guid.NewGuid().ToString(); + var throwingCall = new StateCall { Key = key, Value = "Throw value", Operation = "ThrowException" }; + var setCall = new StateCall { Key = key, Value = "Real value", Operation = "SetState" }; + var savingCall = new StateCall { Operation = "SaveState" }; + + await proxy.InvokeMethodAsync("RemoveState", key, cts.Token); + + // A weakly-typed call that sets state then throws – the state must be rolled back. + await Assert.ThrowsAsync( + () => proxy.InvokeMethodAsync("SaveState", throwingCall, cts.Token)); + + await proxy.InvokeMethodAsync("SaveState", savingCall, cts.Token); + var errorResp = await proxy.InvokeMethodAsync("GetState", key, cts.Token); + Assert.Equal(string.Empty, errorResp); + + await proxy.InvokeMethodAsync("SaveState", setCall, cts.Token); + var resp = await proxy.InvokeMethodAsync("GetState", key, cts.Token); + Assert.Equal("Real value", resp); + } + + // ------------------------------------------------------------------ + // Test infrastructure helpers + // ------------------------------------------------------------------ + + private static async Task CreateTestAppAsync( + CancellationToken cancellationToken) + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("actor-regression-components"); + + var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync( + needsActorState: true, + cancellationToken: cancellationToken); + await environment.StartAsync(cancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildActors(); + + var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + }); + }) + .ConfigureApp(app => + { + app.MapActorsHandlers(); + }) + .BuildAndStartAsync(); + return new ActorTestContext(environment, testApp); + } +} diff --git a/test/Dapr.IntegrationTest.Actors/ReminderTests.cs b/test/Dapr.IntegrationTest.Actors/ReminderTests.cs new file mode 100644 index 000000000..d8e3a2bbf --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/ReminderTests.cs @@ -0,0 +1,225 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; +using Dapr.Actors.Client; +using Dapr.IntegrationTest.Actors.Infrastructure; +using Dapr.IntegrationTest.Actors.Reminders; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Harnesses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Actors; + +/// +/// Integration tests that verify Dapr actor reminder registration, firing, and expiry. +/// +public sealed class ReminderTests +{ + /// + /// Verifies that a reminder fires the expected number of times before self-cancelling. + /// + [Fact] + public async Task ActorCanStartAndStopReminder() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "ReminderActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + await proxy.StartReminder(new StartReminderOptions { Total = 10 }); + + ReminderState state; + while (true) + { + cts.Token.ThrowIfCancellationRequested(); + state = await proxy.GetState(); + if (!state.IsReminderRunning) break; + } + + Assert.Equal(10, state.Count); + } + + /// + /// Verifies that GetReminder returns "null" before the reminder is started, + /// returns a valid reminder descriptor while it runs, and that exactly 10 invocations occur. + /// + [Fact] + public async Task ActorCanStartAndStopAndGetReminder() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "ReminderActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + // Reminder not yet started – should return "null". + var reminder = await proxy.GetReminder(); + Assert.Equal("null", reminder); + + await proxy.StartReminder(new StartReminderOptions { Total = 10 }); + + var countGetReminder = 0; + ReminderState state; + while (true) + { + cts.Token.ThrowIfCancellationRequested(); + + reminder = await proxy.GetReminder(); + Assert.NotNull(reminder); + + if (reminder != "null") + { + countGetReminder++; + var reminderJson = JsonSerializer.Deserialize(reminder); + Assert.Equal("test-reminder", reminderJson.GetProperty("name").GetString()); + Assert.Equal(TimeSpan.FromMilliseconds(50).ToString(), reminderJson.GetProperty("period").GetString()); + Assert.Equal(TimeSpan.Zero.ToString(), reminderJson.GetProperty("dueTime").GetString()); + } + + state = await proxy.GetState(); + if (!state.IsReminderRunning) break; + } + + Assert.Equal(10, state.Count); + Assert.True(countGetReminder > 0, "GetReminder should have returned a non-null descriptor at least once."); + } + + /// + /// Verifies that a reminder configured with a repetition count fires exactly that many times. + /// + [Fact] + public async Task ActorCanStartReminderWithRepetitions() + { + const int repetitions = 5; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "ReminderActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + await proxy.StartReminderWithRepetitions(repetitions); + var start = DateTime.Now; + + await Task.Delay(TimeSpan.FromSeconds(7), cts.Token); + + var state = await proxy.GetState(); + Assert.Equal(repetitions, state.Count); + Assert.True(state.Timestamp.Subtract(start) > TimeSpan.Zero, "Reminder may not have triggered."); + Assert.True(DateTime.Now.Subtract(state.Timestamp) > TimeSpan.FromSeconds(1), + $"Reminder triggered too recently. {DateTime.Now} - {state.Timestamp}"); + } + + /// + /// Verifies that a reminder respects both TTL and repetition count, stopping at whichever limit is hit first. + /// + [Fact] + public async Task ActorCanStartReminderWithTtlAndRepetitions() + { + const int repetitions = 2; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "ReminderActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + await proxy.StartReminderWithTtlAndRepetitions(TimeSpan.FromSeconds(5), repetitions); + var start = DateTime.Now; + + await Task.Delay(TimeSpan.FromSeconds(5), cts.Token); + + var state = await proxy.GetState(); + Assert.Equal(repetitions, state.Count); + Assert.True(state.Timestamp.Subtract(start) > TimeSpan.Zero, "Reminder may not have triggered."); + Assert.True(DateTime.Now.Subtract(state.Timestamp) > TimeSpan.FromSeconds(1), + $"Reminder triggered too recently. {DateTime.Now} - {state.Timestamp}"); + } + + /// + /// Verifies that a reminder configured with a TTL stops firing after the TTL elapses. + /// + [Fact] + public async Task ActorCanStartReminderWithTtl() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "ReminderActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + await proxy.StartReminderWithTtl(TimeSpan.FromSeconds(2)); + var start = DateTime.Now; + + await Task.Delay(TimeSpan.FromSeconds(5), cts.Token); + + var state = await proxy.GetState(); + Assert.True(state.Timestamp.Subtract(start) > TimeSpan.Zero, "Reminder may not have triggered."); + Assert.True(DateTime.Now.Subtract(state.Timestamp) > TimeSpan.FromSeconds(1), + $"Reminder triggered too recently. {DateTime.Now} - {state.Timestamp}"); + } + + // ------------------------------------------------------------------ + // Test infrastructure helpers + // ------------------------------------------------------------------ + + private static async Task CreateTestAppAsync( + CancellationToken cancellationToken) + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("actor-reminder-components"); + + var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync( + needsActorState: true, + cancellationToken: cancellationToken); + await environment.StartAsync(cancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildActors(); + + var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + }); + }) + .ConfigureApp(app => + { + app.MapActorsHandlers(); + }) + .BuildAndStartAsync(); + return new ActorTestContext(environment, testApp); + } +} diff --git a/test/Dapr.IntegrationTest.Actors/SerializationTests.cs b/test/Dapr.IntegrationTest.Actors/SerializationTests.cs new file mode 100644 index 000000000..e29381236 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/SerializationTests.cs @@ -0,0 +1,146 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; +using Dapr.Actors.Client; +using Dapr.Actors.Runtime; +using Dapr.IntegrationTest.Actors.Infrastructure; +using Dapr.IntegrationTest.Actors.Serialization; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Harnesses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Actors; + +/// +/// Integration tests that verify custom JSON serialization when invoking actor methods via remoting. +/// +public sealed class SerializationTests +{ + /// + /// Verifies that a complex payload — including extension data and a nested JSON element — survives + /// a full actor remoting round-trip when custom JSON serializer options are configured. + /// + [Fact] + public async Task ActorCanSupportCustomSerializer() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(useJsonSerialization: true, cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "SerializationActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + var payload = new SerializationPayload("hello world") + { + Value = JsonSerializer.SerializeToElement(new { foo = "bar" }), + ExtensionData = new Dictionary + { + { "baz", "qux" }, + { "count", 42 }, + } + }; + + var result = await proxy.SendAsync("test", payload, cts.Token); + + Assert.Equal(payload.Message, result.Message); + // Compare JSON content semantically: GetRawText() may differ in whitespace/indentation + // when WriteIndented is enabled on the round-trip serializer options. + Assert.Equal( + JsonSerializer.Serialize(payload.Value), + JsonSerializer.Serialize(result.Value)); + Assert.NotNull(result.ExtensionData); + Assert.Equal(payload.ExtensionData!.Count, result.ExtensionData!.Count); + + foreach (var kvp in payload.ExtensionData) + { + Assert.True(result.ExtensionData.TryGetValue(kvp.Key, out var value)); + Assert.Equal(JsonSerializer.Serialize(kvp.Value), JsonSerializer.Serialize(value)); + } + } + + /// + /// Verifies that an actor interface with more than one method can dispatch each method + /// independently when custom JSON serialization is active. + /// + [Fact] + public async Task ActorCanSupportCustomSerializerAndCallMoreThanOneDefinedMethod() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(useJsonSerialization: true, cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "SerializationActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + var payload = DateTime.MinValue; + var result = await proxy.AnotherMethod(payload); + + Assert.Equal(payload, result); + } + + // ------------------------------------------------------------------ + // Test infrastructure helpers + // ------------------------------------------------------------------ + + private static async Task CreateTestAppAsync( + bool useJsonSerialization, + CancellationToken cancellationToken) + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("actor-serialization-components"); + + var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync( + needsActorState: true, + cancellationToken: cancellationToken); + await environment.StartAsync(cancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildActors(); + + var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddActors(options => + { + options.UseJsonSerialization = useJsonSerialization; + if (useJsonSerialization) + { + options.JsonSerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + }; + } + options.Actors.RegisterActor(); + }); + }) + .ConfigureApp(app => + { + app.MapActorsHandlers(); + }) + .BuildAndStartAsync(); + return new ActorTestContext(environment, testApp); + } +} diff --git a/test/Dapr.IntegrationTest.Actors/StateManagementTests.cs b/test/Dapr.IntegrationTest.Actors/StateManagementTests.cs new file mode 100644 index 000000000..ad764aa9e --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/StateManagementTests.cs @@ -0,0 +1,414 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; +using Dapr.Actors.Client; +using Dapr.IntegrationTest.Actors.Infrastructure; +using Dapr.IntegrationTest.Actors.State; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Harnesses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Actors; + +/// +/// Integration tests that validate the correctness of the Dapr actor state manager, +/// including in-memory caching behaviour, GetOrAdd / AddOrUpdate semantics, +/// ContainsState, TryGet, removal, and multi-key isolation. +/// These tests are designed to prove that the behaviour is correct, not merely that the +/// existing implementation does not throw. +/// +public sealed class StateManagementTests +{ + /// + /// Verifies that a value written via SetStateAsync is immediately readable from the + /// in-memory cache within the same actor activation, without a round-trip to the state store. + /// + [Fact] + public async Task SetStateAsync_IsImmediatelyReadableFromCache() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + var result = await proxy.SetAndGetWithinSameActivation("cache-key", "expected-value"); + + Assert.Equal("expected-value", result); + } + + /// + /// Verifies that a value persisted in one actor method call is visible to a subsequent + /// call on the same actor ID, confirming that state is auto-saved after each method. + /// + [Fact] + public async Task SetStateAsync_IsPersistableAcrossMethodCalls() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var actorId = ActorId.CreateRandom(); + var proxy = proxyFactory.CreateActorProxy(actorId, "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + // First call — sets state (auto-saved by the runtime on method return). + await proxy.SetAndGetWithinSameActivation("persist-key", "persisted-value"); + + // Second call — reads via a different method, proving the state was persisted. + var read = await proxy.Read("persist-key"); + Assert.Equal("persisted-value", read); + } + + /// + /// Verifies that a second SetStateAsync on the same key within one activation + /// correctly replaces the first cached value. + /// + [Fact] + public async Task SetStateAsync_OverwriteReplacesValueInCache() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + var result = await proxy.OverwriteAndRead("overwrite-key", "first-value", "second-value"); + + // Only the second write should survive. + Assert.Equal("second-value", result); + } + + /// + /// Verifies that two independent state keys set in the same activation do not interfere + /// with each other and that both values are correctly returned. + /// + [Fact] + public async Task SetStateAsync_MultipleKeysAreIndependent() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + var results = await proxy.SetMultipleAndGetAll("key-a", "value-a", "key-b", "value-b"); + + Assert.Equal(2, results.Length); + Assert.Equal("value-a", results[0]); + Assert.Equal("value-b", results[1]); + } + + /// + /// Verifies that ContainsStateAsync returns after a key has + /// been set, and that the check is satisfied from cache within the same activation. + /// + [Fact] + public async Task ContainsStateAsync_ReturnsTrueForExistingKey() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var actorId = ActorId.CreateRandom(); + var proxy = proxyFactory.CreateActorProxy(actorId, "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + // Persist the key so it is visible on subsequent calls. + await proxy.SetAndGetWithinSameActivation("exists-key", "exists-value"); + + var exists = await proxy.ContainsKey("exists-key"); + Assert.True(exists); + } + + /// + /// Verifies that ContainsStateAsync returns for a key that + /// has never been set. + /// + [Fact] + public async Task ContainsStateAsync_ReturnsFalseForAbsentKey() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + var exists = await proxy.ContainsKey($"no-such-key-{Guid.NewGuid():N}"); + Assert.False(exists); + } + + /// + /// Verifies that removing a key makes it immediately invisible to ContainsStateAsync + /// within the same actor activation — before the removal is flushed to the state store. + /// + [Fact] + public async Task RemoveStateAsync_IsImmediatelyReflectedInContainsState() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var actorId = ActorId.CreateRandom(); + var proxy = proxyFactory.CreateActorProxy(actorId, "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + // Persist a key first. + await proxy.SetAndGetWithinSameActivation("remove-key", "remove-value"); + Assert.True(await proxy.ContainsKey("remove-key")); + + // Remove and immediately verify the cache reflects the removal. + var result = await proxy.RemoveAndCheckExists("remove-key"); + Assert.False(result.Exists); + + // Also verify the removal is durable across activations. + Assert.False(await proxy.ContainsKey("remove-key")); + } + + /// + /// Verifies that TryGetStateAsync returns HasValue = false for a key that + /// has never been written. + /// + [Fact] + public async Task TryGetStateAsync_ReturnsFalseForAbsentKey() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + var result = await proxy.TryGet($"absent-{Guid.NewGuid():N}"); + Assert.False(result.Exists); + Assert.Null(result.Value); + } + + /// + /// Verifies that TryGetStateAsync returns HasValue = true and the correct + /// value for a key that has been written. + /// + [Fact] + public async Task TryGetStateAsync_ReturnsTrueAndValueForExistingKey() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var actorId = ActorId.CreateRandom(); + var proxy = proxyFactory.CreateActorProxy(actorId, "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + await proxy.SetAndGetWithinSameActivation("tryget-key", "tryget-value"); + + var result = await proxy.TryGet("tryget-key"); + Assert.True(result.Exists); + Assert.Equal("tryget-value", result.Value); + } + + /// + /// Verifies that GetOrAddStateAsync returns the existing value when the key is + /// already present — the default value must not overwrite the stored value. + /// + [Fact] + public async Task GetOrAddStateAsync_ReturnsExistingValueAndDoesNotOverwrite() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var actorId = ActorId.CreateRandom(); + var proxy = proxyFactory.CreateActorProxy(actorId, "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + // Persist a known value. + await proxy.SetAndGetWithinSameActivation("getOrAdd-key", "original-value"); + + // GetOrAdd with a different default — must return the existing value, not the default. + var result = await proxy.GetOrAdd("getOrAdd-key", "should-not-be-used"); + Assert.Equal("original-value", result); + + // Confirm the value in the store has not changed. + Assert.Equal("original-value", await proxy.Read("getOrAdd-key")); + } + + /// + /// Verifies that GetOrAddStateAsync stores and returns the default value when the + /// key does not yet exist. + /// + [Fact] + public async Task GetOrAddStateAsync_StoresDefaultWhenKeyIsAbsent() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var actorId = ActorId.CreateRandom(); + var proxy = proxyFactory.CreateActorProxy(actorId, "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + var key = $"getOrAdd-new-{Guid.NewGuid():N}"; + var result = await proxy.GetOrAdd(key, "default-value"); + Assert.Equal("default-value", result); + + // The default must also be durable. + Assert.Equal("default-value", await proxy.Read(key)); + } + + /// + /// Verifies that AddOrUpdateStateAsync stores when the + /// key does not exist. + /// + [Fact] + public async Task AddOrUpdateStateAsync_AddsValueWhenKeyIsAbsent() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var actorId = ActorId.CreateRandom(); + var proxy = proxyFactory.CreateActorProxy(actorId, "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + var key = $"addOrUpdate-new-{Guid.NewGuid():N}"; + var result = await proxy.AddOrUpdate(key, "add-value", "update-value"); + Assert.Equal("add-value", result); + } + + /// + /// Verifies that AddOrUpdateStateAsync replaces an existing value with the + /// update-factory result when the key is already present. + /// + [Fact] + public async Task AddOrUpdateStateAsync_UpdatesValueWhenKeyExists() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var actorId = ActorId.CreateRandom(); + var proxy = proxyFactory.CreateActorProxy(actorId, "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + // Write a value first. + await proxy.SetAndGetWithinSameActivation("addOrUpdate-existing-key", "initial-value"); + + // AddOrUpdate must invoke the update factory. + var result = await proxy.AddOrUpdate("addOrUpdate-existing-key", "add-value", "updated-value"); + Assert.Equal("updated-value", result); + + // Confirm the new value is durable. + Assert.Equal("updated-value", await proxy.Read("addOrUpdate-existing-key")); + } + + /// + /// Verifies that TryAddStateAsync succeeds (returns ) when the + /// key does not exist, and fails (returns ) when the key is already + /// present. + /// + [Fact] + public async Task TryAddStateAsync_AddSucceedsForNewKeyAndFailsForExisting() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var actorId = ActorId.CreateRandom(); + var proxy = proxyFactory.CreateActorProxy(actorId, "AdvancedStateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + var key = $"tryAdd-{Guid.NewGuid():N}"; + + // First attempt — key is absent, must succeed. + var addedFirst = await proxy.TryAdd(key, "first-value"); + Assert.True(addedFirst); + + // Second attempt — key now exists, must fail. + var addedSecond = await proxy.TryAdd(key, "second-value"); + Assert.False(addedSecond); + + // The original value must be preserved. + Assert.Equal("first-value", await proxy.Read(key)); + } + + // ------------------------------------------------------------------ + // Test infrastructure helpers + // ------------------------------------------------------------------ + + private static async Task CreateTestAppAsync( + CancellationToken cancellationToken) + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("actor-state-mgmt-components"); + + var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync( + needsActorState: true, + cancellationToken: cancellationToken); + await environment.StartAsync(cancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildActors(); + + var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + }); + }) + .ConfigureApp(app => + { + app.MapActorsHandlers(); + }) + .BuildAndStartAsync(); + return new ActorTestContext(environment, testApp); + } +} diff --git a/test/Dapr.IntegrationTest.Actors/StateTests.cs b/test/Dapr.IntegrationTest.Actors/StateTests.cs new file mode 100644 index 000000000..33266971e --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/StateTests.cs @@ -0,0 +1,185 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; +using Dapr.Actors.Client; +using Dapr.IntegrationTest.Actors.Infrastructure; +using Dapr.IntegrationTest.Actors.State; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Harnesses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Actors; + +/// +/// Integration tests that verify Dapr actor state management, including TTL support +/// and state isolation across different actor proxy instances. +/// +public sealed class StateTests +{ + /// + /// Verifies that a state entry set with a TTL is automatically removed after the TTL elapses. + /// + [Fact] + public async Task ActorCanSaveStateWithTTL() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "StateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + await proxy.SetState("key", "value", TimeSpan.FromSeconds(2)); + var resp = await proxy.GetState("key"); + Assert.Equal("value", resp); + + await Task.Delay(TimeSpan.FromSeconds(2.5), cts.Token); + + await Assert.ThrowsAsync(() => proxy.GetState("key")); + + await proxy.SetState("key", "new-value", null); + resp = await proxy.GetState("key"); + Assert.Equal("new-value", resp); + } + + /// + /// Verifies that re-setting a state entry with a new TTL correctly resets the expiry timer. + /// + [Fact] + public async Task ActorStateTTLOverridesExisting() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "StateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + await proxy.SetState("key", "value", TimeSpan.FromSeconds(4)); + var resp = await proxy.GetState("key"); + Assert.Equal("value", resp); + + await Task.Delay(TimeSpan.FromSeconds(2), cts.Token); + resp = await proxy.GetState("key"); + Assert.Equal("value", resp); + + // Reset TTL to 4 seconds; the old 2-second window is discarded. + await proxy.SetState("key", "value", TimeSpan.FromSeconds(4)); + + await Task.Delay(TimeSpan.FromSeconds(2), cts.Token); + resp = await proxy.GetState("key"); + Assert.Equal("value", resp); + + await Task.Delay(TimeSpan.FromSeconds(2.5), cts.Token); + await Assert.ThrowsAsync(() => proxy.GetState("key")); + } + + /// + /// Verifies that a TTL can be removed by overwriting the entry without a TTL, and + /// that subsequently re-adding a TTL causes expiry again. + /// + [Fact] + public async Task ActorStateTTLRemoveTTL() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "StateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + await proxy.SetState("key", "value", TimeSpan.FromSeconds(2)); + // Overwrite with no TTL – the entry should survive. + await proxy.SetState("key", "value", null); + await Task.Delay(TimeSpan.FromSeconds(2), cts.Token); + var resp = await proxy.GetState("key"); + Assert.Equal("value", resp); + + // Now apply a TTL again and verify the entry expires. + await proxy.SetState("key", "value", TimeSpan.FromSeconds(2)); + await Task.Delay(TimeSpan.FromSeconds(2.5), cts.Token); + await Assert.ThrowsAsync(() => proxy.GetState("key")); + } + + /// + /// Verifies that two proxies pointing at the same actor ID share state, and that + /// TTL expiry is visible through both. + /// + [Fact] + public async Task ActorStateBetweenProxies() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var actorId = ActorId.CreateRandom(); + var proxy1 = proxyFactory.CreateActorProxy(actorId, "StateActor"); + var proxy2 = proxyFactory.CreateActorProxy(actorId, "StateActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy1, cts.Token); + + await proxy1.SetState("key", "value", TimeSpan.FromSeconds(2)); + Assert.Equal("value", await proxy1.GetState("key")); + Assert.Equal("value", await proxy2.GetState("key")); + + await Task.Delay(TimeSpan.FromSeconds(2.5), cts.Token); + await Assert.ThrowsAsync(() => proxy1.GetState("key")); + await Assert.ThrowsAsync(() => proxy2.GetState("key")); + } + + // ------------------------------------------------------------------ + // Test infrastructure helpers + // ------------------------------------------------------------------ + + private static async Task CreateTestAppAsync( + CancellationToken cancellationToken) + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("actor-state-components"); + + var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync( + needsActorState: true, + cancellationToken: cancellationToken); + await environment.StartAsync(cancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildActors(); + + var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + }); + }) + .ConfigureApp(app => + { + app.MapActorsHandlers(); + }) + .BuildAndStartAsync(); + return new ActorTestContext(environment, testApp); + } +} diff --git a/test/Dapr.IntegrationTest.Actors/TimerTests.cs b/test/Dapr.IntegrationTest.Actors/TimerTests.cs new file mode 100644 index 000000000..ad958f1a0 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/TimerTests.cs @@ -0,0 +1,120 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; +using Dapr.Actors.Client; +using Dapr.IntegrationTest.Actors.Infrastructure; +using Dapr.IntegrationTest.Actors.Timers; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Harnesses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Actors; + +/// +/// Integration tests that verify Dapr actor timer registration, firing, and expiry. +/// +public sealed class TimerTests +{ + /// + /// Verifies that a timer fires the expected number of times before self-cancelling. + /// + [Fact] + public async Task ActorCanStartAndStopTimer() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "TimerActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + await proxy.StartTimer(new StartTimerOptions { Total = 10 }); + + TimerState state; + while (true) + { + cts.Token.ThrowIfCancellationRequested(); + state = await proxy.GetState(); + if (!state.IsTimerRunning) break; + } + + Assert.Equal(10, state.Count); + } + + /// + /// Verifies that a timer configured with a TTL stops firing after the TTL elapses. + /// + [Fact] + public async Task ActorCanStartTimerWithTtl() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + var proxy = proxyFactory.CreateActorProxy(ActorId.CreateRandom(), "TimerActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(proxy, cts.Token); + + await proxy.StartTimerWithTtl(TimeSpan.FromSeconds(2)); + var start = DateTime.Now; + + await Task.Delay(TimeSpan.FromSeconds(5), cts.Token); + + var state = await proxy.GetState(); + Assert.True(state.Timestamp.Subtract(start) > TimeSpan.Zero, "Timer may not have fired."); + Assert.True(DateTime.Now.Subtract(state.Timestamp) > TimeSpan.FromSeconds(1), + $"Timer fired too recently. {DateTime.Now} - {state.Timestamp}"); + } + + // ------------------------------------------------------------------ + // Test infrastructure helpers + // ------------------------------------------------------------------ + + private static async Task CreateTestAppAsync( + CancellationToken cancellationToken) + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("actor-timer-components"); + + var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync( + needsActorState: true, + cancellationToken: cancellationToken); + await environment.StartAsync(cancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildActors(); + + var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + }); + }) + .ConfigureApp(app => + { + app.MapActorsHandlers(); + }) + .BuildAndStartAsync(); + return new ActorTestContext(environment, testApp); + } +} diff --git a/test/Dapr.IntegrationTest.Actors/WeaklyTypedTests.cs b/test/Dapr.IntegrationTest.Actors/WeaklyTypedTests.cs new file mode 100644 index 000000000..dc9d31696 --- /dev/null +++ b/test/Dapr.IntegrationTest.Actors/WeaklyTypedTests.cs @@ -0,0 +1,119 @@ +// ------------------------------------------------------------------------ +// Copyright 2026 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Actors; +using Dapr.Actors.Client; +using Dapr.IntegrationTest.Actors.Infrastructure; +using Dapr.IntegrationTest.Actors.WeaklyTyped; +using Dapr.Testcontainers.Common; +using Dapr.Testcontainers.Harnesses; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Actors; + +/// +/// Integration tests that verify weakly-typed actor invocation, including polymorphic +/// response deserialization and null response handling. +/// +public sealed class WeaklyTypedTests +{ + /// + /// Verifies that a weakly-typed actor proxy can return and correctly deserialize a + /// when the declared return type is . + /// + [Fact] + public async Task WeaklyTypedActorCanReturnPolymorphicResponse() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + + var pingProxy = proxyFactory.CreateActorProxy( + ActorId.CreateRandom(), "WeaklyTypedTestingActor"); + var proxy = proxyFactory.Create(ActorId.CreateRandom(), "WeaklyTypedTestingActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(pingProxy, cts.Token); + + var result = await proxy.InvokeMethodAsync( + nameof(IWeaklyTypedTestingActor.GetPolymorphicResponse), + cts.Token); + + Assert.NotNull(result); + Assert.False(string.IsNullOrWhiteSpace(result.BaseProperty)); + } + + /// + /// Verifies that a weakly-typed actor proxy can return a null response without throwing. + /// + [Fact] + public async Task WeaklyTypedActorCanReturnNullResponse() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await using var testApp = await CreateTestAppAsync(cts.Token); + + using var scope = testApp.CreateScope(); + var proxyFactory = scope.ServiceProvider.GetRequiredService(); + + var pingProxy = proxyFactory.CreateActorProxy( + ActorId.CreateRandom(), "WeaklyTypedTestingActor"); + var proxy = proxyFactory.Create(ActorId.CreateRandom(), "WeaklyTypedTestingActor"); + + await ActorRuntimeHelper.WaitForActorRuntimeAsync(pingProxy, cts.Token); + + var result = await proxy.InvokeMethodAsync( + nameof(IWeaklyTypedTestingActor.GetNullResponse), + cts.Token); + + Assert.Null(result); + } + + // ------------------------------------------------------------------ + // Test infrastructure helpers + // ------------------------------------------------------------------ + + private static async Task CreateTestAppAsync( + CancellationToken cancellationToken) + { + var componentsDir = TestDirectoryManager.CreateTestDirectory("actor-weaklytyped-components"); + + var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync( + needsActorState: true, + cancellationToken: cancellationToken); + await environment.StartAsync(cancellationToken); + + var harness = new DaprHarnessBuilder(componentsDir) + .WithEnvironment(environment) + .BuildActors(); + + var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + }); + }) + .ConfigureApp(app => + { + app.MapActorsHandlers(); + }) + .BuildAndStartAsync(); + return new ActorTestContext(environment, testApp); + } +} diff --git a/test/Dapr.IntegrationTest.Messaging/PublishSubscribe/DynamicSubscriptionIntegrationTests.cs b/test/Dapr.IntegrationTest.Messaging/PublishSubscribe/DynamicSubscriptionIntegrationTests.cs index 55893858e..31d9efe6e 100644 --- a/test/Dapr.IntegrationTest.Messaging/PublishSubscribe/DynamicSubscriptionIntegrationTests.cs +++ b/test/Dapr.IntegrationTest.Messaging/PublishSubscribe/DynamicSubscriptionIntegrationTests.cs @@ -48,7 +48,7 @@ public async ValueTask InitializeAsync() var componentsDir = TestDirectoryManager.CreateTestDirectory("pubsub-components"); _harness = new DaprHarnessBuilder(componentsDir) .WithOptions(new DaprRuntimeOptions()) - .BuildPubSub(componentsDir); + .BuildPubSub(); await _harness.InitializeAsync(); _pubSubClient = new DaprPublishSubscribeClientBuilder()