feat(transport): preview diff + required-mapping detection + blockers (M8 C2)
This commit is contained in:
@@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
@@ -65,6 +66,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
private readonly IExternalSystemRepository _externalRepo;
|
private readonly IExternalSystemRepository _externalRepo;
|
||||||
private readonly INotificationRepository _notificationRepo;
|
private readonly INotificationRepository _notificationRepo;
|
||||||
private readonly IInboundApiRepository _inboundApiRepo;
|
private readonly IInboundApiRepository _inboundApiRepo;
|
||||||
|
private readonly ISiteRepository _siteRepo;
|
||||||
private readonly IBundleSessionStore _sessionStore;
|
private readonly IBundleSessionStore _sessionStore;
|
||||||
private readonly BundleUnlockRateLimiter _unlockRateLimiter;
|
private readonly BundleUnlockRateLimiter _unlockRateLimiter;
|
||||||
private readonly IOptions<TransportOptions> _options;
|
private readonly IOptions<TransportOptions> _options;
|
||||||
@@ -87,6 +89,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
/// <param name="externalRepo">External system repository for diff and apply.</param>
|
/// <param name="externalRepo">External system repository for diff and apply.</param>
|
||||||
/// <param name="notificationRepo">Notification repository for diff and apply.</param>
|
/// <param name="notificationRepo">Notification repository for diff and apply.</param>
|
||||||
/// <param name="inboundApiRepo">Inbound API repository for diff and apply.</param>
|
/// <param name="inboundApiRepo">Inbound API repository for diff and apply.</param>
|
||||||
|
/// <param name="siteRepo">Site repository — supplies the target sites and site-scoped data connections that the preview's site/connection auto-match resolves against (M8 C2).</param>
|
||||||
/// <param name="auditService">Audit service for writing per-entity import audit rows.</param>
|
/// <param name="auditService">Audit service for writing per-entity import audit rows.</param>
|
||||||
/// <param name="correlationContext">Correlation context that carries the active BundleImportId.</param>
|
/// <param name="correlationContext">Correlation context that carries the active BundleImportId.</param>
|
||||||
/// <param name="dbContext">EF Core context used to commit the import transaction.</param>
|
/// <param name="dbContext">EF Core context used to commit the import transaction.</param>
|
||||||
@@ -104,6 +107,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
IExternalSystemRepository externalRepo,
|
IExternalSystemRepository externalRepo,
|
||||||
INotificationRepository notificationRepo,
|
INotificationRepository notificationRepo,
|
||||||
IInboundApiRepository inboundApiRepo,
|
IInboundApiRepository inboundApiRepo,
|
||||||
|
ISiteRepository siteRepo,
|
||||||
IAuditService auditService,
|
IAuditService auditService,
|
||||||
IAuditCorrelationContext correlationContext,
|
IAuditCorrelationContext correlationContext,
|
||||||
ScadaBridgeDbContext dbContext,
|
ScadaBridgeDbContext dbContext,
|
||||||
@@ -122,6 +126,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
_externalRepo = externalRepo ?? throw new ArgumentNullException(nameof(externalRepo));
|
_externalRepo = externalRepo ?? throw new ArgumentNullException(nameof(externalRepo));
|
||||||
_notificationRepo = notificationRepo ?? throw new ArgumentNullException(nameof(notificationRepo));
|
_notificationRepo = notificationRepo ?? throw new ArgumentNullException(nameof(notificationRepo));
|
||||||
_inboundApiRepo = inboundApiRepo ?? throw new ArgumentNullException(nameof(inboundApiRepo));
|
_inboundApiRepo = inboundApiRepo ?? throw new ArgumentNullException(nameof(inboundApiRepo));
|
||||||
|
_siteRepo = siteRepo ?? throw new ArgumentNullException(nameof(siteRepo));
|
||||||
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
|
||||||
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
|
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
|
||||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||||
@@ -427,10 +432,195 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
items.Add(_diff.CompareApiMethod(m, existing));
|
items.Add(_diff.CompareApiMethod(m, existing));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Blocker detection ----
|
// ---- M8 site/instance-scoped types ----
|
||||||
items.AddRange(await DetectBlockersAsync(content, ct).ConfigureAwait(false));
|
// Sites/DataConnections/Instances are referenced by stable string identity
|
||||||
|
// (SiteIdentifier / connection Name / UniqueName) and resolved against the
|
||||||
|
// TARGET environment's own surrogate keys. We auto-match the source site by
|
||||||
|
// identifier; the result drives both the per-type diffs (a matched target
|
||||||
|
// connection / instance feeds CompareDataConnection / CompareInstance) and
|
||||||
|
// the operator-facing required-mapping list built below.
|
||||||
|
//
|
||||||
|
// Cache target-site lookups + their data connections so we don't re-query
|
||||||
|
// the same site once per instance / connection / native-alarm override.
|
||||||
|
var targetSiteByIdentifier = new Dictionary<string, Site?>(StringComparer.Ordinal);
|
||||||
|
var targetConnectionsBySiteIdentifier =
|
||||||
|
new Dictionary<string, IReadOnlyList<DataConnection>>(StringComparer.Ordinal);
|
||||||
|
|
||||||
return new ImportPreview(sessionId, items);
|
async Task<Site?> ResolveTargetSiteAsync(string siteIdentifier)
|
||||||
|
{
|
||||||
|
if (targetSiteByIdentifier.TryGetValue(siteIdentifier, out var cached)) return cached;
|
||||||
|
var site = await _siteRepo.GetSiteByIdentifierAsync(siteIdentifier, ct).ConfigureAwait(false);
|
||||||
|
targetSiteByIdentifier[siteIdentifier] = site;
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<IReadOnlyList<DataConnection>> ResolveTargetConnectionsAsync(string siteIdentifier)
|
||||||
|
{
|
||||||
|
if (targetConnectionsBySiteIdentifier.TryGetValue(siteIdentifier, out var cached)) return cached;
|
||||||
|
var site = await ResolveTargetSiteAsync(siteIdentifier).ConfigureAwait(false);
|
||||||
|
IReadOnlyList<DataConnection> conns = site is null
|
||||||
|
? Array.Empty<DataConnection>()
|
||||||
|
: await _siteRepo.GetDataConnectionsBySiteIdAsync(site.Id, ct).ConfigureAwait(false);
|
||||||
|
targetConnectionsBySiteIdentifier[siteIdentifier] = conns;
|
||||||
|
return conns;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sites ----
|
||||||
|
foreach (var siteDto in content.Sites)
|
||||||
|
{
|
||||||
|
var existing = await ResolveTargetSiteAsync(siteDto.SiteIdentifier).ConfigureAwait(false);
|
||||||
|
items.Add(_diff.CompareSite(siteDto, existing));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DataConnections (site-scoped; matched by name within the auto-matched target site) ----
|
||||||
|
foreach (var dcDto in content.DataConnections)
|
||||||
|
{
|
||||||
|
var targetConns = await ResolveTargetConnectionsAsync(dcDto.SiteIdentifier).ConfigureAwait(false);
|
||||||
|
var existing = targetConns.FirstOrDefault(c => string.Equals(c.Name, dcDto.Name, StringComparison.Ordinal));
|
||||||
|
items.Add(_diff.CompareDataConnection(dcDto, existing));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Instances (hydrated target + resolved template/site/area names; review item I2) ----
|
||||||
|
foreach (var instDto in content.Instances)
|
||||||
|
{
|
||||||
|
// GetInstanceByUniqueNameAsync eagerly Includes all four child nav
|
||||||
|
// collections (AttributeOverrides / AlarmOverrides / ConnectionBindings /
|
||||||
|
// NativeAlarmSourceOverrides), so the entity handed to CompareInstance is
|
||||||
|
// HYDRATED — its children diff correctly instead of every incoming child
|
||||||
|
// reading as an addition (review item I2).
|
||||||
|
var existing = await _templateRepo
|
||||||
|
.GetInstanceByUniqueNameAsync(instDto.UniqueName, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
string? existingTemplateName = null;
|
||||||
|
string? existingSiteIdentifier = null;
|
||||||
|
string? existingAreaName = null;
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
// The entity stores template/site/area as numeric FKs that can't be
|
||||||
|
// compared cross-environment, so resolve each to the same stable
|
||||||
|
// string identity the incoming DTO carries.
|
||||||
|
var tmpl = await _templateRepo.GetTemplateByIdAsync(existing.TemplateId, ct).ConfigureAwait(false);
|
||||||
|
existingTemplateName = tmpl?.Name;
|
||||||
|
|
||||||
|
var site = await _siteRepo.GetSiteByIdAsync(existing.SiteId, ct).ConfigureAwait(false);
|
||||||
|
existingSiteIdentifier = site?.SiteIdentifier;
|
||||||
|
|
||||||
|
if (existing.AreaId is int areaId)
|
||||||
|
{
|
||||||
|
var area = await _templateRepo.GetAreaByIdAsync(areaId, ct).ConfigureAwait(false);
|
||||||
|
existingAreaName = area?.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(_diff.CompareInstance(
|
||||||
|
instDto, existing, existingTemplateName, existingSiteIdentifier, existingAreaName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Required site/connection mappings (M8) ----
|
||||||
|
var (requiredSites, requiredConnections) = await BuildRequiredMappingsAsync(
|
||||||
|
content, ResolveTargetSiteAsync, ResolveTargetConnectionsAsync).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// ---- Blocker detection ----
|
||||||
|
items.AddRange(await DetectBlockersAsync(content, ResolveTargetConnectionsAsync, ct).ConfigureAwait(false));
|
||||||
|
|
||||||
|
return new ImportPreview(sessionId, items, requiredSites, requiredConnections);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collects the distinct set of source sites and (site, connection) pairs the
|
||||||
|
/// bundle references, auto-matching each against the TARGET environment by
|
||||||
|
/// identity, and returns the operator-facing required-mapping lists (M8 C2).
|
||||||
|
/// <para>
|
||||||
|
/// Site references are drawn from every instance, every site, and every
|
||||||
|
/// data-connection in the bundle. Connection references are drawn from every
|
||||||
|
/// instance connection-binding, every non-null native-alarm-source
|
||||||
|
/// <c>ConnectionNameOverride</c>, and every bundled data-connection. A source
|
||||||
|
/// site auto-matches when the target has a site with the same identifier; a
|
||||||
|
/// connection auto-matches when that target site additionally carries a
|
||||||
|
/// connection of the same name. No match leaves <c>AutoMatchTarget*</c> null —
|
||||||
|
/// the operator must supply an explicit mapping (or accept create-new) at
|
||||||
|
/// apply time. The diff path above already populated the resolver caches, so
|
||||||
|
/// these lookups are served from memory.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<(IReadOnlyList<RequiredSiteMapping> Sites, IReadOnlyList<RequiredConnectionMapping> Connections)>
|
||||||
|
BuildRequiredMappingsAsync(
|
||||||
|
BundleContentDto content,
|
||||||
|
Func<string, Task<Site?>> resolveTargetSite,
|
||||||
|
Func<string, Task<IReadOnlyList<DataConnection>>> resolveTargetConnections)
|
||||||
|
{
|
||||||
|
// Distinct source-site identifiers, with a best-effort display name.
|
||||||
|
// SiteDto carries a Name; instances / data-connections only carry the
|
||||||
|
// identifier, so default that to the identifier when no SiteDto is present.
|
||||||
|
var siteNameByIdentifier = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
void NoteSite(string identifier, string? name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(identifier)) return;
|
||||||
|
if (!siteNameByIdentifier.TryGetValue(identifier, out var existing)
|
||||||
|
|| (string.Equals(existing, identifier, StringComparison.Ordinal) && !string.IsNullOrEmpty(name)))
|
||||||
|
{
|
||||||
|
siteNameByIdentifier[identifier] = string.IsNullOrEmpty(name) ? identifier : name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var s in content.Sites) NoteSite(s.SiteIdentifier, s.Name);
|
||||||
|
foreach (var i in content.Instances) NoteSite(i.SiteIdentifier, null);
|
||||||
|
foreach (var dc in content.DataConnections) NoteSite(dc.SiteIdentifier, null);
|
||||||
|
|
||||||
|
// Distinct (sourceSite, connectionName) pairs referenced anywhere.
|
||||||
|
var connectionRefs = new HashSet<(string Site, string Name)>();
|
||||||
|
foreach (var i in content.Instances)
|
||||||
|
{
|
||||||
|
NoteSite(i.SiteIdentifier, null);
|
||||||
|
foreach (var b in i.ConnectionBindings)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(b.ConnectionName))
|
||||||
|
{
|
||||||
|
connectionRefs.Add((i.SiteIdentifier, b.ConnectionName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var n in i.NativeAlarmSourceOverrides)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(n.ConnectionNameOverride))
|
||||||
|
{
|
||||||
|
connectionRefs.Add((i.SiteIdentifier, n.ConnectionNameOverride));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var dc in content.DataConnections)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(dc.Name))
|
||||||
|
{
|
||||||
|
connectionRefs.Add((dc.SiteIdentifier, dc.Name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var siteMappings = new List<RequiredSiteMapping>();
|
||||||
|
foreach (var identifier in siteNameByIdentifier.Keys.OrderBy(k => k, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var target = await resolveTargetSite(identifier).ConfigureAwait(false);
|
||||||
|
siteMappings.Add(new RequiredSiteMapping(
|
||||||
|
SourceSiteIdentifier: identifier,
|
||||||
|
SourceSiteName: siteNameByIdentifier[identifier],
|
||||||
|
AutoMatchTargetIdentifier: target?.SiteIdentifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectionMappings = new List<RequiredConnectionMapping>();
|
||||||
|
foreach (var (site, name) in connectionRefs
|
||||||
|
.OrderBy(r => r.Site, StringComparer.Ordinal)
|
||||||
|
.ThenBy(r => r.Name, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
// Auto-match only WITHIN the auto-matched target site: a connection of
|
||||||
|
// the same name under a different site is not a valid match.
|
||||||
|
var targetConns = await resolveTargetConnections(site).ConfigureAwait(false);
|
||||||
|
var matched = targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal));
|
||||||
|
connectionMappings.Add(new RequiredConnectionMapping(
|
||||||
|
SourceSiteIdentifier: site,
|
||||||
|
SourceConnectionName: name,
|
||||||
|
AutoMatchTargetName: matched ? name : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (siteMappings, connectionMappings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -442,10 +632,92 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
/// the resolver's scan operates on entity Code while the importer's scan
|
/// the resolver's scan operates on entity Code while the importer's scan
|
||||||
/// operates on DTO Code — same algorithm, different inputs.
|
/// operates on DTO Code — same algorithm, different inputs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<IReadOnlyList<ImportPreviewItem>> DetectBlockersAsync(BundleContentDto content, CancellationToken ct)
|
private async Task<IReadOnlyList<ImportPreviewItem>> DetectBlockersAsync(
|
||||||
|
BundleContentDto content,
|
||||||
|
Func<string, Task<IReadOnlyList<DataConnection>>> resolveTargetConnections,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var blockers = new List<ImportPreviewItem>();
|
var blockers = new List<ImportPreviewItem>();
|
||||||
|
|
||||||
|
// ---- M8: instance template + referenced-connection blockers ----
|
||||||
|
// The set of template names the import can satisfy = (in-bundle templates)
|
||||||
|
// ∪ (templates already in the target DB). An instance whose TemplateName is
|
||||||
|
// in neither is unresolvable.
|
||||||
|
var bundleTemplateNames = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var t in content.Templates) bundleTemplateNames.Add(t.Name);
|
||||||
|
// Honour a rename: a bundled template resolved as Rename is created under
|
||||||
|
// its new name, but at preview time no resolution has been chosen yet, so
|
||||||
|
// the in-bundle name is the original DTO name — which is what an instance
|
||||||
|
// references. (Explicit rename remap is a D-wave apply-time concern.)
|
||||||
|
var targetTemplateNames = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var t in await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
targetTemplateNames.Add(t.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var inst in content.Instances)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(inst.TemplateName)) continue;
|
||||||
|
if (bundleTemplateNames.Contains(inst.TemplateName)) continue;
|
||||||
|
if (targetTemplateNames.Contains(inst.TemplateName)) continue;
|
||||||
|
blockers.Add(new ImportPreviewItem(
|
||||||
|
EntityType: "Instance",
|
||||||
|
Name: inst.UniqueName,
|
||||||
|
ExistingVersion: null,
|
||||||
|
IncomingVersion: null,
|
||||||
|
Kind: ConflictKind.Blocker,
|
||||||
|
FieldDiffJson: null,
|
||||||
|
BlockerReason: $"Template '{inst.TemplateName}' not found in bundle or target."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A referenced (sourceSite, connectionName) pair is resolvable when it is
|
||||||
|
// either carried in the bundle's DataConnections OR auto-matches a connection
|
||||||
|
// of the same name in the auto-matched target site. Genuinely-missing
|
||||||
|
// references are blockers. (Explicit operator connection maps are applied in
|
||||||
|
// a later wave; preview's auto-match is identity-based.)
|
||||||
|
var bundleConnections = new HashSet<(string Site, string Name)>();
|
||||||
|
foreach (var dc in content.DataConnections)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(dc.Name)) bundleConnections.Add((dc.SiteIdentifier, dc.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct referenced pairs from instance bindings + native-alarm overrides.
|
||||||
|
var referencedConnections = new HashSet<(string Site, string Name)>();
|
||||||
|
foreach (var inst in content.Instances)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
.OrderBy(r => r.Site, StringComparer.Ordinal)
|
||||||
|
.ThenBy(r => r.Name, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
if (bundleConnections.Contains((site, name))) continue;
|
||||||
|
var targetConns = await resolveTargetConnections(site).ConfigureAwait(false);
|
||||||
|
if (targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal))) continue;
|
||||||
|
blockers.Add(new ImportPreviewItem(
|
||||||
|
EntityType: "Instance",
|
||||||
|
Name: name,
|
||||||
|
ExistingVersion: null,
|
||||||
|
IncomingVersion: null,
|
||||||
|
Kind: ConflictKind.Blocker,
|
||||||
|
FieldDiffJson: null,
|
||||||
|
BlockerReason: $"Connection '{site}/{name}' unresolved — present in neither bundle nor target."));
|
||||||
|
}
|
||||||
|
|
||||||
// Known-resolvable names = (in-bundle) ∪ (already-in-target).
|
// Known-resolvable names = (in-bundle) ∪ (already-in-target).
|
||||||
var allSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
|
var allSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
|
||||||
var allExternalSystems = await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
|
var allExternalSystems = await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
|
||||||
|
|||||||
+339
@@ -2,16 +2,20 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
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.Commons.Types.Transport;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
@@ -78,6 +82,139 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
|||||||
return ms.ToArray();
|
return ms.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Exports every seeded site (and its instance/connection closure) into a bundle.</summary>
|
||||||
|
private async Task<Stream> ExportAllSitesAsync()
|
||||||
|
{
|
||||||
|
await using var scope = _provider.CreateAsyncScope();
|
||||||
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
|
||||||
|
var selection = new ExportSelection(
|
||||||
|
TemplateIds: Array.Empty<int>(),
|
||||||
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
|
ExternalSystemIds: Array.Empty<int>(),
|
||||||
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
|
NotificationListIds: Array.Empty<int>(),
|
||||||
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
|
ApiMethodIds: Array.Empty<int>(),
|
||||||
|
IncludeDependencies: true,
|
||||||
|
SiteIds: siteIds);
|
||||||
|
|
||||||
|
return await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||||
|
passphrase: null, cancellationToken: CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds a template + a site + a site-scoped data connection + an instance bound
|
||||||
|
/// to that connection (with one attribute override) so a full site closure can be
|
||||||
|
/// exported and previewed. Returns the seeded site identifier + connection name.
|
||||||
|
/// </summary>
|
||||||
|
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<ScadaBridgeDbContext>();
|
||||||
|
|
||||||
|
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" };
|
||||||
|
ctx.Sites.Add(site);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var conn = new DataConnection(connectionName, "OpcUa", site.Id)
|
||||||
|
{
|
||||||
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
|
||||||
|
FailoverRetryCount = 3,
|
||||||
|
};
|
||||||
|
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" });
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Packs an arbitrary <see cref="BundleContentDto"/> into a real, loadable plaintext
|
||||||
|
/// bundle so blocker scenarios can reference entities that the export resolver would
|
||||||
|
/// never carry (e.g. an instance pointing at a template absent from both bundle and
|
||||||
|
/// target). Reuses the production manifest builder + serializer for hash fidelity.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<byte[]> PackBundleAsync(BundleContentDto content)
|
||||||
|
{
|
||||||
|
await using var scope = _provider.CreateAsyncScope();
|
||||||
|
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
|
||||||
|
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
|
||||||
|
|
||||||
|
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<ManifestContentEntry>(),
|
||||||
|
contentBytes: serializer.SerializeContentBytes(content));
|
||||||
|
|
||||||
|
var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
|
||||||
|
return await StreamToBytes(packed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BundleContentDto EmptyContent() => new(
|
||||||
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
||||||
|
Templates: Array.Empty<TemplateDto>(),
|
||||||
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
||||||
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
||||||
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
||||||
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
||||||
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
||||||
|
ApiMethods: Array.Empty<ApiMethodDto>());
|
||||||
|
|
||||||
|
private static InstanceDto SimpleInstanceDto(
|
||||||
|
string uniqueName,
|
||||||
|
string templateName,
|
||||||
|
string siteIdentifier,
|
||||||
|
IReadOnlyList<InstanceConnectionBindingDto>? bindings = null) => new(
|
||||||
|
UniqueName: uniqueName,
|
||||||
|
TemplateName: templateName,
|
||||||
|
SiteIdentifier: siteIdentifier,
|
||||||
|
AreaName: null,
|
||||||
|
State: InstanceState.Enabled,
|
||||||
|
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
|
||||||
|
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
|
||||||
|
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
|
||||||
|
ConnectionBindings: bindings ?? Array.Empty<InstanceConnectionBindingDto>());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PreviewAsync_classifies_artifact_as_Identical_when_fields_match()
|
public async Task PreviewAsync_classifies_artifact_as_Identical_when_fields_match()
|
||||||
{
|
{
|
||||||
@@ -388,4 +525,206 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
|||||||
Assert.Equal(ConflictKind.Identical, valveItem.Kind);
|
Assert.Equal(ConflictKind.Identical, valveItem.Kind);
|
||||||
Assert.Equal(ConflictKind.Identical, tankItem.Kind);
|
Assert.Equal(ConflictKind.Identical, tankItem.Kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- M8 C2: site/connection/instance preview + required-mapping detection + blockers ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewAsync_into_fresh_target_surfaces_required_mappings_with_null_auto_match()
|
||||||
|
{
|
||||||
|
// Arrange: seed a full site closure, export it, then wipe the target so the
|
||||||
|
// site + connection are absent. The preview must surface the site +
|
||||||
|
// connection as required mappings with no auto-match (create-new implied),
|
||||||
|
// and classify the site/connection/instance as New.
|
||||||
|
await SeedSiteClosureAsync();
|
||||||
|
var bundleStream = await ExportAllSitesAsync();
|
||||||
|
var bytes = await StreamToBytes(bundleStream);
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
ctx.Instances.RemoveRange(ctx.Instances);
|
||||||
|
ctx.DataConnections.RemoveRange(ctx.DataConnections);
|
||||||
|
ctx.Sites.RemoveRange(ctx.Sites);
|
||||||
|
ctx.Templates.RemoveRange(ctx.Templates);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportPreview preview;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||||
|
preview = await importer.PreviewAsync(session.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var siteMapping = Assert.Single(preview.RequiredSiteMappings, m => m.SourceSiteIdentifier == "plant-1");
|
||||||
|
Assert.Null(siteMapping.AutoMatchTargetIdentifier);
|
||||||
|
Assert.Equal("Plant 1", siteMapping.SourceSiteName);
|
||||||
|
|
||||||
|
var connMapping = Assert.Single(preview.RequiredConnectionMappings,
|
||||||
|
m => m.SourceSiteIdentifier == "plant-1" && m.SourceConnectionName == "OpcUaPrimary");
|
||||||
|
Assert.Null(connMapping.AutoMatchTargetName);
|
||||||
|
|
||||||
|
// The fresh target carries none of these, so each is classified New (and the
|
||||||
|
// missing template would block — but we wiped the template too, so the
|
||||||
|
// instance also blocks; assert the New site/connection at minimum).
|
||||||
|
Assert.Contains(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1" && i.Kind == ConflictKind.New);
|
||||||
|
Assert.Contains(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary" && i.Kind == ConflictKind.New);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewAsync_into_populated_target_auto_matches_site_and_connection()
|
||||||
|
{
|
||||||
|
// Arrange: seed a site closure, export it, leave the target unchanged. The
|
||||||
|
// bundle's site + connection identity-match the target, so both required
|
||||||
|
// mappings auto-match and the per-type diffs read Identical.
|
||||||
|
await SeedSiteClosureAsync();
|
||||||
|
var bundleStream = await ExportAllSitesAsync();
|
||||||
|
var bytes = await StreamToBytes(bundleStream);
|
||||||
|
|
||||||
|
ImportPreview preview;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||||
|
preview = await importer.PreviewAsync(session.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var siteMapping = Assert.Single(preview.RequiredSiteMappings, m => m.SourceSiteIdentifier == "plant-1");
|
||||||
|
Assert.Equal("plant-1", siteMapping.AutoMatchTargetIdentifier);
|
||||||
|
|
||||||
|
var connMapping = Assert.Single(preview.RequiredConnectionMappings,
|
||||||
|
m => m.SourceSiteIdentifier == "plant-1" && m.SourceConnectionName == "OpcUaPrimary");
|
||||||
|
Assert.Equal("OpcUaPrimary", connMapping.AutoMatchTargetName);
|
||||||
|
|
||||||
|
// The site + connection match the target exactly → Identical, not New.
|
||||||
|
var siteItem = Assert.Single(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1");
|
||||||
|
Assert.Equal(ConflictKind.Identical, siteItem.Kind);
|
||||||
|
var connItem = Assert.Single(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary");
|
||||||
|
Assert.Equal(ConflictKind.Identical, connItem.Kind);
|
||||||
|
|
||||||
|
// No blocker — the template + connection both resolve in the target.
|
||||||
|
Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.EntityType == "Instance");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewAsync_modified_instance_against_hydrated_target_shows_child_diff_not_all_added()
|
||||||
|
{
|
||||||
|
// Arrange: seed a site closure (instance has a Flow=42 attribute override),
|
||||||
|
// export it, then mutate the TARGET instance's override value. The diff must
|
||||||
|
// surface a single modified child override — proving CompareInstance received
|
||||||
|
// a HYDRATED existing instance (I2). A non-hydrated entity would read the
|
||||||
|
// existing children as empty and report the bundle's override as an addition.
|
||||||
|
await SeedSiteClosureAsync();
|
||||||
|
var bundleStream = await ExportAllSitesAsync();
|
||||||
|
var bytes = await StreamToBytes(bundleStream);
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var ovr = await ctx.InstanceAttributeOverrides.SingleAsync(o => o.AttributeName == "Flow");
|
||||||
|
ovr.OverrideValue = "99"; // bundle still carries 42
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImportPreview preview;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||||
|
preview = await importer.PreviewAsync(session.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var instItem = Assert.Single(preview.Items, i => i.EntityType == "Instance" && i.Name == "Pump-01");
|
||||||
|
Assert.Equal(ConflictKind.Modified, instItem.Kind);
|
||||||
|
Assert.NotNull(instItem.FieldDiffJson);
|
||||||
|
// The diff names the AttributeOverrides collection (a change, NOT an add of
|
||||||
|
// every existing child). The hydrated existing instance already had a Flow
|
||||||
|
// override, so the diff is a modification, not an addition.
|
||||||
|
Assert.Contains("AttributeOverrides", instItem.FieldDiffJson!, StringComparison.Ordinal);
|
||||||
|
// A non-hydrated existing would have reported AreaName/State unchanged but
|
||||||
|
// every override as Added — assert the diff did NOT explode into the other
|
||||||
|
// unchanged children (ConnectionBindings DataSourceReference matches verbatim).
|
||||||
|
Assert.DoesNotContain("\"ConnectionBindings\"", instItem.FieldDiffJson!, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewAsync_instance_with_missing_template_emits_blocker()
|
||||||
|
{
|
||||||
|
// Arrange: hand-build a bundle whose instance references a template present in
|
||||||
|
// NEITHER the bundle nor the target. (The real exporter would never produce
|
||||||
|
// this — it always carries the instance's template — so we pack directly.)
|
||||||
|
var content = EmptyContent() with
|
||||||
|
{
|
||||||
|
Instances = new[]
|
||||||
|
{
|
||||||
|
SimpleInstanceDto("Ghost-01", templateName: "NoSuchTemplate", siteIdentifier: "plant-1"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var bytes = await PackBundleAsync(content);
|
||||||
|
|
||||||
|
ImportPreview preview;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||||
|
preview = await importer.PreviewAsync(session.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Contains(preview.Items, i =>
|
||||||
|
i.Kind == ConflictKind.Blocker
|
||||||
|
&& i.EntityType == "Instance"
|
||||||
|
&& i.Name == "Ghost-01"
|
||||||
|
&& i.BlockerReason is not null
|
||||||
|
&& i.BlockerReason.Contains("NoSuchTemplate", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewAsync_referenced_connection_absent_from_bundle_and_target_emits_blocker()
|
||||||
|
{
|
||||||
|
// Arrange: seed the template in the target so the instance's TEMPLATE resolves
|
||||||
|
// (isolating the connection blocker), then hand-build a bundle whose instance
|
||||||
|
// binds an attribute to a connection that is in neither the bundle nor the
|
||||||
|
// target.
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
ctx.Templates.Add(new Template("Pump") { Description = "resolves" });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = EmptyContent() with
|
||||||
|
{
|
||||||
|
Instances = new[]
|
||||||
|
{
|
||||||
|
SimpleInstanceDto("Pump-99", templateName: "Pump", siteIdentifier: "plant-1",
|
||||||
|
bindings: new[]
|
||||||
|
{
|
||||||
|
new InstanceConnectionBindingDto("Flow", "PhantomConn", DataSourceReferenceOverride: null),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var bytes = await PackBundleAsync(content);
|
||||||
|
|
||||||
|
ImportPreview preview;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
|
||||||
|
preview = await importer.PreviewAsync(session.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unresolved connection blocks…
|
||||||
|
Assert.Contains(preview.Items, i =>
|
||||||
|
i.Kind == ConflictKind.Blocker
|
||||||
|
&& i.Name == "PhantomConn"
|
||||||
|
&& i.BlockerReason is not null
|
||||||
|
&& i.BlockerReason.Contains("PhantomConn", StringComparison.Ordinal));
|
||||||
|
// …but the template resolves in the target, so the instance is NOT a
|
||||||
|
// missing-template blocker.
|
||||||
|
Assert.DoesNotContain(preview.Items, i =>
|
||||||
|
i.Kind == ConflictKind.Blocker
|
||||||
|
&& i.EntityType == "Instance"
|
||||||
|
&& i.Name == "Pump-99");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ public sealed class BundleImporterLoadTests
|
|||||||
externalRepo: Substitute.For<IExternalSystemRepository>(),
|
externalRepo: Substitute.For<IExternalSystemRepository>(),
|
||||||
notificationRepo: Substitute.For<INotificationRepository>(),
|
notificationRepo: Substitute.For<INotificationRepository>(),
|
||||||
inboundApiRepo: Substitute.For<IInboundApiRepository>(),
|
inboundApiRepo: Substitute.For<IInboundApiRepository>(),
|
||||||
|
siteRepo: Substitute.For<ISiteRepository>(),
|
||||||
auditService: Substitute.For<IAuditService>(),
|
auditService: Substitute.For<IAuditService>(),
|
||||||
correlationContext: Substitute.For<IAuditCorrelationContext>(),
|
correlationContext: Substitute.For<IAuditCorrelationContext>(),
|
||||||
// LoadAsync never touches the DbContext — Preview/Apply do. Build
|
// LoadAsync never touches the DbContext — Preview/Apply do. Build
|
||||||
|
|||||||
Reference in New Issue
Block a user