feat(transport): import name-map plumbing via CLI + ManagementActor (M8 D3)
This commit is contained in:
@@ -137,4 +137,100 @@ public class BundleCommandsStreamingTests : IDisposable
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
|
||||
// ---- M8 (D3): --map-site / --map-connection spec parsing ----------------
|
||||
|
||||
[Fact]
|
||||
public void ParseSiteMappings_Null_ReturnsEmpty()
|
||||
{
|
||||
Assert.Empty(BundleCommands.ParseSiteMappings(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSiteMappings_SrcEqualsDst_MapsToExisting()
|
||||
{
|
||||
var specs = BundleCommands.ParseSiteMappings(new[] { "NORTH-01=PLANT-A" });
|
||||
var spec = Assert.Single(specs);
|
||||
Assert.Equal("NORTH-01", spec.SourceSiteIdentifier);
|
||||
Assert.Equal("PLANT-A", spec.TargetSiteIdentifier);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("NORTH-01")] // no '=' part -> create-new
|
||||
[InlineData("NORTH-01=")] // empty rhs -> create-new
|
||||
[InlineData("NORTH-01= ")] // whitespace rhs -> create-new
|
||||
public void ParseSiteMappings_NoTarget_MeansCreateNew(string token)
|
||||
{
|
||||
var specs = BundleCommands.ParseSiteMappings(new[] { token });
|
||||
var spec = Assert.Single(specs);
|
||||
Assert.Equal("NORTH-01", spec.SourceSiteIdentifier);
|
||||
Assert.Null(spec.TargetSiteIdentifier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSiteMappings_Repeated_AccumulatesAll()
|
||||
{
|
||||
var specs = BundleCommands.ParseSiteMappings(new[] { "A=X", "B" });
|
||||
Assert.Equal(2, specs.Count);
|
||||
Assert.Equal("X", specs[0].TargetSiteIdentifier);
|
||||
Assert.Null(specs[1].TargetSiteIdentifier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSiteMappings_EmptySource_Throws()
|
||||
{
|
||||
Assert.Throws<FormatException>(() => BundleCommands.ParseSiteMappings(new[] { "=PLANT-A" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConnectionMappings_SrcEqualsDst_MapsToExisting()
|
||||
{
|
||||
var specs = BundleCommands.ParseConnectionMappings(new[] { "NORTH-01/OpcA=OpcLive" });
|
||||
var spec = Assert.Single(specs);
|
||||
Assert.Equal("NORTH-01", spec.SourceSiteIdentifier);
|
||||
Assert.Equal("OpcA", spec.SourceConnectionName);
|
||||
Assert.Equal("OpcLive", spec.TargetConnectionName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("NORTH-01/OpcA")] // no '=' part -> create-new
|
||||
[InlineData("NORTH-01/OpcA=")] // empty rhs -> create-new
|
||||
public void ParseConnectionMappings_NoTarget_MeansCreateNew(string token)
|
||||
{
|
||||
var specs = BundleCommands.ParseConnectionMappings(new[] { token });
|
||||
var spec = Assert.Single(specs);
|
||||
Assert.Equal("NORTH-01", spec.SourceSiteIdentifier);
|
||||
Assert.Equal("OpcA", spec.SourceConnectionName);
|
||||
Assert.Null(spec.TargetConnectionName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("OpcA")] // missing site/name shape
|
||||
[InlineData("NORTH-01/")] // empty connection name
|
||||
[InlineData("/OpcA")] // empty site
|
||||
public void ParseConnectionMappings_MalformedSource_Throws(string token)
|
||||
{
|
||||
Assert.Throws<FormatException>(() => BundleCommands.ParseConnectionMappings(new[] { token }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundleImport_MapAndCreateMissingFlags_ParseWithoutError()
|
||||
{
|
||||
var url = new Option<string>("--url") { Recursive = true };
|
||||
var format = new Option<string>("--format") { Recursive = true };
|
||||
var username = new Option<string>("--username") { Recursive = true };
|
||||
var password = new Option<string>("--password") { Recursive = true };
|
||||
var bundle = BundleCommands.Build(url, format, username, password);
|
||||
|
||||
var parse = bundle.Parse(new[]
|
||||
{
|
||||
"import", "--input", "/tmp/in.scadabundle",
|
||||
"--map-site", "NORTH-01=PLANT-A",
|
||||
"--map-connection", "NORTH-01/OpcA=OpcLive",
|
||||
"--create-missing-sites",
|
||||
"--create-missing-connections",
|
||||
});
|
||||
|
||||
Assert.Empty(parse.Errors);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
// ========================================================================
|
||||
|
||||
Reference in New Issue
Block a user