using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services; using ZB.MOM.WW.ScadaBridge.Transport; using ZB.MOM.WW.ScadaBridge.Transport.Import; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; /// /// Integration tests for the M8 D1 site/instance-scoped apply path of /// : /// resolve-or-create target sites + data connections from a , /// upsert instances, and rewire every cross-environment FK (connection-binding /// DataConnectionId, native-alarm-source ConnectionNameOverride) onto /// the target's surrogate keys. /// /// Reuses the in-memory host pattern from BundleImporterApplyTests / /// BundleImporterPreviewTests: real repositories, real EF in-memory provider, /// real Transport pipeline. Bundles are produced by the real exporter (site closure) /// or hand-packed via for negative cases. /// /// public sealed class SiteInstanceImportTests : IDisposable { private readonly ServiceProvider _provider; public SiteInstanceImportTests() { var services = new ServiceCollection(); services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); var dbName = $"SiteInstanceImportTests_{Guid.NewGuid()}"; // Same in-memory caveat as BundleImporterApplyTests: ApplyAsync opens a // transaction (no-op on in-memory) and defers the single real // SaveChangesAsync to just before CommitAsync; intermediate flushes are // undone on the catch path via ChangeTracker.Clear(). Downgrade the // transaction-ignored warning so the in-memory run proceeds. 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.AddScoped(); services.AddTransport(); _provider = services.BuildServiceProvider(); } public void Dispose() => _provider.Dispose(); // ────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────── /// /// Seeds a full site closure (template + site + site-scoped data connection + /// instance bound to that connection, with an attribute override, an alarm /// override and a native-alarm-source override whose ConnectionNameOverride /// names the seeded connection) so the export carries every FK shape D1 must /// rewire. The instance is seeded so the /// import's NotDeployed reset is observable. /// private async Task SeedSiteClosureAsync( string siteIdentifier = "plant-1", string siteName = "Plant 1", string connectionName = "OpcUaPrimary", string templateName = "Pump", string instanceName = "Pump-01") { await using var scope = _provider.CreateAsyncScope(); var ctx = scope.ServiceProvider.GetRequiredService(); var template = new Template(templateName) { Description = "pump tpl" }; template.Attributes.Add(new TemplateAttribute("Flow") { Value = "0" }); ctx.Templates.Add(template); var site = new Site(siteName, siteIdentifier) { Description = "primary plant", NodeAAddress = "akka://site@10.0.0.1:2552", NodeBAddress = "akka://site@10.0.0.2:2552", GrpcNodeAAddress = "10.0.0.1:8083", GrpcNodeBAddress = "10.0.0.2:8083", }; ctx.Sites.Add(site); await ctx.SaveChangesAsync(); var conn = new DataConnection(connectionName, "OpcUa", site.Id) { PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}", BackupConfiguration = "{\"endpoint\":\"opc.tcp://backup\"}", FailoverRetryCount = 5, }; ctx.DataConnections.Add(conn); var instance = new Instance(instanceName) { TemplateId = template.Id, SiteId = site.Id, State = InstanceState.Enabled, }; instance.AttributeOverrides.Add(new InstanceAttributeOverride("Flow") { OverrideValue = "42" }); instance.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm") { PriorityLevelOverride = 7 }); instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc") { ConnectionNameOverride = connectionName, SourceReferenceOverride = "ns=3;s=Pump.Alarm", }); ctx.Instances.Add(instance); await ctx.SaveChangesAsync(); instance.ConnectionBindings.Add(new InstanceConnectionBinding("Flow") { DataConnectionId = conn.Id, DataSourceReferenceOverride = "ns=3;s=Pump.Flow", }); await ctx.SaveChangesAsync(); } /// Exports every seeded site (and its instance/connection closure) into a bundle, then loads it. private async Task ExportAllSitesAndLoadAsync() { Stream bundleStream; await using (var scope = _provider.CreateAsyncScope()) { var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: Array.Empty(), SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: true, SiteIds: siteIds); 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; } /// Removes all site/instance-scoped rows so the import exercises the CreateNew path against a fresh target. private async Task WipeSiteClosureAsync() { await using var scope = _provider.CreateAsyncScope(); var ctx = scope.ServiceProvider.GetRequiredService(); ctx.InstanceConnectionBindings.RemoveRange(ctx.InstanceConnectionBindings); ctx.InstanceAttributeOverrides.RemoveRange(ctx.InstanceAttributeOverrides); ctx.InstanceAlarmOverrides.RemoveRange(ctx.InstanceAlarmOverrides); ctx.InstanceNativeAlarmSourceOverrides.RemoveRange(ctx.InstanceNativeAlarmSourceOverrides); ctx.Instances.RemoveRange(ctx.Instances); ctx.DataConnections.RemoveRange(ctx.DataConnections); ctx.Areas.RemoveRange(ctx.Areas); ctx.Sites.RemoveRange(ctx.Sites); ctx.Templates.RemoveRange(ctx.Templates); await ctx.SaveChangesAsync(); } private async Task ApplyAsync( Guid sessionId, IReadOnlyList resolutions, BundleNameMap nameMap) { await using var scope = _provider.CreateAsyncScope(); var importer = scope.ServiceProvider.GetRequiredService(); return await importer.ApplyAsync(sessionId, resolutions, user: "bob", ct: CancellationToken.None, nameMap: nameMap); } /// /// Hand-packs an arbitrary into a real, loadable /// plaintext bundle and opens a session. Lets negative-path tests carry an /// instance whose TemplateName the export resolver would never emit (e.g. a /// template absent from both bundle and target), so the instance pass' guard /// can be exercised mid-transaction. Reuses the production manifest builder + /// serializer for hash fidelity. /// private async Task PackAndLoadAsync(BundleContentDto content) { await using var scope = _provider.CreateAsyncScope(); var manifestBuilder = scope.ServiceProvider.GetRequiredService(); var serializer = scope.ServiceProvider.GetRequiredService(); var importer = scope.ServiceProvider.GetRequiredService(); var summary = new BundleSummary( Templates: content.Templates.Count, TemplateFolders: content.TemplateFolders.Count, SharedScripts: content.SharedScripts.Count, ExternalSystems: content.ExternalSystems.Count, DbConnections: content.DatabaseConnections.Count, NotificationLists: content.NotificationLists.Count, SmtpConfigs: content.SmtpConfigs.Count, ApiMethods: content.ApiMethods.Count, Sites: content.Sites.Count, DataConnections: content.DataConnections.Count, Instances: content.Instances.Count); var manifest = manifestBuilder.Build( sourceEnvironment: "dev", exportedBy: "alice", scadaBridgeVersion: "1.0.0", encryption: null, summary: summary, contents: Array.Empty(), contentBytes: serializer.SerializeContentBytes(content)); await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null); using var ms = new MemoryStream(); await packed.CopyToAsync(ms); ms.Position = 0; var session = await importer.LoadAsync(ms, passphrase: null); return session.SessionId; } // ────────────────────────────────────────────────────────────────────── // CreateNew into a fresh target // ────────────────────────────────────────────────────────────────────── [Fact] public async Task ApplyAsync_CreateNew_into_fresh_target_creates_site_connection_instance_with_rewired_FKs() { await SeedSiteClosureAsync(); var sessionId = await ExportAllSitesAndLoadAsync(); await WipeSiteClosureAsync(); // The template must exist for the instance to resolve its TemplateId. // The wipe removed it, so re-create it (a real import that carries the // template would Add it; here we isolate the site/instance path). await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" }); await ctx.SaveChangesAsync(); } var nameMap = new BundleNameMap( Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) }, Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) }); var result = await ApplyAsync( sessionId, new List { new("Template", "Pump", ResolutionAction.Skip, null), // already present new("Site", "plant-1", ResolutionAction.Add, null), new("DataConnection", "OpcUaPrimary", ResolutionAction.Add, null), new("Instance", "Pump-01", ResolutionAction.Add, null), }, nameMap); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var site = await ctx.Sites.SingleAsync(s => s.SiteIdentifier == "plant-1"); Assert.Equal("Plant 1", site.Name); // Full config carried (D3 "carry full config"). Assert.Equal("10.0.0.1:8083", site.GrpcNodeAAddress); Assert.Equal("akka://site@10.0.0.2:2552", site.NodeBAddress); var conn = await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary"); Assert.Equal(site.Id, conn.SiteId); // Primary/Backup config restored from the bundle's secrets block. Assert.Contains("opc.tcp://primary", conn.PrimaryConfiguration!); Assert.Contains("opc.tcp://backup", conn.BackupConfiguration!); Assert.Equal(5, conn.FailoverRetryCount); var inst = await ctx.Instances .Include(i => i.ConnectionBindings) .Include(i => i.AttributeOverrides) .Include(i => i.AlarmOverrides) .Include(i => i.NativeAlarmSourceOverrides) .SingleAsync(i => i.UniqueName == "Pump-01"); // Imported instances are design-time config — never carried as deployed. Assert.Equal(InstanceState.NotDeployed, inst.State); Assert.Equal(site.Id, inst.SiteId); // FK rewire — binding points at the CREATED connection's surrogate id. var binding = Assert.Single(inst.ConnectionBindings); Assert.Equal(conn.Id, binding.DataConnectionId); Assert.Equal("ns=3;s=Pump.Flow", binding.DataSourceReferenceOverride); // Native-alarm override connection-name rewritten to the target name. var native = Assert.Single(inst.NativeAlarmSourceOverrides); Assert.Equal("OpcUaPrimary", native.ConnectionNameOverride); // Override rows present. Assert.Single(inst.AttributeOverrides); Assert.Equal("42", inst.AttributeOverrides.Single().OverrideValue); Assert.Single(inst.AlarmOverrides); Assert.Equal(7, inst.AlarmOverrides.Single().PriorityLevelOverride); } // Counts: site + connection + instance added (template was Skipped). Assert.Equal(3, result.Added); Assert.Equal(1, result.Skipped); // D2 has not run yet — StaleInstanceIds stays empty. Assert.Empty(result.StaleInstanceIds); } // ────────────────────────────────────────────────────────────────────── // MapToExisting into a populated target (FK remap to existing ids) // ────────────────────────────────────────────────────────────────────── [Fact] public async Task ApplyAsync_MapToExisting_remaps_binding_to_existing_target_connection_id() { // Source env: plant-1 / OpcUaPrimary. Build a bundle from it, then set up // a DISTINCT target env that already has plant-1 + OpcUaPrimary with their // OWN surrogate ids (achieved by wiping + reseeding so ids differ from the // source's). The import must MapToExisting and rebind to the TARGET ids. await SeedSiteClosureAsync(); var sessionId = await ExportAllSitesAndLoadAsync(); await WipeSiteClosureAsync(); // Re-seed the target with the SAME identifiers but fresh rows (no instance // — the import brings it). Pad with throwaway rows first so the target's // surrogate ids do not coincide with the source's by accident. int targetConnId; await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); // Throwaway site/connection to advance the id counters. var pad = new Site("Pad", "pad-site"); ctx.Sites.Add(pad); await ctx.SaveChangesAsync(); ctx.DataConnections.Add(new DataConnection("PadConn", "OpcUa", pad.Id)); await ctx.SaveChangesAsync(); ctx.Templates.Add(new Template("Pump") { Description = "target pump" }); var site = new Site("Plant 1 (target)", "plant-1"); ctx.Sites.Add(site); await ctx.SaveChangesAsync(); var conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id) { PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://target-existing\"}", }; ctx.DataConnections.Add(conn); await ctx.SaveChangesAsync(); targetConnId = conn.Id; } var nameMap = new BundleNameMap( Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") }, Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") }); var result = await ApplyAsync( sessionId, new List { new("Template", "Pump", ResolutionAction.Skip, null), new("Site", "plant-1", ResolutionAction.Skip, null), // leave target site untouched new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null), // leave target conn untouched new("Instance", "Pump-01", ResolutionAction.Add, null), }, nameMap); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); // Skip left the existing target connection's config untouched — NOT a // new connection created from the bundle. var conns = await ctx.DataConnections.Where(c => c.Name == "OpcUaPrimary").ToListAsync(); var conn = Assert.Single(conns); Assert.Equal(targetConnId, conn.Id); Assert.Contains("target-existing", conn.PrimaryConfiguration!); var inst = await ctx.Instances .Include(i => i.ConnectionBindings) .Include(i => i.NativeAlarmSourceOverrides) .SingleAsync(i => i.UniqueName == "Pump-01"); // FK remapped explicitly to the EXISTING target connection id. var binding = Assert.Single(inst.ConnectionBindings); Assert.Equal(targetConnId, binding.DataConnectionId); Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride); } // Site + connection Skipped, instance Added. Assert.Equal(1, result.Added); Assert.Equal(3, result.Skipped); // template + site + connection } // ────────────────────────────────────────────────────────────────────── // Rename an instance on import // ────────────────────────────────────────────────────────────────────── [Fact] public async Task ApplyAsync_renames_instance_unique_name_on_import() { await SeedSiteClosureAsync(); var sessionId = await ExportAllSitesAndLoadAsync(); await WipeSiteClosureAsync(); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" }); await ctx.SaveChangesAsync(); } var nameMap = new BundleNameMap( Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) }, Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) }); var result = await ApplyAsync( sessionId, new List { new("Template", "Pump", ResolutionAction.Skip, null), new("Instance", "Pump-01", ResolutionAction.Rename, "Pump-01-imported"), }, nameMap); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); Assert.Equal(0, await ctx.Instances.CountAsync(i => i.UniqueName == "Pump-01")); var renamed = await ctx.Instances .Include(i => i.ConnectionBindings) .SingleAsync(i => i.UniqueName == "Pump-01-imported"); // Renamed instance still gets its FKs rewired + state reset. Assert.Equal(InstanceState.NotDeployed, renamed.State); var conn = await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary"); Assert.Equal(conn.Id, renamed.ConnectionBindings.Single().DataConnectionId); } Assert.Equal(1, result.Renamed); } // ────────────────────────────────────────────────────────────────────── // Cross-site rebind: source site maps to a DIFFERENTLY-named target site // ────────────────────────────────────────────────────────────────────── [Fact] public async Task ApplyAsync_maps_source_site_to_differently_named_target_site_and_rebinds_connections() { await SeedSiteClosureAsync(siteIdentifier: "plant-1", connectionName: "OpcUaPrimary"); var sessionId = await ExportAllSitesAndLoadAsync(); await WipeSiteClosureAsync(); // Target env has a DIFFERENTLY-identified site (plant-west) carrying a // same-named connection. The operator maps plant-1 → plant-west. int westSiteId; int westConnId; await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" }); var west = new Site("Plant West", "plant-west"); ctx.Sites.Add(west); await ctx.SaveChangesAsync(); westSiteId = west.Id; var conn = new DataConnection("OpcUaPrimary", "OpcUa", west.Id) { PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://west\"}", }; ctx.DataConnections.Add(conn); await ctx.SaveChangesAsync(); westConnId = conn.Id; } var nameMap = new BundleNameMap( Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-west") }, Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") }); var result = await ApplyAsync( sessionId, new List { new("Template", "Pump", ResolutionAction.Skip, null), new("Site", "plant-1", ResolutionAction.Skip, null), new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null), new("Instance", "Pump-01", ResolutionAction.Add, null), }, nameMap); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); // No plant-1 site was created — the bundle's site mapped onto plant-west. Assert.Equal(0, await ctx.Sites.CountAsync(s => s.SiteIdentifier == "plant-1")); var inst = await ctx.Instances .Include(i => i.ConnectionBindings) .SingleAsync(i => i.UniqueName == "Pump-01"); // Instance rebound to the TARGET site + its connection id. Assert.Equal(westSiteId, inst.SiteId); Assert.Equal(westConnId, inst.ConnectionBindings.Single().DataConnectionId); } Assert.Equal(1, result.Added); } // ────────────────────────────────────────────────────────────────────── // Rollback: a mid-apply failure persists NOTHING // ────────────────────────────────────────────────────────────────────── [Fact] public async Task ApplyAsync_rolls_back_site_and_connection_when_instance_pass_throws() { // Hand-pack a bundle whose instance references template "GhostTemplate" // that exists in NEITHER the bundle NOR the (empty) target. The reference // is rejected in the pre-write validation phase (so the change tracker is // still empty), the import aborts, and the transaction rolls back — no // site, connection, or instance row may survive, and a BundleImportFailed // audit row records the abort. The validation-phase rejection surfaces as // a SemanticValidationException, the same all-or-nothing failure contract // the script-reference and template-validation checks already use. var content = new BundleContentDto( TemplateFolders: Array.Empty(), Templates: Array.Empty(), SharedScripts: Array.Empty(), ExternalSystems: Array.Empty(), DatabaseConnections: Array.Empty(), NotificationLists: Array.Empty(), SmtpConfigs: Array.Empty(), ApiMethods: Array.Empty()) { Sites = new[] { new SiteDto("plant-1", "Plant 1", null, null, null, null, null), }, DataConnections = new[] { new DataConnectionDto("plant-1", "OpcUaPrimary", "OpcUa", 3, null), }, Instances = new[] { new InstanceDto( UniqueName: "Pump-01", TemplateName: "GhostTemplate", SiteIdentifier: "plant-1", AreaName: null, State: InstanceState.Enabled, AttributeOverrides: Array.Empty(), AlarmOverrides: Array.Empty(), NativeAlarmSourceOverrides: Array.Empty(), ConnectionBindings: Array.Empty()), }, }; var sessionId = await PackAndLoadAsync(content); await using (var scope = _provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var nameMap = new BundleNameMap( Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) }, Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) }); await Assert.ThrowsAsync(() => importer.ApplyAsync( sessionId, new List { new("Site", "plant-1", ResolutionAction.Add, null), new("DataConnection", "OpcUaPrimary", ResolutionAction.Add, null), new("Instance", "Pump-01", ResolutionAction.Add, null), }, user: "bob", ct: CancellationToken.None, nameMap: nameMap)); } await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); Assert.Equal(0, await ctx.Sites.CountAsync()); Assert.Equal(0, await ctx.DataConnections.CountAsync()); Assert.Equal(0, await ctx.Instances.CountAsync()); // A BundleImportFailed audit row records the aborted import. Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed")); } } // ────────────────────────────────────────────────────────────────────── // Overwrite an existing instance: child rows replaced from the bundle // ────────────────────────────────────────────────────────────────────── [Fact] public async Task ApplyAsync_Overwrite_existing_instance_replaces_child_rows_and_remaps_binding() { await SeedSiteClosureAsync(); var sessionId = await ExportAllSitesAndLoadAsync(); // Mutate the target instance so its children diverge from the bundle: // drop the override + binding, add a junk override. Overwrite must restore // the bundle's shape and rebind to the (still-present) target connection. int targetConnId; await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); targetConnId = (await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary")).Id; var inst = await ctx.Instances .Include(i => i.AttributeOverrides) .Include(i => i.ConnectionBindings) .SingleAsync(i => i.UniqueName == "Pump-01"); ctx.InstanceAttributeOverrides.RemoveRange(inst.AttributeOverrides); ctx.InstanceConnectionBindings.RemoveRange(inst.ConnectionBindings); inst.AttributeOverrides.Clear(); inst.ConnectionBindings.Clear(); inst.AttributeOverrides.Add(new InstanceAttributeOverride("Junk") { OverrideValue = "stale" }); await ctx.SaveChangesAsync(); } var nameMap = new BundleNameMap( Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") }, Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") }); var result = await ApplyAsync( sessionId, new List { new("Template", "Pump", ResolutionAction.Skip, null), new("Site", "plant-1", ResolutionAction.Skip, null), new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null), new("Instance", "Pump-01", ResolutionAction.Overwrite, null), }, nameMap); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var inst = await ctx.Instances .Include(i => i.AttributeOverrides) .Include(i => i.ConnectionBindings) .Include(i => i.NativeAlarmSourceOverrides) .SingleAsync(i => i.UniqueName == "Pump-01"); // Bundle's child shape restored — junk override gone, Flow override + binding back. Assert.DoesNotContain(inst.AttributeOverrides, o => o.AttributeName == "Junk"); Assert.Contains(inst.AttributeOverrides, o => o.AttributeName == "Flow" && o.OverrideValue == "42"); var binding = Assert.Single(inst.ConnectionBindings); Assert.Equal(targetConnId, binding.DataConnectionId); Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride); Assert.Equal(InstanceState.NotDeployed, inst.State); } Assert.Equal(1, result.Overwritten); } }