fix(transport): connection map Pass-2 (FK) + site-qualified connection resolution (M8 D1-FIX, C1+C2)

This commit is contained in:
Joseph Doherty
2026-06-18 07:08:31 -04:00
parent 6457f03fae
commit d974477e87
3 changed files with 507 additions and 16 deletions
@@ -569,7 +569,8 @@ public sealed class BundleImporterPreviewTests : IDisposable
// 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);
// 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]
@@ -600,13 +601,66 @@ public sealed class BundleImporterPreviewTests : IDisposable
// 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");
// 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);
// 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_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]
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);
}
// 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 =>
i.Kind == ConflictKind.Blocker
&& i.Name == "PhantomConn"
&& i.Name == "plant-1/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