using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ScadaLink.Commons.Entities.ExternalSystems; 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.Enums; 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 externalSystemIds = await ctx.ExternalSystemDefinitions.Select(e => e.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: templateIds, SharedScriptIds: sharedScriptIds, ExternalSystemIds: externalSystemIds, 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")); } // T-007: a failed apply used to keep the BundleSession (and its decrypted // secrets) in the in-memory store for the full 30-minute TTL. The session // must now be removed immediately so the plaintext is released. var sessionStore = _provider.GetRequiredService(); Assert.Null(sessionStore.Get(sessionId)); } [Fact] public async Task ApplyAsync_removes_session_on_success_path_too() { // T-007: companion to the failed-apply test — the success path must also // remove the session (it was already doing so before T-007, but the new // test asserts the contract explicitly so a future refactor cannot // accidentally leave plaintext in the store). await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("PumpForT007") { Description = "fresh" }); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); await WipeContentAsync(); await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); await importer.ApplyAsync(sessionId, new List { new("Template", "PumpForT007", ResolutionAction.Add, null) }, user: "alice"); } var sessionStore = _provider.GetRequiredService(); Assert.Null(sessionStore.Get(sessionId)); } [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); } } [Fact] public async Task ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle() { // T-001 regression. The Overwrite branch used to write only Description // / FolderId on the existing template; the bundle's Attributes / Alarms // / Scripts collections were silently dropped on the floor. This test // seeds a template with one shape, exports it, mutates the target to a // divergent shape, then asserts that Overwrite restores every child // collection AND emits per-field audit rows. // // Bundle shape (exported from "Pump"): // Attributes: [SetPoint (Float, 50.0), Pressure (Float, 100.0)] // Alarms: [HiAlarm (PriorityLevel=1)] // Scripts: [Init (Code="return 1;")] await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var t = new Template("Pump") { Description = "from-bundle" }; t.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Float, Value = "50.0" }); t.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "100.0" }); t.Alarms.Add(new TemplateAlarm("HiAlarm") { PriorityLevel = 1, TriggerType = AlarmTriggerType.ValueMatch }); t.Scripts.Add(new TemplateScript("Init", "return 1;")); ctx.Templates.Add(t); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); // Mutate the target so every child collection diverges from the bundle. // Attributes: SetPoint value mutated, Pressure DELETED, NewAttr ADDED // Alarms: HiAlarm PriorityLevel mutated, ExtraAlarm ADDED // Scripts: Init code mutated, ExtraScript ADDED // Description also mutated so the scalar field still flips. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var pump = await ctx.Templates .Include(t => t.Attributes) .Include(t => t.Alarms) .Include(t => t.Scripts) .SingleAsync(t => t.Name == "Pump"); pump.Description = "target-mutated"; var setPoint = pump.Attributes.Single(a => a.Name == "SetPoint"); setPoint.Value = "999.0"; // mutated var pressure = pump.Attributes.Single(a => a.Name == "Pressure"); pump.Attributes.Remove(pressure); ctx.TemplateAttributes.Remove(pressure); pump.Attributes.Add(new TemplateAttribute("NewAttr") { DataType = DataType.String, Value = "should-be-deleted-by-overwrite", }); var hiAlarm = pump.Alarms.Single(a => a.Name == "HiAlarm"); hiAlarm.PriorityLevel = 99; // mutated pump.Alarms.Add(new TemplateAlarm("ExtraAlarm") { PriorityLevel = 5, TriggerType = AlarmTriggerType.RangeViolation, }); var initScript = pump.Scripts.Single(s => s.Name == "Init"); initScript.Code = "return 999;"; // mutated pump.Scripts.Add(new TemplateScript("ExtraScript", "return 0;")); await ctx.SaveChangesAsync(); } // Capture the audit baseline so we can scope assertions to rows // emitted by THIS apply. int beforeMaxAuditId; await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); beforeMaxAuditId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0; } // Act — apply Overwrite. 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 — children mirror the bundle. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var pump = await ctx.Templates .Include(t => t.Attributes) .Include(t => t.Alarms) .Include(t => t.Scripts) .SingleAsync(t => t.Name == "Pump"); Assert.Equal("from-bundle", pump.Description); // Attributes — exactly { SetPoint, Pressure }, values restored. Assert.Equal(2, pump.Attributes.Count); var setPoint = pump.Attributes.Single(a => a.Name == "SetPoint"); Assert.Equal("50.0", setPoint.Value); Assert.Equal(DataType.Float, setPoint.DataType); var pressure = pump.Attributes.Single(a => a.Name == "Pressure"); Assert.Equal("100.0", pressure.Value); Assert.DoesNotContain(pump.Attributes, a => a.Name == "NewAttr"); // Alarms — exactly { HiAlarm }, PriorityLevel restored. Assert.Single(pump.Alarms); var hi = pump.Alarms.Single(); Assert.Equal("HiAlarm", hi.Name); Assert.Equal(1, hi.PriorityLevel); Assert.DoesNotContain(pump.Alarms, a => a.Name == "ExtraAlarm"); // Scripts — exactly { Init }, code restored. Assert.Single(pump.Scripts); var init = pump.Scripts.Single(); Assert.Equal("Init", init.Name); Assert.Equal("return 1;", init.Code); Assert.DoesNotContain(pump.Scripts, s => s.Name == "ExtraScript"); // Per-field audit rows — design doc enumerates Added / Updated / // Deleted shapes; all of these should appear, all stamped with // the BundleImportId from the result. var newRows = await ctx.AuditLogEntries .Where(a => a.Id > beforeMaxAuditId) .ToListAsync(); Assert.All(newRows, r => Assert.Equal(result.BundleImportId, r.BundleImportId)); // Attribute audit events. Assert.Contains(newRows, r => r.Action == "TemplateAttributeUpdated" && r.EntityName == "Pump.SetPoint"); Assert.Contains(newRows, r => r.Action == "TemplateAttributeAdded" && r.EntityName == "Pump.Pressure"); Assert.Contains(newRows, r => r.Action == "TemplateAttributeDeleted" && r.EntityName == "Pump.NewAttr"); // Alarm audit events. Assert.Contains(newRows, r => r.Action == "TemplateAlarmUpdated" && r.EntityName == "Pump.HiAlarm"); Assert.Contains(newRows, r => r.Action == "TemplateAlarmDeleted" && r.EntityName == "Pump.ExtraAlarm"); // Script audit events. Assert.Contains(newRows, r => r.Action == "TemplateScriptUpdated" && r.EntityName == "Pump.Init"); Assert.Contains(newRows, r => r.Action == "TemplateScriptDeleted" && r.EntityName == "Pump.ExtraScript"); } Assert.Equal(1, result.Overwritten); } [Fact] public async Task ApplyAsync_Overwrite_synchronises_external_system_methods_to_bundle() { // T-002 regression. The ExternalSystem Overwrite branch used to write // only EndpointUrl / AuthType / AuthConfiguration on the existing // definition; the bundle's Methods collection was silently dropped on // the floor. This test seeds an external system with one method // shape, exports it, mutates the target's methods to diverge, then // asserts that Overwrite restores every method AND emits per-field // audit rows. // // Bundle shape (exported from "Erp"): // Methods: [ // GetUser (GET /users/{id}, param=A, return=R), // PostJob (POST /jobs, param=B, return=S), // ] await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var sys = new ExternalSystemDefinition("Erp", "https://erp.example", "ApiKey"); ctx.ExternalSystemDefinitions.Add(sys); await ctx.SaveChangesAsync(); ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("GetUser", "GET", "/users/{id}") { ExternalSystemDefinitionId = sys.Id, ParameterDefinitions = "A", ReturnDefinition = "R", }); ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("PostJob", "POST", "/jobs") { ExternalSystemDefinitionId = sys.Id, ParameterDefinitions = "B", ReturnDefinition = "S", }); await ctx.SaveChangesAsync(); } var sessionId = await ExportAndLoadAsync(); // Mutate the target so the methods diverge from the bundle: // GetUser — Path mutated (UPDATE expected on Overwrite) // PostJob — DELETED (ADD expected on Overwrite to restore) // ExtraOp — ADDED (DELETE expected on Overwrite to remove) // EndpointUrl / AuthType also mutated so the scalar update still fires. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp"); sys.EndpointUrl = "https://wrong.example"; sys.AuthType = "Basic"; var getUser = await ctx.ExternalSystemMethods .SingleAsync(m => m.ExternalSystemDefinitionId == sys.Id && m.Name == "GetUser"); getUser.Path = "/wrong/path"; var postJob = await ctx.ExternalSystemMethods .SingleAsync(m => m.ExternalSystemDefinitionId == sys.Id && m.Name == "PostJob"); ctx.ExternalSystemMethods.Remove(postJob); ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("ExtraOp", "DELETE", "/extra") { ExternalSystemDefinitionId = sys.Id, ParameterDefinitions = "X", ReturnDefinition = "Y", }); await ctx.SaveChangesAsync(); } // Capture the audit baseline so we can scope assertions to rows // emitted by THIS apply. int beforeMaxAuditId; await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); beforeMaxAuditId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0; } // Act — apply Overwrite on the external system. ImportResult result; await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); result = await importer.ApplyAsync(sessionId, new List { new("ExternalSystem", "Erp", ResolutionAction.Overwrite, null) }, user: "bob"); } // Assert — methods mirror the bundle exactly. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp"); Assert.Equal("https://erp.example", sys.EndpointUrl); var methods = await ctx.ExternalSystemMethods .Where(m => m.ExternalSystemDefinitionId == sys.Id) .ToListAsync(); Assert.Equal(2, methods.Count); var getUser = methods.Single(m => m.Name == "GetUser"); Assert.Equal("GET", getUser.HttpMethod); Assert.Equal("/users/{id}", getUser.Path); Assert.Equal("A", getUser.ParameterDefinitions); Assert.Equal("R", getUser.ReturnDefinition); var postJob = methods.Single(m => m.Name == "PostJob"); Assert.Equal("POST", postJob.HttpMethod); Assert.Equal("/jobs", postJob.Path); Assert.Equal("B", postJob.ParameterDefinitions); Assert.Equal("S", postJob.ReturnDefinition); Assert.DoesNotContain(methods, m => m.Name == "ExtraOp"); // Per-field audit rows — design doc enumerates Added / Updated / // Deleted shapes; all of these should appear, all stamped with // the BundleImportId from the result. var newRows = await ctx.AuditLogEntries .Where(a => a.Id > beforeMaxAuditId) .ToListAsync(); Assert.All(newRows, r => Assert.Equal(result.BundleImportId, r.BundleImportId)); Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodUpdated" && r.EntityName == "Erp.GetUser"); Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodAdded" && r.EntityName == "Erp.PostJob"); Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodDeleted" && r.EntityName == "Erp.ExtraOp"); } Assert.Equal(1, result.Overwritten); } }