diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleImporter.cs
index 78df5513..8db4ccfd 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleImporter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Transport/IBundleImporter.cs
@@ -28,10 +28,22 @@ public interface IBundleImporter
/// Per-artifact conflict resolutions from the preview step.
/// Username of the operator performing the import, stamped in audit rows.
/// Cancellation token.
+ ///
+ /// The operator-supplied resolution of every source-environment site and connection
+ /// name the bundle references (M8 D1). Each entry is either
+ /// (bind to an existing target site/connection)
+ /// or (create one from the bundle payload). A
+ /// site or connection that the bundle references but that has no explicit entry here is
+ /// auto-matched against the target by identity (existing → MapToExisting, otherwise
+ /// CreateNew). Trailing optional parameter — null is normalised to
+ /// so callers that carry no site/instance payload
+ /// (e.g. central-config-only bundles) keep working unchanged.
+ ///
/// A task that resolves to the result of the committed import transaction.
Task ApplyAsync(
Guid sessionId,
IReadOnlyList resolutions,
string user,
- CancellationToken ct = default);
+ CancellationToken ct = default,
+ BundleNameMap? nameMap = null);
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
index 5f78eb76..6d65ac18 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
+using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
@@ -863,11 +864,17 @@ public sealed class BundleImporter : IBundleImporter
Guid sessionId,
IReadOnlyList resolutions,
string user,
- CancellationToken ct = default)
+ CancellationToken ct = default,
+ BundleNameMap? nameMap = null)
{
ArgumentNullException.ThrowIfNull(resolutions);
ArgumentNullException.ThrowIfNull(user);
+ // Normalise null → Empty so the resolve-or-create logic always has a map
+ // to consult. An empty map means "no explicit operator mappings" — every
+ // referenced site/connection falls through to the identity auto-match.
+ nameMap ??= BundleNameMap.Empty;
+
var session = _sessionStore.Get(sessionId)
?? throw new InvalidOperationException($"Bundle session {sessionId} not found or expired.");
if (session.Locked)
@@ -929,11 +936,42 @@ public sealed class BundleImporter : IBundleImporter
// a Skip on a dependency surfaces as a missing-reference error
// rather than silently passing.
var validationErrors = await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
+ // M8: validate every site / connection / template reference the
+ // site-instance payload depends on BEFORE any row is staged. Running
+ // this in the validation phase (not as an apply-pass guard) preserves
+ // the rollback contract: a structurally-unresolvable bundle fails with
+ // an empty change tracker, so nothing is half-written on the in-memory
+ // provider (where the intermediate site/connection flush can't be
+ // undone by ChangeTracker.Clear). The apply passes keep defensive
+ // guards, but in normal operation those never fire.
+ validationErrors = validationErrors.Count > 0
+ ? validationErrors
+ : await ValidateSiteInstanceReferencesAsync(content, resolutionMap, nameMap, ct).ConfigureAwait(false);
if (validationErrors.Count > 0)
{
throw new SemanticValidationException(validationErrors);
}
+ // ---- M8 site/instance-scoped apply: sites + connections FIRST ----
+ // Sites are the FK target for both data connections (SiteId) and
+ // instances (SiteId); data connections are the FK target for instance
+ // connection bindings (DataConnectionId) and the rewrite target for
+ // native-alarm-source ConnectionNameOverride. Resolve-or-create both
+ // BEFORE the central-config apply so the maps the instance pass needs
+ // are fully populated, then flush so newly-created site/connection ids
+ // materialise on the relational provider. (The instance pass itself
+ // runs AFTER the central-config flush so it can also resolve template
+ // ids by name.)
+ var siteBySourceIdentifier = await ApplySitesAsync(
+ content, nameMap, resolutionMap, user, summary, ct).ConfigureAwait(false);
+ var connectionMaps = await ApplyDataConnectionsAsync(
+ content, nameMap, siteBySourceIdentifier, resolutionMap, user, summary, ct).ConfigureAwait(false);
+ // Flush so site + connection surrogate ids are assigned (relational
+ // provider) before the instance pass wires its FKs. In-memory assigns
+ // ids on AddAsync, so this is mostly a no-op there, but it keeps the
+ // ordering correct on a real DB. Rides the same outer transaction.
+ await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
+
await ApplyTemplateFoldersAsync(content.TemplateFolders, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyTemplatesAsync(content.Templates, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySharedScriptsAsync(content.SharedScripts, resolutionMap, user, summary, ct).ConfigureAwait(false);
@@ -964,6 +1002,31 @@ public sealed class BundleImporter : IBundleImporter
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
+ // ---- M8 site/instance-scoped apply: instances LAST ----
+ // The instance pass needs every FK target materialised first:
+ // • template id — resolved by name against the just-flushed central
+ // config (Templates were flushed above for the alarm/composition
+ // rewire), plus pre-existing target templates;
+ // • site id — from the site map built by ApplySitesAsync;
+ // • connection id — from the connection map built by
+ // ApplyDataConnectionsAsync.
+ // It writes the instance row + its four child collections (attribute /
+ // alarm / native-alarm-source overrides + connection bindings) and
+ // rewires every name-keyed FK to the target environment's ids.
+ await ApplyInstancesAsync(
+ content, resolutionMap, siteBySourceIdentifier, connectionMaps,
+ user, summary, ct).ConfigureAwait(false);
+
+ // ---- D2 pre-commit point (#16): stale-instance computation ----
+ // Everything the import will write is staged on the change tracker at
+ // this point but NOT yet committed. D2 computes StaleInstanceIds here
+ // — target instances whose template was overwritten by this import and
+ // therefore now carry a stale flattened-config revision — and threads
+ // the resulting list into the ImportResult below (replacing the
+ // Array.Empty() placeholder). The single deferred SaveChangesAsync
+ // is the next statement, so a read against the change tracker here sees
+ // the full post-apply graph before commit.
+
await _auditService.LogAsync(
user: user,
action: "BundleImported",
@@ -2387,6 +2450,784 @@ public sealed class BundleImporter : IBundleImporter
};
}
+ // ─────────────────────────────────────────────────────────────────────
+ // M8 D1 — site/instance-scoped apply.
+ //
+ // Three passes (sites → connections → instances) resolve every cross-
+ // environment name reference against the TARGET database's own surrogate
+ // keys, honouring the operator-supplied BundleNameMap with an identity
+ // auto-match fallback for references the map doesn't mention. Sites and
+ // connections run before the central-config apply (they are FK targets);
+ // instances run after the central-config flush (they additionally need
+ // template ids by name). All three ride the single outer transaction begun
+ // in ApplyAsync, so a throw anywhere rolls back the whole import.
+ // ─────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Resolve-or-create every target the bundle references,
+ /// returning a sourceSiteIdentifier → target Site map (each value
+ /// carries the target environment's surrogate ).
+ ///
+ /// A site's mapping is taken from (matched by
+ /// ); when the bundle carries
+ /// no explicit entry we auto-match by identity — an existing target site with
+ /// the same identifier resolves to ,
+ /// otherwise . CreateNew inserts a
+ /// site from the full payload (display name, description,
+ /// and the verbatim Node A/B + gRPC Node A/B addresses — D3's "carry full
+ /// config" decision). MapToExisting honours the site's
+ /// : leaves
+ /// the target untouched; applies the
+ /// bundle's fields onto the existing row.
+ ///
+ ///
+ /// Sites that are referenced by an instance (or data connection) but
+ /// not carried in must already exist in
+ /// the target — they auto-match to the existing target site. An unresolvable
+ /// reference is a hard error (caught at preview as a blocker; guarded
+ /// defensively here so a stale resolution payload can't write an orphan FK).
+ ///
+ ///
+ private async Task> ApplySitesAsync(
+ BundleContentDto content,
+ BundleNameMap nameMap,
+ Dictionary<(string, string), ImportResolution> resolutionMap,
+ string user,
+ ImportSummary summary,
+ CancellationToken ct)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ var siteMappingByIdentifier = nameMap.Sites
+ .GroupBy(m => m.SourceSiteIdentifier, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
+
+ // Pass 1 — sites the bundle CARRIES (full SiteDto payload available).
+ foreach (var siteDto in content.Sites)
+ {
+ if (result.ContainsKey(siteDto.SiteIdentifier)) continue;
+
+ siteMappingByIdentifier.TryGetValue(siteDto.SiteIdentifier, out var mapping);
+ var existing = await _siteRepo
+ .GetSiteByIdentifierAsync(siteDto.SiteIdentifier, ct).ConfigureAwait(false);
+
+ // Auto-match when the operator didn't supply an explicit mapping:
+ // existing target → MapToExisting, otherwise CreateNew.
+ var action = mapping?.Action
+ ?? (existing is not null ? MappingAction.MapToExisting : MappingAction.CreateNew);
+
+ // An explicit MapToExisting can target a DIFFERENTLY-named site; honour
+ // the operator's TargetSiteIdentifier when supplied, else fall back to
+ // the same-identifier match.
+ Site? target = existing;
+ if (action == MappingAction.MapToExisting
+ && mapping?.TargetSiteIdentifier is { Length: > 0 } targetId
+ && !string.Equals(targetId, siteDto.SiteIdentifier, StringComparison.Ordinal))
+ {
+ target = await _siteRepo.GetSiteByIdentifierAsync(targetId, ct).ConfigureAwait(false);
+ }
+
+ if (action == MappingAction.CreateNew || target is null)
+ {
+ var created = BuildSite(siteDto);
+ await _siteRepo.AddSiteAsync(created, ct).ConfigureAwait(false);
+ await _auditService.LogAsync(user, "Create", "Site", "0", created.SiteIdentifier,
+ new { created.SiteIdentifier, created.Name }, ct).ConfigureAwait(false);
+ summary.Added++;
+ result[siteDto.SiteIdentifier] = created;
+ continue;
+ }
+
+ // MapToExisting — honour the site's own conflict resolution.
+ var resolution = ResolveOrDefault(resolutionMap, "Site", siteDto.SiteIdentifier);
+ if (resolution.Action == ResolutionAction.Overwrite)
+ {
+ ApplySiteFields(target, siteDto);
+ await _siteRepo.UpdateSiteAsync(target, ct).ConfigureAwait(false);
+ await _auditService.LogAsync(user, "Update", "Site", target.Id.ToString(), target.SiteIdentifier,
+ new { target.SiteIdentifier, target.Name }, ct).ConfigureAwait(false);
+ summary.Overwritten++;
+ }
+ else
+ {
+ // Skip / Add(no-op for existing) — leave the target untouched.
+ summary.Skipped++;
+ }
+ result[siteDto.SiteIdentifier] = target;
+ }
+
+ // Pass 2 — sites REFERENCED but not carried (instances / connections).
+ // These must already exist in the target (no DTO to create from). Auto-
+ // match by identity; honour an explicit MapToExisting redirect.
+ foreach (var identifier in EnumerateReferencedSiteIdentifiers(content))
+ {
+ if (result.ContainsKey(identifier)) continue;
+
+ siteMappingByIdentifier.TryGetValue(identifier, out var mapping);
+ var lookupId = mapping?.Action == MappingAction.MapToExisting
+ && mapping.TargetSiteIdentifier is { Length: > 0 } t
+ ? t
+ : identifier;
+ var target = await _siteRepo.GetSiteByIdentifierAsync(lookupId, ct).ConfigureAwait(false);
+ if (target is null)
+ {
+ // Defensive guard — preview surfaces this as a blocker, but a stale
+ // resolution payload could still reach apply. Fail the whole import
+ // rather than write an instance/connection with an orphan SiteId.
+ throw new InvalidOperationException(
+ $"Site '{identifier}' is referenced by the bundle but not present in the target "
+ + "and not carried in the bundle — cannot resolve a target site.");
+ }
+ result[identifier] = target;
+ }
+
+ return result;
+ }
+
+ ///
+ /// Distinct source-site identifiers referenced by instances and data
+ /// connections but not necessarily carried as a .
+ ///
+ private static IEnumerable EnumerateReferencedSiteIdentifiers(BundleContentDto content)
+ {
+ var seen = new HashSet(StringComparer.Ordinal);
+ foreach (var i in content.Instances)
+ {
+ if (!string.IsNullOrEmpty(i.SiteIdentifier) && seen.Add(i.SiteIdentifier))
+ yield return i.SiteIdentifier;
+ }
+ foreach (var dc in content.DataConnections)
+ {
+ if (!string.IsNullOrEmpty(dc.SiteIdentifier) && seen.Add(dc.SiteIdentifier))
+ yield return dc.SiteIdentifier;
+ }
+ }
+
+ private static Site BuildSite(SiteDto dto) => new(dto.Name, dto.SiteIdentifier)
+ {
+ Description = dto.Description,
+ NodeAAddress = dto.NodeAAddress,
+ NodeBAddress = dto.NodeBAddress,
+ GrpcNodeAAddress = dto.GrpcNodeAAddress,
+ GrpcNodeBAddress = dto.GrpcNodeBAddress,
+ };
+
+ private static void ApplySiteFields(Site target, SiteDto dto)
+ {
+ target.Name = dto.Name;
+ target.Description = dto.Description;
+ target.NodeAAddress = dto.NodeAAddress;
+ target.NodeBAddress = dto.NodeBAddress;
+ target.GrpcNodeAAddress = dto.GrpcNodeAAddress;
+ target.GrpcNodeBAddress = dto.GrpcNodeBAddress;
+ }
+
+ ///
+ /// The resolved-connection maps the instance pass needs:
+ /// rewires connection-binding FKs
+ /// ((sourceSite, sourceName) → target DataConnectionId);
+ /// rewrites native-alarm-source
+ /// ConnectionNameOverride values to the MAPPED target connection name
+ /// ((sourceSite, sourceName) → target connection Name) so a
+ /// differently-named MapToExisting redirect carries through.
+ ///
+ private readonly record struct ResolvedConnectionMaps(
+ Dictionary<(string Site, string Name), int> IdBySourceRef,
+ Dictionary<(string Site, string Name), string> TargetNameBySourceRef);
+
+ ///
+ /// Resolve-or-create every target the bundle
+ /// references, returning the id + target-name maps (see
+ /// ) the instance pass uses to rewire
+ /// connection-binding FKs and native-alarm-source ConnectionNameOverride
+ /// rewrites.
+ ///
+ /// A connection's mapping is taken from (matched by
+ /// +
+ /// ); with no explicit
+ /// entry we auto-match by name WITHIN the mapped target site. CreateNew
+ /// inserts a connection under the mapped target site, restoring
+ /// PrimaryConfiguration / BackupConfiguration from the DTO's
+ /// . MapToExisting honours the connection's
+ /// (Overwrite applies the bundle fields; Skip
+ /// leaves the target row untouched).
+ ///
+ ///
+ private async Task ApplyDataConnectionsAsync(
+ BundleContentDto content,
+ BundleNameMap nameMap,
+ Dictionary siteBySourceIdentifier,
+ Dictionary<(string, string), ImportResolution> resolutionMap,
+ string user,
+ ImportSummary summary,
+ CancellationToken ct)
+ {
+ var result = new Dictionary<(string, string), int>();
+ var targetNameByRef = new Dictionary<(string Site, string Name), string>();
+ var connMappingByRef = nameMap.Connections
+ .GroupBy(m => (m.SourceSiteIdentifier, m.SourceConnectionName))
+ .ToDictionary(g => g.Key, g => g.First());
+
+ // Memoise the target site's existing connections (one query per site).
+ var targetConnsBySiteId = new Dictionary>();
+ async Task> TargetConnsAsync(int siteId)
+ {
+ if (targetConnsBySiteId.TryGetValue(siteId, out var cached)) return cached;
+ var conns = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, ct).ConfigureAwait(false);
+ targetConnsBySiteId[siteId] = conns;
+ return conns;
+ }
+
+ foreach (var dcDto in content.DataConnections)
+ {
+ var key = (dcDto.SiteIdentifier, dcDto.Name);
+ if (result.ContainsKey(key)) continue;
+
+ if (!siteBySourceIdentifier.TryGetValue(dcDto.SiteIdentifier, out var targetSite))
+ {
+ // Should never happen: ApplySitesAsync resolved every referenced
+ // site (including those only referenced by a connection). Guard so a
+ // missing entry fails the import instead of writing an orphan FK.
+ throw new InvalidOperationException(
+ $"Data connection '{dcDto.SiteIdentifier}/{dcDto.Name}' references a site that "
+ + "could not be resolved to a target.");
+ }
+
+ connMappingByRef.TryGetValue(key, out var mapping);
+ var targetConns = await TargetConnsAsync(targetSite.Id).ConfigureAwait(false);
+
+ // The connection name to match in the target — an explicit
+ // MapToExisting may redirect to a differently-named target connection.
+ var targetName = mapping?.Action == MappingAction.MapToExisting
+ && mapping.TargetConnectionName is { Length: > 0 } tn
+ ? tn
+ : dcDto.Name;
+ var existing = targetConns.FirstOrDefault(c =>
+ string.Equals(c.Name, targetName, StringComparison.Ordinal));
+
+ var action = mapping?.Action
+ ?? (existing is not null ? MappingAction.MapToExisting : MappingAction.CreateNew);
+
+ if (action == MappingAction.CreateNew || existing is null)
+ {
+ var created = BuildDataConnection(dcDto, targetSite.Id);
+ await _siteRepo.AddDataConnectionAsync(created, ct).ConfigureAwait(false);
+ await _auditService.LogAsync(user, "Create", "DataConnection", "0",
+ $"{targetSite.SiteIdentifier}/{created.Name}",
+ new { created.Name, created.Protocol, SiteIdentifier = targetSite.SiteIdentifier }, ct)
+ .ConfigureAwait(false);
+ summary.Added++;
+ result[key] = created.Id;
+ targetNameByRef[key] = created.Name;
+ continue;
+ }
+
+ // MapToExisting — honour the connection's own conflict resolution.
+ var resolution = ResolveOrDefault(
+ resolutionMap, "DataConnection", dcDto.Name);
+ if (resolution.Action == ResolutionAction.Overwrite)
+ {
+ ApplyDataConnectionFields(existing, dcDto);
+ await _siteRepo.UpdateDataConnectionAsync(existing, ct).ConfigureAwait(false);
+ await _auditService.LogAsync(user, "Update", "DataConnection", existing.Id.ToString(),
+ $"{targetSite.SiteIdentifier}/{existing.Name}",
+ new { existing.Name, existing.Protocol }, ct).ConfigureAwait(false);
+ summary.Overwritten++;
+ }
+ else
+ {
+ summary.Skipped++;
+ }
+ result[key] = existing.Id;
+ // existing.Name is the TARGET connection name — for a same-name match
+ // it equals dcDto.Name, for a redirect it's the redirected target.
+ targetNameByRef[key] = existing.Name;
+ }
+
+ return new ResolvedConnectionMaps(result, targetNameByRef);
+ }
+
+ private static DataConnection BuildDataConnection(DataConnectionDto dto, int siteId) =>
+ new(dto.Name, dto.Protocol, siteId)
+ {
+ FailoverRetryCount = dto.FailoverRetryCount,
+ PrimaryConfiguration =
+ dto.Secrets?.Values.TryGetValue("PrimaryConfiguration", out var pc) == true ? pc : null,
+ BackupConfiguration =
+ dto.Secrets?.Values.TryGetValue("BackupConfiguration", out var bc) == true ? bc : null,
+ };
+
+ private static void ApplyDataConnectionFields(DataConnection target, DataConnectionDto dto)
+ {
+ target.Protocol = dto.Protocol;
+ target.FailoverRetryCount = dto.FailoverRetryCount;
+ target.PrimaryConfiguration =
+ dto.Secrets?.Values.TryGetValue("PrimaryConfiguration", out var pc) == true ? pc : null;
+ target.BackupConfiguration =
+ dto.Secrets?.Values.TryGetValue("BackupConfiguration", out var bc) == true ? bc : null;
+ }
+
+ ///
+ /// Upsert every in the bundle, rewiring its
+ /// cross-environment name references to the target's surrogate keys:
+ ///
+ /// - TemplateName → target template id (just-imported set first,
+ /// then pre-existing target); an unresolved template was a preview blocker —
+ /// guarded here so a stale payload fails the import rather than writing an
+ /// orphan FK.
+ /// - SiteIdentifier → target site id (from the site map).
+ /// - AreaName → an area under the target site, created if missing.
+ /// - each ConnectionBinding.ConnectionName → target
+ /// DataConnectionId (from the connection map).
+ /// - each NativeAlarmSourceOverride.ConnectionNameOverride →
+ /// rewritten to the MAPPED target connection name.
+ ///
+ /// Imported instances are always written with
+ /// — an imported instance is design-time configuration, never carried as
+ /// live/deployed across environments. Identity is the UniqueName
+ /// (hydrated via ,
+ /// which eager-loads all four child collections). The instance's
+ /// drives Add / Overwrite / Skip / Rename; on
+ /// Overwrite the existing child rows are deleted-then-readded so the bundle is
+ /// the source of truth (mirrors the template-overwrite child-sync pattern).
+ ///
+ private async Task ApplyInstancesAsync(
+ BundleContentDto content,
+ Dictionary<(string, string), ImportResolution> resolutionMap,
+ Dictionary siteBySourceIdentifier,
+ ResolvedConnectionMaps connectionMaps,
+ string user,
+ ImportSummary summary,
+ CancellationToken ct)
+ {
+ if (content.Instances.Count == 0) return;
+
+ // Build a target-template name→id map once (just-imported templates were
+ // flushed before this pass; pre-existing target templates count too).
+ var templateIdByName = (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
+ .GroupBy(t => t.Name, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
+
+ // Memoise area resolution per (siteId, areaName) so two instances under the
+ // same area don't each create a duplicate row.
+ var areaIdByKey = new Dictionary<(int SiteId, string Name), int>();
+
+ foreach (var dto in content.Instances)
+ {
+ var resolution = ResolveOrDefault(resolutionMap, "Instance", dto.UniqueName);
+ if (resolution.Action == ResolutionAction.Skip)
+ {
+ summary.Skipped++;
+ continue;
+ }
+
+ // Resolve the template id by name (post-rename: an imported template
+ // resolved as Rename was written under RenameTo, which is what the
+ // instance's TemplateName must already match in a self-consistent
+ // bundle; v1 does not rewrite instance TemplateName references).
+ if (!templateIdByName.TryGetValue(dto.TemplateName, out var templateId))
+ {
+ throw new InvalidOperationException(
+ $"Instance '{dto.UniqueName}' references template '{dto.TemplateName}' which is "
+ + "present in neither the bundle nor the target.");
+ }
+
+ if (!siteBySourceIdentifier.TryGetValue(dto.SiteIdentifier, out var targetSite))
+ {
+ throw new InvalidOperationException(
+ $"Instance '{dto.UniqueName}' references site '{dto.SiteIdentifier}' which could "
+ + "not be resolved to a target.");
+ }
+
+ int? areaId = await ResolveOrCreateAreaIdAsync(
+ dto.AreaName, targetSite.Id, areaIdByKey, ct).ConfigureAwait(false);
+
+ switch (resolution.Action)
+ {
+ case ResolutionAction.Rename:
+ {
+ var name = resolution.RenameTo ?? dto.UniqueName;
+ var inst = BuildInstance(
+ dto, name, templateId, targetSite, areaId, connectionMaps);
+ await _templateRepo.AddInstanceAsync(inst, ct).ConfigureAwait(false);
+ await _auditService.LogAsync(user, "Create", "Instance", "0", name,
+ new { UniqueName = name, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier, RenamedFrom = dto.UniqueName },
+ ct).ConfigureAwait(false);
+ summary.Renamed++;
+ break;
+ }
+ case ResolutionAction.Overwrite:
+ {
+ var existing = await _templateRepo
+ .GetInstanceByUniqueNameAsync(dto.UniqueName, ct).ConfigureAwait(false);
+ if (existing is null)
+ {
+ // Overwrite chosen but no target row — treat as Add (the
+ // preview's "existing" read may have raced a delete). Write
+ // a fresh instance under the bundle's UniqueName.
+ var added = BuildInstance(
+ dto, dto.UniqueName, templateId, targetSite, areaId, connectionMaps);
+ await _templateRepo.AddInstanceAsync(added, ct).ConfigureAwait(false);
+ await _auditService.LogAsync(user, "Create", "Instance", "0", added.UniqueName,
+ new { added.UniqueName, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier },
+ ct).ConfigureAwait(false);
+ summary.Added++;
+ break;
+ }
+
+ // Rewire scalar FKs + state, then replace child rows.
+ existing.TemplateId = templateId;
+ existing.SiteId = targetSite.Id;
+ existing.AreaId = areaId;
+ existing.State = InstanceState.NotDeployed;
+ await ReplaceInstanceChildrenAsync(
+ existing, dto, dto.SiteIdentifier, connectionMaps, ct)
+ .ConfigureAwait(false);
+ await _templateRepo.UpdateInstanceAsync(existing, ct).ConfigureAwait(false);
+ await _auditService.LogAsync(user, "Update", "Instance", existing.Id.ToString(), existing.UniqueName,
+ new { existing.UniqueName, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier },
+ ct).ConfigureAwait(false);
+ summary.Overwritten++;
+ break;
+ }
+ case ResolutionAction.Add:
+ default:
+ {
+ var inst = BuildInstance(
+ dto, dto.UniqueName, templateId, targetSite, areaId, connectionMaps);
+ await _templateRepo.AddInstanceAsync(inst, ct).ConfigureAwait(false);
+ await _auditService.LogAsync(user, "Create", "Instance", "0", inst.UniqueName,
+ new { inst.UniqueName, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier },
+ ct).ConfigureAwait(false);
+ summary.Added++;
+ break;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Resolves an instance's area by name within the target site, creating the
+ /// area if it doesn't exist. Returns null when is
+ /// null/empty (the instance has no area). Memoised via
+ /// so repeated references resolve to one row.
+ ///
+ private async Task ResolveOrCreateAreaIdAsync(
+ string? areaName,
+ int siteId,
+ Dictionary<(int SiteId, string Name), int> areaIdByKey,
+ CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(areaName)) return null;
+
+ var key = (siteId, areaName);
+ if (areaIdByKey.TryGetValue(key, out var cached)) return cached;
+
+ var existingAreas = await _templateRepo.GetAreasBySiteIdAsync(siteId, ct).ConfigureAwait(false);
+ var match = existingAreas.FirstOrDefault(a => string.Equals(a.Name, areaName, StringComparison.Ordinal));
+ if (match is not null)
+ {
+ areaIdByKey[key] = match.Id;
+ return match.Id;
+ }
+
+ var area = new Area(areaName) { SiteId = siteId };
+ await _templateRepo.AddAreaAsync(area, ct).ConfigureAwait(false);
+ // Flush so the area's surrogate id materialises before it's used as an
+ // instance FK (relational provider). Rides the outer transaction.
+ await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
+ areaIdByKey[key] = area.Id;
+ return area.Id;
+ }
+
+ ///
+ /// Builds a new from a DTO with all four child
+ /// collections populated and every cross-environment FK rewired to the
+ /// target. State is always .
+ ///
+ private static Instance BuildInstance(
+ InstanceDto dto,
+ string uniqueName,
+ int templateId,
+ Site targetSite,
+ int? areaId,
+ ResolvedConnectionMaps connectionMaps)
+ {
+ var inst = new Instance(uniqueName)
+ {
+ TemplateId = templateId,
+ SiteId = targetSite.Id,
+ AreaId = areaId,
+ State = InstanceState.NotDeployed,
+ };
+ // The connection maps are keyed by the SOURCE site identifier (dto.SiteIdentifier),
+ // NOT the target's — a cross-site MapToExisting redirect resolves the binding
+ // through the source key, then the map already points at the target's id/name.
+ PopulateInstanceChildren(inst, dto, dto.SiteIdentifier, connectionMaps);
+ return inst;
+ }
+
+ ///
+ /// Populates the four child collections on a tracked/new instance from the
+ /// DTO. Connection bindings rewire ConnectionName → the target
+ /// DataConnectionId; native-alarm-source overrides rewrite
+ /// ConnectionNameOverride to the MAPPED target connection name. The
+ /// connection map is keyed by (sourceSiteIdentifier, sourceConnectionName) —
+ /// the instance's own SiteIdentifier is the source-site key.
+ ///
+ private static void PopulateInstanceChildren(
+ Instance inst,
+ InstanceDto dto,
+ string sourceSiteIdentifier,
+ ResolvedConnectionMaps connectionMaps)
+ {
+ foreach (var o in dto.AttributeOverrides)
+ {
+ inst.AttributeOverrides.Add(new InstanceAttributeOverride(o.AttributeName)
+ {
+ OverrideValue = o.OverrideValue,
+ ElementDataType = o.ElementDataType,
+ });
+ }
+ foreach (var o in dto.AlarmOverrides)
+ {
+ inst.AlarmOverrides.Add(new InstanceAlarmOverride(o.AlarmCanonicalName)
+ {
+ TriggerConfigurationOverride = o.TriggerConfigurationOverride,
+ PriorityLevelOverride = o.PriorityLevelOverride,
+ });
+ }
+ foreach (var o in dto.NativeAlarmSourceOverrides)
+ {
+ // Rewrite the connection-name override to the MAPPED target connection
+ // name (the binding FK is an id, but this override stores a NAME). When
+ // the source name maps to a differently-named target connection we must
+ // carry the target name forward; otherwise keep the original (it already
+ // names a target connection, e.g. an unmapped pass-through).
+ inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride(o.SourceCanonicalName)
+ {
+ ConnectionNameOverride = RewriteConnectionName(
+ o.ConnectionNameOverride, sourceSiteIdentifier, connectionMaps),
+ SourceReferenceOverride = o.SourceReferenceOverride,
+ ConditionFilterOverride = o.ConditionFilterOverride,
+ });
+ }
+ foreach (var b in dto.ConnectionBindings)
+ {
+ // Resolve ConnectionName → target DataConnectionId. A binding whose
+ // connection didn't resolve was a preview blocker; default to 0 here
+ // (the FK constraint / a later deploy gate surfaces it) rather than
+ // throwing, since the binding's attribute may legitimately be unbound.
+ connectionMaps.IdBySourceRef.TryGetValue((sourceSiteIdentifier, b.ConnectionName), out var connId);
+ inst.ConnectionBindings.Add(new InstanceConnectionBinding(b.AttributeName)
+ {
+ DataConnectionId = connId,
+ DataSourceReferenceOverride = b.DataSourceReferenceOverride,
+ });
+ }
+ }
+
+ ///
+ /// Replaces an existing instance's four child collections with the bundle's
+ /// (Overwrite semantics — the bundle is the source of truth). Deletes the
+ /// current rows via the repo, clears the tracked navigations, then re-adds
+ /// from the DTO with FKs rewired. Mirrors the template-overwrite child sync.
+ ///
+ private async Task ReplaceInstanceChildrenAsync(
+ Instance existing,
+ InstanceDto dto,
+ string sourceSiteIdentifier,
+ ResolvedConnectionMaps connectionMaps,
+ CancellationToken ct)
+ {
+ foreach (var o in existing.AttributeOverrides.ToList())
+ {
+ await _templateRepo.DeleteInstanceAttributeOverrideAsync(o.Id, ct).ConfigureAwait(false);
+ }
+ existing.AttributeOverrides.Clear();
+ foreach (var o in existing.AlarmOverrides.ToList())
+ {
+ await _templateRepo.DeleteInstanceAlarmOverrideAsync(o.Id, ct).ConfigureAwait(false);
+ }
+ existing.AlarmOverrides.Clear();
+ foreach (var o in existing.NativeAlarmSourceOverrides.ToList())
+ {
+ await _templateRepo.DeleteInstanceNativeAlarmSourceOverrideAsync(o.Id, ct).ConfigureAwait(false);
+ }
+ existing.NativeAlarmSourceOverrides.Clear();
+ foreach (var b in existing.ConnectionBindings.ToList())
+ {
+ await _templateRepo.DeleteInstanceConnectionBindingAsync(b.Id, ct).ConfigureAwait(false);
+ }
+ existing.ConnectionBindings.Clear();
+
+ PopulateInstanceChildren(existing, dto, sourceSiteIdentifier, connectionMaps);
+ }
+
+ ///
+ /// Rewrites a source connection-name reference (a native-alarm-source
+ /// ConnectionNameOverride) to the MAPPED target connection name. The
+ /// lookup is (sourceSite, sourceName) → target Name via the
+ /// target-name map built — so a
+ /// differently-named MapToExisting redirect (sourceConn "OpcA" mapped onto
+ /// target "OpcUaPrimary") carries through correctly. Returns the original
+ /// name unchanged when it doesn't resolve in the map (an unmapped
+ /// pass-through that already names a target connection — e.g. a connection
+ /// that auto-matched an existing target but wasn't carried in the bundle's
+ /// DataConnections, so it isn't in the map).
+ ///
+ private static string? RewriteConnectionName(
+ string? sourceName,
+ string sourceSiteIdentifier,
+ ResolvedConnectionMaps connectionMaps)
+ {
+ if (string.IsNullOrEmpty(sourceName)) return sourceName;
+ return connectionMaps.TargetNameBySourceRef.TryGetValue((sourceSiteIdentifier, sourceName), out var targetName)
+ ? targetName
+ : sourceName;
+ }
+
+ ///
+ /// M8: validates every cross-environment reference the site/instance payload
+ /// depends on, BEFORE any row is staged — so a structurally-unresolvable
+ /// bundle fails with an empty change tracker (preserving the all-or-nothing
+ /// rollback contract on every EF provider, including the in-memory one whose
+ /// intermediate flush can't be undone by ChangeTracker.Clear).
+ /// Checks, mirroring the resolve-or-create logic the apply passes use:
+ ///
+ /// - every non-Skip instance's TemplateName resolves to an in-bundle
+ /// (non-Skip) template or a pre-existing target template;
+ /// - every referenced site resolves — carried in the bundle (auto-creatable),
+ /// or mapped/auto-matched to an existing target site;
+ /// - every referenced connection resolves — carried in the bundle
+ /// (auto-creatable under its mapped site), or mapped/auto-matched to an existing
+ /// connection in the mapped target site.
+ ///
+ ///
+ private async Task> ValidateSiteInstanceReferencesAsync(
+ BundleContentDto content,
+ Dictionary<(string, string), ImportResolution> resolutionMap,
+ BundleNameMap nameMap,
+ CancellationToken ct)
+ {
+ if (content.Instances.Count == 0 && content.DataConnections.Count == 0 && content.Sites.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ var errors = new List();
+
+ var siteMappingByIdentifier = nameMap.Sites
+ .GroupBy(m => m.SourceSiteIdentifier, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
+ var connMappingByRef = nameMap.Connections
+ .GroupBy(m => (m.SourceSiteIdentifier, m.SourceConnectionName))
+ .ToDictionary(g => g.Key, g => g.First());
+
+ // Sites carried in the bundle are always resolvable (CreateNew or
+ // MapToExisting both yield a target).
+ var bundleSiteIdentifiers = new HashSet(
+ content.Sites.Select(s => s.SiteIdentifier), StringComparer.Ordinal);
+
+ // Resolve a source site identifier → target Site (memoised). Null = the
+ // source site has no resolvable target AND isn't carried in the bundle.
+ var targetSiteCache = new Dictionary(StringComparer.Ordinal);
+ async Task ResolveTargetSiteAsync(string identifier)
+ {
+ if (targetSiteCache.TryGetValue(identifier, out var cached)) return cached;
+ siteMappingByIdentifier.TryGetValue(identifier, out var mapping);
+ var lookupId = mapping?.Action == MappingAction.MapToExisting
+ && mapping.TargetSiteIdentifier is { Length: > 0 } t
+ ? t
+ : identifier;
+ var site = await _siteRepo.GetSiteByIdentifierAsync(lookupId, ct).ConfigureAwait(false);
+ targetSiteCache[identifier] = site;
+ return site;
+ }
+
+ // ---- Site references (instances + connections + carried sites) ----
+ foreach (var identifier in EnumerateReferencedSiteIdentifiers(content)
+ .Concat(content.Sites.Select(s => s.SiteIdentifier))
+ .Distinct(StringComparer.Ordinal))
+ {
+ if (bundleSiteIdentifiers.Contains(identifier)) continue; // carried → creatable
+ var target = await ResolveTargetSiteAsync(identifier).ConfigureAwait(false);
+ if (target is null)
+ {
+ errors.Add(
+ $"Site '{identifier}' is referenced by the bundle but is neither carried in the "
+ + "bundle nor resolvable to an existing target site.");
+ }
+ }
+
+ // ---- Connection references (bindings + native-alarm overrides + carried) ----
+ var bundleConnectionRefs = new HashSet<(string Site, string Name)>(
+ content.DataConnections.Select(dc => (dc.SiteIdentifier, dc.Name)));
+
+ var referencedConnections = new HashSet<(string Site, string Name)>(bundleConnectionRefs);
+ foreach (var inst in content.Instances)
+ {
+ var resolution = ResolveOrDefault(resolutionMap, "Instance", inst.UniqueName);
+ if (resolution.Action == ResolutionAction.Skip) continue;
+ foreach (var b in inst.ConnectionBindings)
+ {
+ if (!string.IsNullOrEmpty(b.ConnectionName))
+ referencedConnections.Add((inst.SiteIdentifier, b.ConnectionName));
+ }
+ foreach (var n in inst.NativeAlarmSourceOverrides)
+ {
+ if (!string.IsNullOrEmpty(n.ConnectionNameOverride))
+ referencedConnections.Add((inst.SiteIdentifier, n.ConnectionNameOverride));
+ }
+ }
+
+ foreach (var (site, name) in referencedConnections)
+ {
+ if (bundleConnectionRefs.Contains((site, name))) continue; // carried → creatable
+ var targetSite = await ResolveTargetSiteAsync(site).ConfigureAwait(false);
+ if (targetSite is null) continue; // already flagged above as a site error
+ connMappingByRef.TryGetValue((site, name), out var mapping);
+ var targetName = mapping?.Action == MappingAction.MapToExisting
+ && mapping.TargetConnectionName is { Length: > 0 } tn
+ ? tn
+ : name;
+ var conns = await _siteRepo.GetDataConnectionsBySiteIdAsync(targetSite.Id, ct).ConfigureAwait(false);
+ if (!conns.Any(c => string.Equals(c.Name, targetName, StringComparison.Ordinal)))
+ {
+ errors.Add(
+ $"Connection '{site}/{name}' is referenced by the bundle but is neither carried in "
+ + "the bundle nor resolvable to an existing connection in the mapped target site.");
+ }
+ }
+
+ // ---- Instance template references ----
+ var bundleTemplateNames = new HashSet(StringComparer.Ordinal);
+ foreach (var t in content.Templates)
+ {
+ var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
+ if (resolution.Action == ResolutionAction.Skip) continue;
+ bundleTemplateNames.Add(t.Name);
+ if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo))
+ bundleTemplateNames.Add(resolution.RenameTo);
+ }
+ var targetTemplateNames = new HashSet(
+ (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false)).Select(t => t.Name),
+ StringComparer.Ordinal);
+
+ foreach (var inst in content.Instances)
+ {
+ var resolution = ResolveOrDefault(resolutionMap, "Instance", inst.UniqueName);
+ if (resolution.Action == ResolutionAction.Skip) continue;
+ if (string.IsNullOrEmpty(inst.TemplateName)) continue;
+ if (bundleTemplateNames.Contains(inst.TemplateName)) continue;
+ if (targetTemplateNames.Contains(inst.TemplateName)) continue;
+ errors.Add(
+ $"Instance '{inst.UniqueName}' references template '{inst.TemplateName}' which is "
+ + "present in neither the bundle nor the target.");
+ }
+
+ return errors;
+ }
+
///
/// Two-tier semantic validation run before any rows are flushed:
///
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs
new file mode 100644
index 00000000..6f097385
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs
@@ -0,0 +1,669 @@
+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);
+ }
+}