From 6457f03fae3ac8ec3c1485814386e4439b33ee2a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 06:52:40 -0400 Subject: [PATCH] feat(transport): apply site/instance import with name-map + FK rewire (M8 D1, T18) --- .../Interfaces/Transport/IBundleImporter.cs | 14 +- .../Import/BundleImporter.cs | 843 +++++++++++++++++- .../Import/SiteInstanceImportTests.cs | 669 ++++++++++++++ 3 files changed, 1524 insertions(+), 2 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/SiteInstanceImportTests.cs 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); + } +}