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 ScadaLink.SiteRuntime.Scripts; 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 ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; 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(); _compilationService = new ScriptCompilationService( NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); } void IDisposable.Dispose() { Shutdown(); try { File.Delete(_dbFile); } catch { /* cleanup */ } } private IActorRef CreateDeploymentManager(SiteRuntimeOptions? options = null) { options ??= new SiteRuntimeOptions(); return ActorOf(Props.Create(() => new DeploymentManagerActor( _storage, _compilationService, _sharedScriptLibrary, null, // no stream manager in tests options, NullLogger.Instance))); } 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 actor = CreateDeploymentManager( new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 }); // 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 actor = CreateDeploymentManager( new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 10 }); 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 actor = CreateDeploymentManager( new SiteRuntimeOptions { StartupBatchSize = 2, StartupBatchDelayMs = 50 }); // 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 actor = CreateDeploymentManager(); 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 actor = CreateDeploymentManager(); 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 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() { var actor = CreateDeploymentManager(); // The actor exists and is responsive -- supervision is configured 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); } }