fix(transport): connection map Pass-2 (FK) + site-qualified connection resolution (M8 D1-FIX, C1+C2)
This commit is contained in:
@@ -474,11 +474,18 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- DataConnections (site-scoped; matched by name within the auto-matched target site) ----
|
// ---- DataConnections (site-scoped; matched by name within the auto-matched target site) ----
|
||||||
|
// C2: connection names are unique only WITHIN a site, so the preview item's
|
||||||
|
// identity is SITE-QUALIFIED (`{SiteIdentifier}/{Name}`). The diff CONTENT is
|
||||||
|
// unchanged — only the item Name is qualified (after CompareDataConnection
|
||||||
|
// returns) so two sites' same-named connections resolve to distinct items and
|
||||||
|
// the operator's per-item Skip/Overwrite applies to the right site's connection.
|
||||||
|
// ApplyDataConnectionsAsync looks the resolution up by this same qualified key.
|
||||||
foreach (var dcDto in content.DataConnections)
|
foreach (var dcDto in content.DataConnections)
|
||||||
{
|
{
|
||||||
var targetConns = await ResolveTargetConnectionsAsync(dcDto.SiteIdentifier).ConfigureAwait(false);
|
var targetConns = await ResolveTargetConnectionsAsync(dcDto.SiteIdentifier).ConfigureAwait(false);
|
||||||
var existing = targetConns.FirstOrDefault(c => string.Equals(c.Name, dcDto.Name, StringComparison.Ordinal));
|
var existing = targetConns.FirstOrDefault(c => string.Equals(c.Name, dcDto.Name, StringComparison.Ordinal));
|
||||||
items.Add(_diff.CompareDataConnection(dcDto, existing));
|
var item = _diff.CompareDataConnection(dcDto, existing);
|
||||||
|
items.Add(item with { Name = QualifiedConnectionName(dcDto.SiteIdentifier, dcDto.Name) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Instances (hydrated target + resolved template/site/area names; review item I2) ----
|
// ---- Instances (hydrated target + resolved template/site/area names; review item I2) ----
|
||||||
@@ -709,9 +716,11 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
if (bundleConnections.Contains((site, name))) continue;
|
if (bundleConnections.Contains((site, name))) continue;
|
||||||
var targetConns = await resolveTargetConnections(site).ConfigureAwait(false);
|
var targetConns = await resolveTargetConnections(site).ConfigureAwait(false);
|
||||||
if (targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal))) continue;
|
if (targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal))) continue;
|
||||||
|
// C2: site-qualify the blocker Name so two sites' same-named-but-missing
|
||||||
|
// connections surface as distinct blockers (a bare name would collide).
|
||||||
blockers.Add(new ImportPreviewItem(
|
blockers.Add(new ImportPreviewItem(
|
||||||
EntityType: "Instance",
|
EntityType: "Instance",
|
||||||
Name: name,
|
Name: QualifiedConnectionName(site, name),
|
||||||
ExistingVersion: null,
|
ExistingVersion: null,
|
||||||
IncomingVersion: null,
|
IncomingVersion: null,
|
||||||
Kind: ConflictKind.Blocker,
|
Kind: ConflictKind.Blocker,
|
||||||
@@ -859,6 +868,18 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
private static bool IsIdentifierStart(char c) => c == '_' || char.IsLetter(c);
|
private static bool IsIdentifierStart(char c) => c == '_' || char.IsLetter(c);
|
||||||
private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(c);
|
private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(c);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C2: the site-qualified identity (<c>{siteIdentifier}/{connectionName}</c>) used
|
||||||
|
/// for every <c>DataConnection</c> preview item Name + blocker Name, and for the
|
||||||
|
/// matching resolution lookup in <see cref="ApplyDataConnectionsAsync"/>. Connection
|
||||||
|
/// names are unique only WITHIN a site, so a bare-name key would collapse two sites'
|
||||||
|
/// same-named connections onto one resolution entry. Callers building a resolution
|
||||||
|
/// map from <c>preview.Items</c> keyed by <c>(EntityType, Name)</c> get the
|
||||||
|
/// site-qualified key for free.
|
||||||
|
/// </summary>
|
||||||
|
private static string QualifiedConnectionName(string siteIdentifier, string connectionName) =>
|
||||||
|
$"{siteIdentifier}/{connectionName}";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ImportResult> ApplyAsync(
|
public async Task<ImportResult> ApplyAsync(
|
||||||
Guid sessionId,
|
Guid sessionId,
|
||||||
@@ -2722,8 +2743,12 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MapToExisting — honour the connection's own conflict resolution.
|
// MapToExisting — honour the connection's own conflict resolution.
|
||||||
|
// C2: the resolution is keyed by the SITE-QUALIFIED name
|
||||||
|
// (`{SiteIdentifier}/{Name}`), matching the qualified preview-item Name —
|
||||||
|
// so a same-named connection under a different site can't pick up the
|
||||||
|
// wrong Skip/Overwrite.
|
||||||
var resolution = ResolveOrDefault(
|
var resolution = ResolveOrDefault(
|
||||||
resolutionMap, "DataConnection", dcDto.Name);
|
resolutionMap, "DataConnection", QualifiedConnectionName(dcDto.SiteIdentifier, dcDto.Name));
|
||||||
if (resolution.Action == ResolutionAction.Overwrite)
|
if (resolution.Action == ResolutionAction.Overwrite)
|
||||||
{
|
{
|
||||||
ApplyDataConnectionFields(existing, dcDto);
|
ApplyDataConnectionFields(existing, dcDto);
|
||||||
@@ -2743,9 +2768,89 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
targetNameByRef[key] = existing.Name;
|
targetNameByRef[key] = existing.Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- C1 Pass 2 — referenced-but-not-carried connections ----
|
||||||
|
// A binding (or native-alarm-source override) can name a connection that
|
||||||
|
// exists in the TARGET database but was NOT carried in the bundle's
|
||||||
|
// DataConnections (e.g. exported without it). Preview correctly does NOT
|
||||||
|
// block it — it auto-matches against the target. But the main loop above
|
||||||
|
// only populated the maps for connections the bundle CARRIES, so without
|
||||||
|
// this pass IdBySourceRef would MISS for such a binding and the instance
|
||||||
|
// pass would write DataConnectionId = 0 (an invalid FK). Mirror the
|
||||||
|
// site path's Pass 2: for every distinct (sourceSite, connName) referenced
|
||||||
|
// by an instance binding / native-alarm override that isn't already mapped,
|
||||||
|
// resolve the target site (honouring a nameMap redirect to a differently-
|
||||||
|
// named target connection) and look up the EXISTING target connection by
|
||||||
|
// name, populating both maps with its real id + name.
|
||||||
|
foreach (var (sourceSite, connName) in EnumerateReferencedConnectionRefs(content))
|
||||||
|
{
|
||||||
|
var key = (sourceSite, connName);
|
||||||
|
if (result.ContainsKey(key)) continue; // already mapped by the carried-connection loop.
|
||||||
|
|
||||||
|
if (!siteBySourceIdentifier.TryGetValue(sourceSite, out var targetSite))
|
||||||
|
{
|
||||||
|
// ApplySitesAsync resolved every referenced site; guard so a missing
|
||||||
|
// entry fails the import rather than writing an orphan FK.
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Connection '{sourceSite}/{connName}' references a site that could not be "
|
||||||
|
+ "resolved to a target.");
|
||||||
|
}
|
||||||
|
|
||||||
|
connMappingByRef.TryGetValue(key, out var mapping);
|
||||||
|
var targetName = mapping?.Action == MappingAction.MapToExisting
|
||||||
|
&& mapping.TargetConnectionName is { Length: > 0 } tn
|
||||||
|
? tn
|
||||||
|
: connName;
|
||||||
|
|
||||||
|
var targetConns = await TargetConnsAsync(targetSite.Id).ConfigureAwait(false);
|
||||||
|
var existing = targetConns.FirstOrDefault(c =>
|
||||||
|
string.Equals(c.Name, targetName, StringComparison.Ordinal));
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
// Should already be a preview blocker (present in neither bundle nor
|
||||||
|
// target). Fail with a clear message rather than letting the instance
|
||||||
|
// pass write DataConnectionId = 0.
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Connection '{sourceSite}/{connName}' is referenced by the bundle but is present "
|
||||||
|
+ "in neither the bundle nor the target — cannot resolve a target connection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
result[key] = existing.Id;
|
||||||
|
targetNameByRef[key] = existing.Name;
|
||||||
|
}
|
||||||
|
|
||||||
return new ResolvedConnectionMaps(result, targetNameByRef);
|
return new ResolvedConnectionMaps(result, targetNameByRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C1: every distinct <c>(sourceSiteIdentifier, connectionName)</c> pair an instance
|
||||||
|
/// references — via a <see cref="InstanceConnectionBindingDto.ConnectionName"/> or a
|
||||||
|
/// non-null <see cref="InstanceNativeAlarmSourceOverrideDto.ConnectionNameOverride"/>.
|
||||||
|
/// Drives the connection-map Pass 2 that resolves references the bundle didn't carry.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<(string Site, string Name)> EnumerateReferencedConnectionRefs(BundleContentDto content)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<(string, string)>();
|
||||||
|
foreach (var inst in content.Instances)
|
||||||
|
{
|
||||||
|
foreach (var b in inst.ConnectionBindings)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(b.ConnectionName)
|
||||||
|
&& seen.Add((inst.SiteIdentifier, b.ConnectionName)))
|
||||||
|
{
|
||||||
|
yield return (inst.SiteIdentifier, b.ConnectionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var n in inst.NativeAlarmSourceOverrides)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(n.ConnectionNameOverride)
|
||||||
|
&& seen.Add((inst.SiteIdentifier, n.ConnectionNameOverride)))
|
||||||
|
{
|
||||||
|
yield return (inst.SiteIdentifier, n.ConnectionNameOverride);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static DataConnection BuildDataConnection(DataConnectionDto dto, int siteId) =>
|
private static DataConnection BuildDataConnection(DataConnectionDto dto, int siteId) =>
|
||||||
new(dto.Name, dto.Protocol, siteId)
|
new(dto.Name, dto.Protocol, siteId)
|
||||||
{
|
{
|
||||||
@@ -3013,10 +3118,24 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
}
|
}
|
||||||
foreach (var b in dto.ConnectionBindings)
|
foreach (var b in dto.ConnectionBindings)
|
||||||
{
|
{
|
||||||
// Resolve ConnectionName → target DataConnectionId. A binding whose
|
// Resolve ConnectionName → target DataConnectionId. After the C1 Pass-2
|
||||||
// connection didn't resolve was a preview blocker; default to 0 here
|
// in ApplyDataConnectionsAsync, the map carries an entry for every
|
||||||
// (the FK constraint / a later deploy gate surfaces it) rather than
|
// referenced connection — carried in the bundle OR auto-matched in the
|
||||||
// throwing, since the binding's attribute may legitimately be unbound.
|
// target. A binding whose connection name STILL doesn't resolve is a
|
||||||
|
// structural error (it should already have been a preview blocker +
|
||||||
|
// a pre-write validation failure); THROW rather than write
|
||||||
|
// DataConnectionId = 0, which would be an invalid FK on a relational
|
||||||
|
// provider and a silently-broken binding on the in-memory one.
|
||||||
|
// A binding may legitimately carry NO connection name (unbound
|
||||||
|
// attribute) — only a NON-EMPTY name that fails to resolve is an error.
|
||||||
|
if (!string.IsNullOrEmpty(b.ConnectionName)
|
||||||
|
&& !connectionMaps.IdBySourceRef.TryGetValue((sourceSiteIdentifier, b.ConnectionName), out _))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Instance '{inst.UniqueName}' binding for attribute '{b.AttributeName}' references "
|
||||||
|
+ $"connection '{sourceSiteIdentifier}/{b.ConnectionName}' which could not be resolved "
|
||||||
|
+ "to a target connection (present in neither bundle nor target).");
|
||||||
|
}
|
||||||
connectionMaps.IdBySourceRef.TryGetValue((sourceSiteIdentifier, b.ConnectionName), out var connId);
|
connectionMaps.IdBySourceRef.TryGetValue((sourceSiteIdentifier, b.ConnectionName), out var connId);
|
||||||
inst.ConnectionBindings.Add(new InstanceConnectionBinding(b.AttributeName)
|
inst.ConnectionBindings.Add(new InstanceConnectionBinding(b.AttributeName)
|
||||||
{
|
{
|
||||||
|
|||||||
+59
-4
@@ -569,7 +569,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
|||||||
// missing template would block — but we wiped the template too, so the
|
// missing template would block — but we wiped the template too, so the
|
||||||
// instance also blocks; assert the New site/connection at minimum).
|
// 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 == "Site" && i.Name == "plant-1" && i.Kind == ConflictKind.New);
|
||||||
Assert.Contains(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary" && i.Kind == ConflictKind.New);
|
// C2: DataConnection preview items are site-qualified ({site}/{name}).
|
||||||
|
Assert.Contains(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "plant-1/OpcUaPrimary" && i.Kind == ConflictKind.New);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -600,13 +601,66 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
|||||||
// The site + connection match the target exactly → Identical, not New.
|
// The site + connection match the target exactly → Identical, not New.
|
||||||
var siteItem = Assert.Single(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1");
|
var siteItem = Assert.Single(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1");
|
||||||
Assert.Equal(ConflictKind.Identical, siteItem.Kind);
|
Assert.Equal(ConflictKind.Identical, siteItem.Kind);
|
||||||
var connItem = Assert.Single(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary");
|
// C2: DataConnection preview items are site-qualified ({site}/{name}).
|
||||||
|
var connItem = Assert.Single(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "plant-1/OpcUaPrimary");
|
||||||
Assert.Equal(ConflictKind.Identical, connItem.Kind);
|
Assert.Equal(ConflictKind.Identical, connItem.Kind);
|
||||||
|
|
||||||
// No blocker — the template + connection both resolve in the target.
|
// No blocker — the template + connection both resolve in the target.
|
||||||
Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.EntityType == "Instance");
|
Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.EntityType == "Instance");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PreviewAsync_two_sites_same_connection_name_emit_two_distinct_site_qualified_items()
|
||||||
|
{
|
||||||
|
// C2: a bundle with plant-1/OpcUaPrimary + plant-2/OpcUaPrimary must surface
|
||||||
|
// TWO distinct DataConnection preview items, each site-qualified
|
||||||
|
// ({site}/{name}) — NOT a single collapsed "OpcUaPrimary" item. This is what
|
||||||
|
// lets the operator (and the apply path) resolve each site's connection
|
||||||
|
// independently. Hand-pack the bundle so both same-named connections are
|
||||||
|
// present without a heavy two-site export.
|
||||||
|
var content = new BundleContentDto(
|
||||||
|
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>())
|
||||||
|
{
|
||||||
|
Sites = new[]
|
||||||
|
{
|
||||||
|
new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
|
||||||
|
new SiteDto("plant-2", "Plant 2", null, null, null, null, null),
|
||||||
|
},
|
||||||
|
DataConnections = new[]
|
||||||
|
{
|
||||||
|
new DataConnectionDto("plant-1", "OpcUaPrimary", "OpcUa", 3, null),
|
||||||
|
new DataConnectionDto("plant-2", "OpcUaPrimary", "OpcUa", 3, null),
|
||||||
|
},
|
||||||
|
Instances = Array.Empty<InstanceDto>(),
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
var connItems = preview.Items
|
||||||
|
.Where(i => i.EntityType == "DataConnection")
|
||||||
|
.Select(i => i.Name)
|
||||||
|
.OrderBy(n => n, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Two distinct site-qualified items — NOT one bare "OpcUaPrimary".
|
||||||
|
Assert.Equal(new[] { "plant-1/OpcUaPrimary", "plant-2/OpcUaPrimary" }, connItems);
|
||||||
|
Assert.DoesNotContain(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PreviewAsync_modified_instance_against_hydrated_target_shows_child_diff_not_all_added()
|
public async Task PreviewAsync_modified_instance_against_hydrated_target_shows_child_diff_not_all_added()
|
||||||
{
|
{
|
||||||
@@ -714,10 +768,11 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
|||||||
preview = await importer.PreviewAsync(session.SessionId);
|
preview = await importer.PreviewAsync(session.SessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The unresolved connection blocks…
|
// The unresolved connection blocks… (C2: blocker Name is site-qualified
|
||||||
|
// {site}/{name} so two sites' same-named missing connections don't collide).
|
||||||
Assert.Contains(preview.Items, i =>
|
Assert.Contains(preview.Items, i =>
|
||||||
i.Kind == ConflictKind.Blocker
|
i.Kind == ConflictKind.Blocker
|
||||||
&& i.Name == "PhantomConn"
|
&& i.Name == "plant-1/PhantomConn"
|
||||||
&& i.BlockerReason is not null
|
&& i.BlockerReason is not null
|
||||||
&& i.BlockerReason.Contains("PhantomConn", StringComparison.Ordinal));
|
&& i.BlockerReason.Contains("PhantomConn", StringComparison.Ordinal));
|
||||||
// …but the template resolves in the target, so the instance is NOT a
|
// …but the template resolves in the target, so the instance is NOT a
|
||||||
|
|||||||
+322
-5
@@ -270,7 +270,8 @@ public sealed class SiteInstanceImportTests : IDisposable
|
|||||||
{
|
{
|
||||||
new("Template", "Pump", ResolutionAction.Skip, null), // already present
|
new("Template", "Pump", ResolutionAction.Skip, null), // already present
|
||||||
new("Site", "plant-1", ResolutionAction.Add, null),
|
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||||
new("DataConnection", "OpcUaPrimary", ResolutionAction.Add, null),
|
// C2: DataConnection resolutions are keyed by the site-qualified name.
|
||||||
|
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Add, null),
|
||||||
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||||
},
|
},
|
||||||
nameMap);
|
nameMap);
|
||||||
@@ -378,7 +379,8 @@ public sealed class SiteInstanceImportTests : IDisposable
|
|||||||
{
|
{
|
||||||
new("Template", "Pump", ResolutionAction.Skip, null),
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||||
new("Site", "plant-1", ResolutionAction.Skip, null), // leave target site untouched
|
new("Site", "plant-1", ResolutionAction.Skip, null), // leave target site untouched
|
||||||
new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null), // leave target conn untouched
|
// C2: DataConnection resolutions are keyed by the site-qualified name.
|
||||||
|
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null), // leave target conn untouched
|
||||||
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||||
},
|
},
|
||||||
nameMap);
|
nameMap);
|
||||||
@@ -499,7 +501,8 @@ public sealed class SiteInstanceImportTests : IDisposable
|
|||||||
{
|
{
|
||||||
new("Template", "Pump", ResolutionAction.Skip, null),
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||||
new("Site", "plant-1", ResolutionAction.Skip, null),
|
new("Site", "plant-1", ResolutionAction.Skip, null),
|
||||||
new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null),
|
// C2: DataConnection resolution is keyed by the SOURCE site-qualified name.
|
||||||
|
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null),
|
||||||
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||||
},
|
},
|
||||||
nameMap);
|
nameMap);
|
||||||
@@ -582,7 +585,7 @@ public sealed class SiteInstanceImportTests : IDisposable
|
|||||||
new List<ImportResolution>
|
new List<ImportResolution>
|
||||||
{
|
{
|
||||||
new("Site", "plant-1", ResolutionAction.Add, null),
|
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||||
new("DataConnection", "OpcUaPrimary", ResolutionAction.Add, null),
|
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Add, null),
|
||||||
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||||
},
|
},
|
||||||
user: "bob",
|
user: "bob",
|
||||||
@@ -641,7 +644,8 @@ public sealed class SiteInstanceImportTests : IDisposable
|
|||||||
{
|
{
|
||||||
new("Template", "Pump", ResolutionAction.Skip, null),
|
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||||
new("Site", "plant-1", ResolutionAction.Skip, null),
|
new("Site", "plant-1", ResolutionAction.Skip, null),
|
||||||
new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null),
|
// C2: DataConnection resolution is keyed by the site-qualified name.
|
||||||
|
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null),
|
||||||
new("Instance", "Pump-01", ResolutionAction.Overwrite, null),
|
new("Instance", "Pump-01", ResolutionAction.Overwrite, null),
|
||||||
},
|
},
|
||||||
nameMap);
|
nameMap);
|
||||||
@@ -666,4 +670,317 @@ public sealed class SiteInstanceImportTests : IDisposable
|
|||||||
|
|
||||||
Assert.Equal(1, result.Overwritten);
|
Assert.Equal(1, result.Overwritten);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// C1 — binding references a TARGET connection the bundle did NOT carry
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyAsync_binding_to_target_connection_omitted_from_bundle_resolves_to_existing_id_not_zero()
|
||||||
|
{
|
||||||
|
// C1 regression: a valid bundle can carry an instance whose binding (and
|
||||||
|
// native-alarm-source override) references a connection that exists in the
|
||||||
|
// TARGET but was NOT carried in the bundle's DataConnections. Preview does
|
||||||
|
// NOT block it (it auto-matches the target). Before the C1 Pass-2 fix the
|
||||||
|
// connection map MISSED for that binding → DataConnectionId defaulted to 0
|
||||||
|
// (an invalid FK). After the fix the map resolves the EXISTING target
|
||||||
|
// connection's id and both the binding + the native-alarm override rewrite
|
||||||
|
// correctly. Hand-pack the bundle so DataConnections is empty while the
|
||||||
|
// instance still references the connection by name.
|
||||||
|
int targetConnId;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
||||||
|
var site = new Site("Plant 1", "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 content = new BundleContentDto(
|
||||||
|
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>())
|
||||||
|
{
|
||||||
|
// Site carried, but the connection is DELIBERATELY OMITTED from the bundle.
|
||||||
|
Sites = new[]
|
||||||
|
{
|
||||||
|
new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
|
||||||
|
},
|
||||||
|
DataConnections = Array.Empty<DataConnectionDto>(),
|
||||||
|
Instances = new[]
|
||||||
|
{
|
||||||
|
new InstanceDto(
|
||||||
|
UniqueName: "Pump-01",
|
||||||
|
TemplateName: "Pump",
|
||||||
|
SiteIdentifier: "plant-1",
|
||||||
|
AreaName: null,
|
||||||
|
State: InstanceState.Enabled,
|
||||||
|
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
|
||||||
|
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
|
||||||
|
NativeAlarmSourceOverrides: new[]
|
||||||
|
{
|
||||||
|
new InstanceNativeAlarmSourceOverrideDto(
|
||||||
|
SourceCanonicalName: "NativeSrc",
|
||||||
|
ConnectionNameOverride: "OpcUaPrimary",
|
||||||
|
SourceReferenceOverride: "ns=3;s=Pump.Alarm",
|
||||||
|
ConditionFilterOverride: null),
|
||||||
|
},
|
||||||
|
ConnectionBindings: new[]
|
||||||
|
{
|
||||||
|
new InstanceConnectionBindingDto(
|
||||||
|
AttributeName: "Flow",
|
||||||
|
ConnectionName: "OpcUaPrimary",
|
||||||
|
DataSourceReferenceOverride: "ns=3;s=Pump.Flow"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var sessionId = await PackAndLoadAsync(content);
|
||||||
|
|
||||||
|
var nameMap = new BundleNameMap(
|
||||||
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
|
||||||
|
// No explicit connection mapping — the connection auto-matches the
|
||||||
|
// existing target connection within plant-1 (the C1 Pass-2 path).
|
||||||
|
Connections: Array.Empty<ConnectionMapping>());
|
||||||
|
|
||||||
|
var result = await ApplyAsync(
|
||||||
|
sessionId,
|
||||||
|
new List<ImportResolution>
|
||||||
|
{
|
||||||
|
new("Site", "plant-1", ResolutionAction.Skip, null),
|
||||||
|
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||||
|
},
|
||||||
|
nameMap);
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
|
||||||
|
// No new connection was created — the omitted-from-bundle connection
|
||||||
|
// auto-matched the EXISTING target row.
|
||||||
|
var conn = Assert.Single(await ctx.DataConnections.Where(c => c.Name == "OpcUaPrimary").ToListAsync());
|
||||||
|
Assert.Equal(targetConnId, conn.Id);
|
||||||
|
|
||||||
|
var inst = await ctx.Instances
|
||||||
|
.Include(i => i.ConnectionBindings)
|
||||||
|
.Include(i => i.NativeAlarmSourceOverrides)
|
||||||
|
.SingleAsync(i => i.UniqueName == "Pump-01");
|
||||||
|
|
||||||
|
// THE FIX: binding FK points at the EXISTING target connection id — NOT 0.
|
||||||
|
var binding = Assert.Single(inst.ConnectionBindings);
|
||||||
|
Assert.NotEqual(0, binding.DataConnectionId);
|
||||||
|
Assert.Equal(targetConnId, binding.DataConnectionId);
|
||||||
|
|
||||||
|
// Native-alarm-source override connection name carries through.
|
||||||
|
Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Added);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyAsync_binding_to_connection_in_neither_bundle_nor_target_fails_instead_of_writing_zero()
|
||||||
|
{
|
||||||
|
// C1 guard: a binding naming a connection present in NEITHER the bundle nor
|
||||||
|
// the target must FAIL the import (caught in the pre-write validation phase
|
||||||
|
// as a SemanticValidationException) rather than silently write
|
||||||
|
// DataConnectionId = 0. Nothing may persist.
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
||||||
|
ctx.Sites.Add(new Site("Plant 1", "plant-1"));
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = new BundleContentDto(
|
||||||
|
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>())
|
||||||
|
{
|
||||||
|
DataConnections = Array.Empty<DataConnectionDto>(),
|
||||||
|
Instances = new[]
|
||||||
|
{
|
||||||
|
new InstanceDto(
|
||||||
|
UniqueName: "Pump-01",
|
||||||
|
TemplateName: "Pump",
|
||||||
|
SiteIdentifier: "plant-1",
|
||||||
|
AreaName: null,
|
||||||
|
State: InstanceState.Enabled,
|
||||||
|
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
|
||||||
|
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
|
||||||
|
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
|
||||||
|
ConnectionBindings: new[]
|
||||||
|
{
|
||||||
|
new InstanceConnectionBindingDto(
|
||||||
|
AttributeName: "Flow",
|
||||||
|
ConnectionName: "GhostConn",
|
||||||
|
DataSourceReferenceOverride: null),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var sessionId = await PackAndLoadAsync(content);
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
var nameMap = new BundleNameMap(
|
||||||
|
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
|
||||||
|
Connections: Array.Empty<ConnectionMapping>());
|
||||||
|
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||||
|
importer.ApplyAsync(
|
||||||
|
sessionId,
|
||||||
|
new List<ImportResolution>
|
||||||
|
{
|
||||||
|
new("Site", "plant-1", ResolutionAction.Skip, 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<ScadaBridgeDbContext>();
|
||||||
|
// No instance with a zero (or any) binding persisted.
|
||||||
|
Assert.Equal(0, await ctx.Instances.CountAsync());
|
||||||
|
Assert.Equal(0, await ctx.InstanceConnectionBindings.CountAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// C2 — two sites with same-named connections resolve independently
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyAsync_two_sites_same_connection_name_apply_per_site_resolution_independently()
|
||||||
|
{
|
||||||
|
// C2 regression: connection names are unique only WITHIN a site, so a bundle
|
||||||
|
// with plant-1/OpcUaPrimary + plant-2/OpcUaPrimary must NOT collapse onto a
|
||||||
|
// single resolution. Both target connections exist; the operator Overwrites
|
||||||
|
// plant-1's and Skips plant-2's. The site-qualified resolution key routes the
|
||||||
|
// Overwrite to plant-1's connection ONLY and leaves plant-2's untouched.
|
||||||
|
int plant1ConnId, plant2ConnId;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var s1 = new Site("Plant 1", "plant-1");
|
||||||
|
var s2 = new Site("Plant 2", "plant-2");
|
||||||
|
ctx.Sites.AddRange(s1, s2);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
var c1 = new DataConnection("OpcUaPrimary", "OpcUa", s1.Id)
|
||||||
|
{
|
||||||
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://p1-existing\"}",
|
||||||
|
FailoverRetryCount = 1,
|
||||||
|
};
|
||||||
|
var c2 = new DataConnection("OpcUaPrimary", "OpcUa", s2.Id)
|
||||||
|
{
|
||||||
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://p2-existing\"}",
|
||||||
|
FailoverRetryCount = 2,
|
||||||
|
};
|
||||||
|
ctx.DataConnections.AddRange(c1, c2);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
plant1ConnId = c1.Id;
|
||||||
|
plant2ConnId = c2.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-pack a bundle carrying both same-named connections, each with a
|
||||||
|
// DISTINCT incoming protocol-config so an Overwrite is observable.
|
||||||
|
var content = new BundleContentDto(
|
||||||
|
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>())
|
||||||
|
{
|
||||||
|
Sites = new[]
|
||||||
|
{
|
||||||
|
new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
|
||||||
|
new SiteDto("plant-2", "Plant 2", null, null, null, null, null),
|
||||||
|
},
|
||||||
|
DataConnections = new[]
|
||||||
|
{
|
||||||
|
new DataConnectionDto("plant-1", "OpcUaPrimary", "OpcUa", 9,
|
||||||
|
new SecretsBlock(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://p1-from-bundle\"}",
|
||||||
|
})),
|
||||||
|
new DataConnectionDto("plant-2", "OpcUaPrimary", "OpcUa", 9,
|
||||||
|
new SecretsBlock(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://p2-from-bundle\"}",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
Instances = Array.Empty<InstanceDto>(),
|
||||||
|
};
|
||||||
|
var sessionId = await PackAndLoadAsync(content);
|
||||||
|
|
||||||
|
var nameMap = new BundleNameMap(
|
||||||
|
Sites: new[]
|
||||||
|
{
|
||||||
|
new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1"),
|
||||||
|
new SiteMapping("plant-2", MappingAction.MapToExisting, "plant-2"),
|
||||||
|
},
|
||||||
|
Connections: new[]
|
||||||
|
{
|
||||||
|
new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary"),
|
||||||
|
new ConnectionMapping("plant-2", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await ApplyAsync(
|
||||||
|
sessionId,
|
||||||
|
new List<ImportResolution>
|
||||||
|
{
|
||||||
|
new("Site", "plant-1", ResolutionAction.Skip, null),
|
||||||
|
new("Site", "plant-2", ResolutionAction.Skip, null),
|
||||||
|
// Per-site resolutions — keyed by the site-qualified name (C2).
|
||||||
|
new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Overwrite, null),
|
||||||
|
new("DataConnection", "plant-2/OpcUaPrimary", ResolutionAction.Skip, null),
|
||||||
|
},
|
||||||
|
nameMap);
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
|
||||||
|
// plant-1's connection was Overwritten with the bundle's config.
|
||||||
|
var c1 = await ctx.DataConnections.SingleAsync(c => c.Id == plant1ConnId);
|
||||||
|
Assert.Contains("p1-from-bundle", c1.PrimaryConfiguration!);
|
||||||
|
Assert.Equal(9, c1.FailoverRetryCount);
|
||||||
|
|
||||||
|
// plant-2's same-named connection was LEFT UNTOUCHED by the Skip — the
|
||||||
|
// bare-name collision bug would have applied plant-1's Overwrite here too.
|
||||||
|
var c2 = await ctx.DataConnections.SingleAsync(c => c.Id == plant2ConnId);
|
||||||
|
Assert.Contains("p2-existing", c2.PrimaryConfiguration!);
|
||||||
|
Assert.Equal(2, c2.FailoverRetryCount);
|
||||||
|
|
||||||
|
// Still exactly two connections — no duplicates created.
|
||||||
|
Assert.Equal(2, await ctx.DataConnections.CountAsync(c => c.Name == "OpcUaPrimary"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One Overwrite (plant-1 conn), three Skips (two sites + plant-2 conn).
|
||||||
|
Assert.Equal(1, result.Overwritten);
|
||||||
|
Assert.Equal(3, result.Skipped);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user