using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ScadaLink.Commons.Entities.Scripts; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Types.Transport; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; using ScadaLink.ConfigurationDatabase.Services; using ScadaLink.Transport; using ScadaLink.Transport.Import; namespace ScadaLink.Transport.IntegrationTests.Import; /// /// Integration tests for . Reuses the /// in-memory host pattern from and /// BundleExporterTests: real repositories, real EF in-memory provider, /// real Transport pipeline. /// /// In-memory EF caveat: /// is a no-op on this provider, so the rollback test depends on ApplyAsync's /// implementation deferring SaveChangesAsync to a single call just /// before CommitAsync. The implementation enforces that contract + /// calls ChangeTracker.Clear() on the catch path to defend against /// in-memory bleed-through; the rollback test asserts via row counts that the /// invariant holds. /// /// public sealed class BundleImporterApplyTests : IDisposable { private readonly ServiceProvider _provider; public BundleImporterApplyTests() { var services = new ServiceCollection(); services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); var dbName = $"BundleImporterApplyTests_{Guid.NewGuid()}"; // In-memory provider throws by default when BeginTransactionAsync is // called (InMemoryEventId.TransactionIgnoredWarning is escalated to an // exception). ApplyAsync legitimately opens a transaction for // relational providers; downgrade the warning here so the in-memory // run is a no-op and the rest of the apply runs through. See the // ApplyAsync XML comment for the rollback-safety contract that makes // this safe (single deferred SaveChangesAsync + ChangeTracker.Clear // on catch). 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(); // ---- helpers ---- /// /// Exports the entire seeded content as a bundle, then immediately loads it /// via and returns the opened /// session. Used by every test that needs a session to feed /// . Selection is "all templates + /// all shared scripts" because the tests want the bundle to carry whatever /// the test seeded. /// private async Task ExportAndLoadAsync() { Stream bundleStream; 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 sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: templateIds, SharedScriptIds: sharedScriptIds, ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiKeyIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: false); bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); } using var ms = new MemoryStream(); await bundleStream.CopyToAsync(ms); ms.Position = 0; await using var loadScope = _provider.CreateAsyncScope(); var importer = loadScope.ServiceProvider.GetRequiredService(); var session = await importer.LoadAsync(ms, passphrase: null); return session.SessionId; } private async Task WipeContentAsync() { await using var scope = _provider.CreateAsyncScope(); var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.RemoveRange(ctx.Templates); ctx.SharedScripts.RemoveRange(ctx.SharedScripts); ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders); await ctx.SaveChangesAsync(); } // ---- tests ---- [Fact] public async Task ApplyAsync_adds_new_artifacts_in_single_transaction() { // Arrange: seed → export → wipe → apply. The wipe ensures the import // is exercising the Add path (the bundle's artifacts are absent from // the target). await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;")); ctx.Templates.Add(new Template("Pump") { Description = "fresh" }); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); await WipeContentAsync(); // Act ImportResult result; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var resolutions = new List { new("Template", "Pump", ResolutionAction.Add, null), new("SharedScript", "HelperFn", ResolutionAction.Add, null), }; result = await importer.ApplyAsync(sessionId, resolutions, user: "bob"); } // Assert await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Pump")); Assert.Equal(1, await ctx.SharedScripts.CountAsync(s => s.Name == "HelperFn")); } Assert.Equal(2, result.Added); Assert.Equal(0, result.Overwritten); Assert.Equal(0, result.Skipped); Assert.NotEqual(Guid.Empty, result.BundleImportId); } [Fact] public async Task ApplyAsync_overwrites_artifact_when_resolution_is_Overwrite() { // Arrange: seed Pump with Description=new, export, then mutate to // Description=old. The bundle still carries "new". Overwrite must // restore the description. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump") { Description = "new" }); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump"); t.Description = "old"; await ctx.SaveChangesAsync(); } // Act ImportResult result; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); result = await importer.ApplyAsync(sessionId, new List { new("Template", "Pump", ResolutionAction.Overwrite, null) }, user: "bob"); } // Assert await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump"); Assert.Equal("new", t.Description); } Assert.Equal(1, result.Overwritten); } [Fact] public async Task ApplyAsync_skips_artifact_when_resolution_is_Skip() { // Arrange: identical seed + bundle; Skip resolution should leave // target unchanged and bump Skipped count. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump") { Description = "stable" }); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); // Act ImportResult result; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); result = await importer.ApplyAsync(sessionId, new List { new("Template", "Pump", ResolutionAction.Skip, null) }, user: "bob"); } // Assert await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); // Exactly one Pump still, with Description unchanged. var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump"); Assert.Equal("stable", t.Description); } Assert.Equal(1, result.Skipped); Assert.Equal(0, result.Added); Assert.Equal(0, result.Overwritten); } [Fact] public async Task ApplyAsync_renames_artifact_when_resolution_is_Rename() { // Arrange: seed X, export, wipe so the Rename target Y doesn't // collide. Apply Rename X→Y. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("X") { Description = "orig" }); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); await WipeContentAsync(); // Act ImportResult result; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); result = await importer.ApplyAsync(sessionId, new List { new("Template", "X", ResolutionAction.Rename, "Y") }, user: "bob"); } // Assert await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); Assert.Equal(0, await ctx.Templates.CountAsync(t => t.Name == "X")); Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Y")); } Assert.Equal(1, result.Renamed); } [Fact] public async Task ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails() { // Arrange: seed a template whose script body calls MissingHelper(). // No SharedScript by that name exists in source or (after wipe) in the // target, so semantic validation must reject the apply. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("BrokenPump") { Description = "broken" }; t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();")); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); await WipeContentAsync(); // Act await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); await Assert.ThrowsAsync(() => importer.ApplyAsync(sessionId, new List { new("Template", "BrokenPump", ResolutionAction.Add, null) }, user: "bob")); } // Assert — target still wiped (template not committed), AND a // BundleImportFailed row exists. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); Assert.Equal(0, await ctx.Templates.CountAsync()); Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed")); } } [Fact] public async Task ApplyAsync_writes_BundleImportId_on_every_emitted_audit_row() { // The correlation guarantee — every per-entity audit row emitted during // ApplyAsync must carry the same BundleImportId as the returned result. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;")); ctx.Templates.Add(new Template("Pump") { Description = "fresh" }); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); await WipeContentAsync(); // Snapshot the audit-row ids before the apply so the assertion only // looks at rows the apply itself emitted (the export wrote a // BundleExported row too, with no BundleImportId — that's correct, it // wasn't part of an import). int beforeMaxId; await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); beforeMaxId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0; } // Act ImportResult result; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); result = await importer.ApplyAsync(sessionId, new List { new("Template", "Pump", ResolutionAction.Add, null), new("SharedScript", "HelperFn", ResolutionAction.Add, null), }, user: "bob"); } // Assert await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var newRows = await ctx.AuditLogEntries.Where(a => a.Id > beforeMaxId).ToListAsync(); // We expect at least: TemplateCreated + SharedScriptCreated + BundleImported. Assert.True(newRows.Count >= 3, $"Expected at least 3 new audit rows, got {newRows.Count}."); Assert.All(newRows, row => Assert.Equal(result.BundleImportId, row.BundleImportId)); } } [Fact] public async Task ApplyAsync_writes_BundleImported_summary_row_inside_transaction() { await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump") { Description = "fresh" }); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); await WipeContentAsync(); // Act ImportResult result; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); result = await importer.ApplyAsync(sessionId, new List { new("Template", "Pump", ResolutionAction.Add, null) }, user: "bob"); } // Assert: BundleImported row exists, has the right SourceEnvironment in // its AfterStateJson, and carries the BundleImportId from the result. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var row = await ctx.AuditLogEntries.SingleOrDefaultAsync(a => a.Action == "BundleImported"); Assert.NotNull(row); Assert.Equal("Bundle", row!.EntityType); Assert.Equal(result.BundleImportId, row.BundleImportId); Assert.NotNull(row.AfterStateJson); Assert.Contains("dev", row.AfterStateJson!, StringComparison.Ordinal); // Summary block in payload. Assert.Contains("Summary", row.AfterStateJson!, StringComparison.Ordinal); } } [Fact] public async Task ApplyAsync_writes_BundleImportFailed_outside_rolled_back_transaction() { // Paired with the rollback test — the failure row IS present even // though every other write was rolled back, AND it carries // BundleImportId == null (the rolled-back id is intentionally // disowned from the failure record). await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("BrokenPump") { Description = "broken" }; t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();")); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); await WipeContentAsync(); // Act await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); await Assert.ThrowsAsync(() => importer.ApplyAsync(sessionId, new List { new("Template", "BrokenPump", ResolutionAction.Add, null) }, user: "bob")); } // Assert await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var row = await ctx.AuditLogEntries.SingleOrDefaultAsync(a => a.Action == "BundleImportFailed"); Assert.NotNull(row); Assert.Equal("Bundle", row!.EntityType); // Correlation MUST be null on the failure row — the rolled-back // BundleImportId is intentionally disowned. Assert.Null(row.BundleImportId); } } }