using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Messages.Deployment; using ScadaLink.Commons.Messages.Lifecycle; using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Flattening; using ScadaLink.SiteRuntime.Actors; using ScadaLink.SiteRuntime.Persistence; using System.Text.Json; namespace ScadaLink.SiteRuntime.Tests.Actors; /// /// Tests for DeploymentManagerActor: startup from SQLite, staggered batching, /// lifecycle commands, and supervision strategy. /// public class DeploymentManagerActorTests : TestKit, IDisposable { private readonly SiteStorageService _storage; private readonly string _dbFile; public DeploymentManagerActorTests() { _dbFile = Path.Combine(Path.GetTempPath(), $"dm-test-{Guid.NewGuid():N}.db"); _storage = new SiteStorageService( $"Data Source={_dbFile}", NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); } void IDisposable.Dispose() { Shutdown(); try { File.Delete(_dbFile); } catch { /* cleanup */ } } private static string MakeConfigJson(string instanceName) { var config = new FlattenedConfiguration { InstanceUniqueName = instanceName, Attributes = [ new ResolvedAttribute { CanonicalName = "TestAttr", Value = "42", DataType = "Int32" } ] }; return JsonSerializer.Serialize(config); } [Fact] public async Task DeploymentManager_CreatesInstanceActors_FromStoredConfigs() { // Pre-populate SQLite with deployed configs await _storage.StoreDeployedConfigAsync("Pump1", MakeConfigJson("Pump1"), "d1", "h1", true); await _storage.StoreDeployedConfigAsync("Pump2", MakeConfigJson("Pump2"), "d2", "h2", true); var options = new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 }; var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( _storage, options, NullLogger.Instance))); // Allow time for async startup (load configs + create actors) await Task.Delay(2000); // Verify by deploying — if actors already exist, we'd get a warning // Instead, verify by checking we can send lifecycle commands actor.Tell(new DisableInstanceCommand("cmd-1", "Pump1", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(response.Success); Assert.Equal("Pump1", response.InstanceUniqueName); } [Fact] public async Task DeploymentManager_SkipsDisabledInstances_OnStartup() { await _storage.StoreDeployedConfigAsync("Active1", MakeConfigJson("Active1"), "d1", "h1", true); await _storage.StoreDeployedConfigAsync("Disabled1", MakeConfigJson("Disabled1"), "d2", "h2", false); var options = new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 }; var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( _storage, options, NullLogger.Instance))); await Task.Delay(2000); // The disabled instance should NOT have an actor running // Try to disable it — it should succeed (no actor to stop, but SQLite update works) actor.Tell(new DisableInstanceCommand("cmd-2", "Disabled1", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(response.Success); } [Fact] public async Task DeploymentManager_StaggeredBatchCreation() { // Create more instances than the batch size for (int i = 0; i < 5; i++) { var name = $"Batch{i}"; await _storage.StoreDeployedConfigAsync(name, MakeConfigJson(name), $"d{i}", $"h{i}", true); } // Use a small batch size to force multiple batches var options = new SiteRuntimeOptions { StartupBatchSize = 2, StartupBatchDelayMs = 50 }; var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( _storage, options, NullLogger.Instance))); // Wait for all batches to complete (3 batches with 50ms delay = ~150ms + processing) await Task.Delay(3000); // Verify all instances are running by disabling them for (int i = 0; i < 5; i++) { actor.Tell(new DisableInstanceCommand($"cmd-{i}", $"Batch{i}", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(response.Success); } } [Fact] public async Task DeploymentManager_Deploy_CreatesNewInstance() { var options = new SiteRuntimeOptions(); var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( _storage, options, NullLogger.Instance))); await Task.Delay(500); // Wait for empty startup actor.Tell(new DeployInstanceCommand( "dep-100", "NewPump", "sha256:xyz", MakeConfigJson("NewPump"), "admin", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(DeploymentStatus.Success, response.Status); Assert.Equal("NewPump", response.InstanceUniqueName); } [Fact] public async Task DeploymentManager_Lifecycle_DisableEnableDelete() { var options = new SiteRuntimeOptions(); var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( _storage, options, NullLogger.Instance))); await Task.Delay(500); // Deploy actor.Tell(new DeployInstanceCommand( "dep-200", "LifecyclePump", "sha256:abc", MakeConfigJson("LifecyclePump"), "admin", DateTimeOffset.UtcNow)); ExpectMsg(TimeSpan.FromSeconds(5)); // Wait for the async deploy persistence (PipeTo) to complete // The deploy handler replies immediately but persists asynchronously await Task.Delay(1000); // Disable actor.Tell(new DisableInstanceCommand("cmd-d1", "LifecyclePump", DateTimeOffset.UtcNow)); var disableResp = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(disableResp.Success); // Verify disabled in storage await Task.Delay(500); var configs = await _storage.GetAllDeployedConfigsAsync(); var pump = configs.FirstOrDefault(c => c.InstanceUniqueName == "LifecyclePump"); Assert.NotNull(pump); Assert.False(pump.IsEnabled); // Delete actor.Tell(new DeleteInstanceCommand("cmd-del1", "LifecyclePump", DateTimeOffset.UtcNow)); var deleteResp = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(deleteResp.Success); // Verify removed from storage await Task.Delay(500); configs = await _storage.GetAllDeployedConfigsAsync(); Assert.DoesNotContain(configs, c => c.InstanceUniqueName == "LifecyclePump"); } [Fact] public void DeploymentManager_SupervisionStrategy_ResumesOnException() { // Verify the supervision strategy by creating the actor and checking // that it uses OneForOneStrategy var options = new SiteRuntimeOptions(); var actor = ActorOf(Props.Create(() => new DeploymentManagerActor( _storage, options, NullLogger.Instance))); // The actor exists and is responsive — supervision is configured // The actual Resume behavior is verified implicitly: if an Instance Actor // throws during message handling, it resumes rather than restarting actor.Tell(new DeployInstanceCommand( "dep-sup", "SupervisedPump", "sha256:sup", MakeConfigJson("SupervisedPump"), "admin", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(DeploymentStatus.Success, response.Status); } }