feat(transport): import name-map plumbing via CLI + ManagementActor (M8 D3)

This commit is contained in:
Joseph Doherty
2026-06-18 07:08:33 -04:00
parent d974477e87
commit 565d53d0fe
5 changed files with 721 additions and 7 deletions
@@ -1677,12 +1677,16 @@ public class ManagementActorTests : TestKit, IDisposable
}));
IReadOnlyList<Commons.Types.Transport.ImportResolution>? captured = null;
// M8 (D3): the handler now calls the 5-arg ApplyAsync overload (trailing
// nameMap:), so the arrange must include the BundleNameMap arg matcher or
// NSubstitute treats it as an unmatched call and never runs the capture.
importer.ApplyAsync(
Arg.Any<Guid>(),
Arg.Do<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(
r => captured = r),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
Arg.Any<CancellationToken>(),
Arg.Any<Commons.Types.Transport.BundleNameMap?>())
.Returns(new Commons.Types.Transport.ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 0, Overwritten: 0, Skipped: 0, Renamed: 0,
@@ -1709,6 +1713,285 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Equal(Commons.Types.Transport.ResolutionAction.Skip, dupResolutions[0].Action);
}
// ------------------------------------------------------------------------
// M8 (D3): import name-map plumbing — PreviewBundleResult carries the
// required mappings; HandleImportBundle merges explicit specs + auto-match +
// create-missing flags into the BundleNameMap passed to ApplyAsync.
// ------------------------------------------------------------------------
/// <summary>
/// Configures the substituted importer with a Load + Preview returning the
/// supplied required mappings (and no conflicting artifact rows), and a
/// capturing ApplyAsync. Returns the session id and a getter for the captured
/// <see cref="Commons.Types.Transport.BundleNameMap"/>.
/// </summary>
private static (Guid SessionId, Func<Commons.Types.Transport.BundleNameMap?> CapturedMap) ConfigureImportForMapping(
Commons.Interfaces.Transport.IBundleImporter importer,
IReadOnlyList<Commons.Types.Transport.RequiredSiteMapping> siteMappings,
IReadOnlyList<Commons.Types.Transport.RequiredConnectionMapping> connectionMappings)
{
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
Array.Empty<Commons.Types.Transport.ImportPreviewItem>(),
siteMappings,
connectionMappings));
Commons.Types.Transport.BundleNameMap? captured = null;
importer.ApplyAsync(
Arg.Any<Guid>(),
Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>(),
Arg.Do<Commons.Types.Transport.BundleNameMap?>(m => captured = m))
.Returns(new Commons.Types.Transport.ImportResult(
BundleImportId: Guid.NewGuid(),
Added: 0, Overwritten: 0, Skipped: 0, Renamed: 0,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: "correlation"));
return (sessionId, () => captured);
}
[Fact]
public void ImportBundleCommand_ExplicitSpec_OverridesAutoMatch()
{
// An explicit --map-site spec (target present → MapToExisting) wins over
// the preview's auto-match suggestion.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO-MATCH"),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(
payload, null, "overwrite",
SiteMappings: new[] { new SiteMappingSpec("NORTH-01", "PLANT-A") });
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
var site = Assert.Single(map!.Sites);
Assert.Equal("NORTH-01", site.SourceSiteIdentifier);
Assert.Equal(Commons.Types.Transport.MappingAction.MapToExisting, site.Action);
Assert.Equal("PLANT-A", site.TargetSiteIdentifier);
}
[Fact]
public void ImportBundleCommand_NoSpec_FallsBackToAutoMatch()
{
// With no explicit spec, the preview's auto-match becomes a MapToExisting.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO-MATCH"),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(payload, null, "overwrite");
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
var site = Assert.Single(map!.Sites);
Assert.Equal(Commons.Types.Transport.MappingAction.MapToExisting, site.Action);
Assert.Equal("AUTO-MATCH", site.TargetSiteIdentifier);
}
[Fact]
public void ImportBundleCommand_NoAutoMatchWithCreateMissing_CreatesNew()
{
// No spec, no auto-match, but --create-missing-sites → CreateNew.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: null),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(
payload, null, "overwrite", CreateMissingSites: true);
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
var site = Assert.Single(map!.Sites);
Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, site.Action);
Assert.Null(site.TargetSiteIdentifier);
}
[Fact]
public void ImportBundleCommand_ExplicitCreateNewSpec_CreatesNew()
{
// An explicit spec with a null target means CreateNew even without the
// create-missing flag.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO-MATCH"),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(
payload, null, "overwrite",
SiteMappings: new[] { new SiteMappingSpec("NORTH-01", TargetSiteIdentifier: null) });
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
var site = Assert.Single(map!.Sites);
Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, site.Action);
}
[Fact]
public void ImportBundleCommand_UnresolvedWithoutCreateMissing_Fails()
{
// No spec, no auto-match, no create-missing flag → unresolved → fail
// before ApplyAsync.
var (_, importer) = AddBundleSubstitutes();
ConfigureImportForMapping(
importer,
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: null),
},
Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(payload, null, "overwrite");
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
Assert.Contains("unresolved", response.Error, StringComparison.OrdinalIgnoreCase);
Assert.Contains("NORTH-01", response.Error);
// Apply must NOT have been called — the merge fails before it.
importer.DidNotReceive().ApplyAsync(
Arg.Any<Guid>(),
Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>(),
Arg.Any<Commons.Types.Transport.BundleNameMap?>());
}
[Fact]
public void ImportBundleCommand_ConnectionSpecAndCreateMissing_BuildMap()
{
// Connection-side mirror: an explicit spec maps to existing, and an
// unmatched second connection is created via --create-missing-connections.
var (_, importer) = AddBundleSubstitutes();
var (_, capturedMap) = ConfigureImportForMapping(
importer,
Array.Empty<Commons.Types.Transport.RequiredSiteMapping>(),
new[]
{
new Commons.Types.Transport.RequiredConnectionMapping(
"NORTH-01", "OpcA", AutoMatchTargetName: null),
new Commons.Types.Transport.RequiredConnectionMapping(
"NORTH-01", "OpcB", AutoMatchTargetName: null),
});
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var cmd = new ImportBundleCommand(
payload, null, "overwrite",
ConnectionMappings: new[] { new ConnectionMappingSpec("NORTH-01", "OpcA", "OpcLive") },
CreateMissingConnections: true);
var actor = CreateActor();
actor.Tell(Envelope(cmd, "Administrator"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
var map = capturedMap();
Assert.NotNull(map);
Assert.Equal(2, map!.Connections.Count);
var opcA = map.Connections.Single(c => c.SourceConnectionName == "OpcA");
Assert.Equal(Commons.Types.Transport.MappingAction.MapToExisting, opcA.Action);
Assert.Equal("OpcLive", opcA.TargetConnectionName);
var opcB = map.Connections.Single(c => c.SourceConnectionName == "OpcB");
Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, opcB.Action);
Assert.Null(opcB.TargetConnectionName);
}
[Fact]
public void PreviewBundleCommand_SurfacesRequiredMappings()
{
// HandlePreviewBundle must echo the preview's required mappings on the
// PreviewBundleResult so the CLI/UI can show what needs resolving.
var (_, importer) = AddBundleSubstitutes();
var sessionId = Guid.NewGuid();
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.BundleSession
{
SessionId = sessionId,
Manifest = null!,
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
});
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
.Returns(new Commons.Types.Transport.ImportPreview(
sessionId,
Array.Empty<Commons.Types.Transport.ImportPreviewItem>(),
new[]
{
new Commons.Types.Transport.RequiredSiteMapping(
"NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO"),
},
new[]
{
new Commons.Types.Transport.RequiredConnectionMapping(
"NORTH-01", "OpcA", AutoMatchTargetName: null),
}));
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
var actor = CreateActor();
actor.Tell(Envelope(new PreviewBundleCommand(payload, null), "Administrator"));
var success = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// The result is camelCase JSON (ManagementActor.SerializeResult). The new
// PreviewBundleResult fields must surface the preview's required mappings.
Assert.Contains("requiredSiteMappings", success.JsonData);
Assert.Contains("requiredConnectionMappings", success.JsonData);
Assert.Contains("NORTH-01", success.JsonData);
Assert.Contains("North Plant", success.JsonData);
Assert.Contains("AUTO", success.JsonData);
Assert.Contains("OpcA", success.JsonData);
}
// ========================================================================
// Native alarm source CRUD (Task 21)
// ========================================================================