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