using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Transport; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; using ScadaLink.ConfigurationDatabase.Services; using ScadaLink.Transport; namespace ScadaLink.Transport.IntegrationTests; /// /// FU-B — integration coverage for the post-flush second-pass rewire in /// BundleImporter.ApplyAsync: composition edges (#39) and /// alarm-script FKs (remainder of #37). All three scenarios drive the /// full export → load → apply pipeline so the wire-level DTO carries the /// name-keyed references the importer is expected to resolve. /// public sealed class CompositionImportTests : IDisposable { private readonly ServiceProvider _provider; public CompositionImportTests() { var services = new ServiceCollection(); services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); var dbName = $"CompositionImportTests_{Guid.NewGuid()}"; services.AddDbContext(opts => opts .UseInMemoryDatabase(dbName) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); _provider = services.BuildServiceProvider(); } public void Dispose() => _provider.Dispose(); /// /// Builds a bundle of the templates currently in the DB and returns the /// raw bytes. Mirrors the helper in RoundTripTests but parameterised /// to keep the per-test setup terse. /// private async Task ExportAllTemplatesAsync() { await using var scope = _provider.CreateAsyncScope(); var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: templateIds, SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiKeyIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: false); var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); using var ms = new MemoryStream(); await stream.CopyToAsync(ms); return ms.ToArray(); } private async Task WipeTemplatesAsync() { await using var scope = _provider.CreateAsyncScope(); var ctx = scope.ServiceProvider.GetRequiredService(); ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions); ctx.TemplateAlarms.RemoveRange(ctx.TemplateAlarms); ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts); ctx.Templates.RemoveRange(ctx.Templates); ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries); await ctx.SaveChangesAsync(); } [Fact] public async Task Composition_edges_are_restored_on_import() { // Seed: Template A composes Template B via InstanceName="b1". await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("B") { Description = "leaf" }); await ctx.SaveChangesAsync(); var b = await ctx.Templates.SingleAsync(t => t.Name == "B"); var a = new Template("A") { Description = "composer" }; ctx.Templates.Add(a); await ctx.SaveChangesAsync(); a.Compositions.Add(new TemplateComposition("b1") { TemplateId = a.Id, ComposedTemplateId = b.Id, }); await ctx.SaveChangesAsync(); } var bundleBytes = await ExportAllTemplatesAsync(); await WipeTemplatesAsync(); // Load + preview + apply with Add for both. Guid sessionId; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); using var ms = new MemoryStream(bundleBytes, writable: false); var session = await importer.LoadAsync(ms, passphrase: null); sessionId = session.SessionId; } ImportPreview preview; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); preview = await importer.PreviewAsync(sessionId); } var resolutions = preview.Items .Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null)) .ToList(); await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); await importer.ApplyAsync(sessionId, resolutions, user: "bob"); } // Assert: A has exactly one TemplateComposition pointing at B. await using (var assertScope = _provider.CreateAsyncScope()) { var ctx = assertScope.ServiceProvider.GetRequiredService(); var a = await ctx.Templates .Include(t => t.Compositions) .SingleAsync(t => t.Name == "A"); var comp = Assert.Single(a.Compositions); Assert.Equal("b1", comp.InstanceName); var b = await ctx.Templates.SingleAsync(t => t.Name == "B"); Assert.Equal(b.Id, comp.ComposedTemplateId); } } [Fact] public async Task Composition_referencing_skipped_template_emits_unresolved_audit_and_skips_edge() { // Same seed as the happy-path test; the divergence is in the Apply // resolutions — B is Skip-resolved so its composition reference is // expected to surface as a BundleImportCompositionUnresolved audit row // and the composition edge must NOT be written. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("B") { Description = "leaf" }); await ctx.SaveChangesAsync(); var b = await ctx.Templates.SingleAsync(t => t.Name == "B"); var a = new Template("A") { Description = "composer" }; ctx.Templates.Add(a); await ctx.SaveChangesAsync(); a.Compositions.Add(new TemplateComposition("b1") { TemplateId = a.Id, ComposedTemplateId = b.Id, }); await ctx.SaveChangesAsync(); } var bundleBytes = await ExportAllTemplatesAsync(); await WipeTemplatesAsync(); Guid sessionId; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); using var ms = new MemoryStream(bundleBytes, writable: false); var session = await importer.LoadAsync(ms, passphrase: null); sessionId = session.SessionId; } ImportResult result; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); // Add A but Skip B. The composition's ComposedTemplateName="B" // therefore can't resolve (B isn't being written and isn't in the // target — we wiped) and must surface as an unresolved audit row. var resolutions = new List { new("Template", "A", ResolutionAction.Add, null), new("Template", "B", ResolutionAction.Skip, null), }; result = await importer.ApplyAsync(sessionId, resolutions, user: "bob"); } await using (var assertScope = _provider.CreateAsyncScope()) { var ctx = assertScope.ServiceProvider.GetRequiredService(); var a = await ctx.Templates .Include(t => t.Compositions) .SingleAsync(t => t.Name == "A"); // Composition couldn't resolve → zero edges on A. Assert.Empty(a.Compositions); // B was skipped → still not in target. Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "B")); // BundleImportCompositionUnresolved audit row exists and is // correlated by the run's BundleImportId. var unresolved = await ctx.AuditLogEntries .Where(e => e.Action == "BundleImportCompositionUnresolved") .ToListAsync(); var row = Assert.Single(unresolved); Assert.Equal(result.BundleImportId, row.BundleImportId); Assert.Equal("TemplateComposition", row.EntityType); // Entity name is "." so an operator can find the // offending row in the Configuration Audit Log Viewer quickly. Assert.Equal("A.b1", row.EntityName); } } [Fact] public async Task OnTriggerScript_alarm_link_is_restored_on_import() { // Seed: a template with a "Startup" script and an alarm whose // OnTriggerScriptId points at "Startup". The DTO carries // OnTriggerScriptName="Startup" and the importer's second pass should // re-resolve the FK to Startup's new id. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("Pump") { Description = "with alarm" }; ctx.Templates.Add(t); await ctx.SaveChangesAsync(); var script = new TemplateScript("Startup", "return 1;") { TemplateId = t.Id, }; t.Scripts.Add(script); await ctx.SaveChangesAsync(); t.Alarms.Add(new TemplateAlarm("High") { TemplateId = t.Id, PriorityLevel = 2, TriggerType = AlarmTriggerType.RangeViolation, OnTriggerScriptId = script.Id, }); await ctx.SaveChangesAsync(); } var bundleBytes = await ExportAllTemplatesAsync(); await WipeTemplatesAsync(); Guid sessionId; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); using var ms = new MemoryStream(bundleBytes, writable: false); var session = await importer.LoadAsync(ms, passphrase: null); sessionId = session.SessionId; } await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var preview = await importer.PreviewAsync(sessionId); var resolutions = preview.Items .Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null)) .ToList(); await importer.ApplyAsync(sessionId, resolutions, user: "bob"); } await using (var assertScope = _provider.CreateAsyncScope()) { var ctx = assertScope.ServiceProvider.GetRequiredService(); var pump = await ctx.Templates .Include(t => t.Alarms) .Include(t => t.Scripts) .SingleAsync(t => t.Name == "Pump"); var script = Assert.Single(pump.Scripts); Assert.Equal("Startup", script.Name); var alarm = Assert.Single(pump.Alarms); Assert.Equal("High", alarm.Name); // FK rewired to the imported Startup script's NEW id (not the // pre-export id, which is gone after the wipe). Assert.Equal(script.Id, alarm.OnTriggerScriptId); } } }