diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs
index 76d467f2..9d8eee20 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/BundleCommands.cs
@@ -222,6 +222,11 @@ public static class BundleCommands
timeout: BundleCommandTimeout,
onSuccess: jsonOk =>
{
+ // Surface the required site/connection mappings so an operator
+ // sees which references need resolving on `bundle import`. The
+ // full PreviewBundleResult JSON (including the new fields) is
+ // still printed verbatim afterwards for tooling/jq.
+ PrintRequiredMappingSummary(jsonOk);
Console.WriteLine(jsonOk);
return 0;
});
@@ -229,6 +234,60 @@ public static class BundleCommands
return cmd;
}
+ ///
+ /// Parses the JSON envelope and writes a
+ /// human-readable summary of the requiredSiteMappings /
+ /// requiredConnectionMappings arrays to stdout. Best-effort: any parse
+ /// hiccup is swallowed so the raw JSON (printed by the caller) remains the
+ /// authoritative output.
+ ///
+ /// The success-body JSON returned by the management endpoint.
+ private static void PrintRequiredMappingSummary(string jsonOk)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(jsonOk);
+ var root = doc.RootElement;
+ var sites = root.TryGetProperty("requiredSiteMappings", out var s)
+ && s.ValueKind == JsonValueKind.Array ? s : default;
+ var conns = root.TryGetProperty("requiredConnectionMappings", out var c)
+ && c.ValueKind == JsonValueKind.Array ? c : default;
+
+ var siteCount = sites.ValueKind == JsonValueKind.Array ? sites.GetArrayLength() : 0;
+ var connCount = conns.ValueKind == JsonValueKind.Array ? conns.GetArrayLength() : 0;
+ if (siteCount == 0 && connCount == 0) return;
+
+ Console.WriteLine("Mappings required before import:");
+ foreach (var site in sites.EnumerateArray())
+ {
+ var src = site.TryGetProperty("sourceSiteIdentifier", out var v) ? v.GetString() : null;
+ var name = site.TryGetProperty("sourceSiteName", out var n) ? n.GetString() : null;
+ var auto = site.TryGetProperty("autoMatchTargetIdentifier", out var a)
+ && a.ValueKind == JsonValueKind.String ? a.GetString() : null;
+ var hint = auto is null
+ ? "no auto-match (use --map-site or --create-missing-sites)"
+ : $"auto-matches '{auto}'";
+ Console.WriteLine($" site: {src} ({name}) — {hint}");
+ }
+ foreach (var conn in conns.EnumerateArray())
+ {
+ var src = conn.TryGetProperty("sourceSiteIdentifier", out var v) ? v.GetString() : null;
+ var cname = conn.TryGetProperty("sourceConnectionName", out var n) ? n.GetString() : null;
+ var auto = conn.TryGetProperty("autoMatchTargetName", out var a)
+ && a.ValueKind == JsonValueKind.String ? a.GetString() : null;
+ var hint = auto is null
+ ? "no auto-match (use --map-connection or --create-missing-connections)"
+ : $"auto-matches '{auto}'";
+ Console.WriteLine($" connection: {src}/{cname} — {hint}");
+ }
+ Console.WriteLine();
+ }
+ catch (JsonException)
+ {
+ // Best-effort: the raw JSON is still printed by the caller.
+ }
+ }
+
// ====================================================================
// bundle import
// ====================================================================
@@ -250,6 +309,30 @@ public static class BundleCommands
Description = "Resolution policy applied to every Modified row: skip, overwrite, or rename. Default: overwrite.",
DefaultValueFactory = _ => "overwrite",
};
+ // M8 (D3): site/connection name mapping. Repeatable. A token with no
+ // "=dst" part, or "=" with an empty right-hand side, means CreateNew (the
+ // destination is created from the bundle payload); otherwise the source
+ // is mapped to the named existing destination.
+ var mapSiteOption = new Option("--map-site")
+ {
+ Description = "Map a source site to a destination: 'srcIdentifier=dstIdentifier'. " +
+ "'srcIdentifier' or 'srcIdentifier=' (no/empty dst) means create-new. Repeatable.",
+ AllowMultipleArgumentsPerToken = true,
+ };
+ var mapConnectionOption = new Option("--map-connection")
+ {
+ Description = "Map a source connection to a destination: 'srcSiteIdentifier/srcName=dstName'. " +
+ "'srcSiteIdentifier/srcName' or '…=' (no/empty dst) means create-new. Repeatable.",
+ AllowMultipleArgumentsPerToken = true,
+ };
+ var createMissingSitesOption = new Option("--create-missing-sites")
+ {
+ Description = "Create any unmapped/unmatched source site from the bundle payload instead of failing.",
+ };
+ var createMissingConnectionsOption = new Option("--create-missing-connections")
+ {
+ Description = "Create any unmapped/unmatched source connection from the bundle payload instead of failing.",
+ };
var cmd = new Command("import")
{
@@ -258,6 +341,10 @@ public static class BundleCommands
cmd.Add(inputOption);
cmd.Add(passphraseOption);
cmd.Add(onConflictOption);
+ cmd.Add(mapSiteOption);
+ cmd.Add(mapConnectionOption);
+ cmd.Add(createMissingSitesOption);
+ cmd.Add(createMissingConnectionsOption);
cmd.SetAction(async (ParseResult result) =>
{
@@ -267,11 +354,29 @@ public static class BundleCommands
OutputFormatter.WriteError($"Bundle file not found: {input}", "FILE_NOT_FOUND");
return 1;
}
+
+ List siteMappings;
+ List connectionMappings;
+ try
+ {
+ siteMappings = ParseSiteMappings(result.GetValue(mapSiteOption));
+ connectionMappings = ParseConnectionMappings(result.GetValue(mapConnectionOption));
+ }
+ catch (FormatException ex)
+ {
+ OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
+ return 1;
+ }
+
var bytes = await File.ReadAllBytesAsync(input);
var payload = new ImportBundleCommand(
Base64Bundle: Convert.ToBase64String(bytes),
Passphrase: result.GetValue(passphraseOption),
- DefaultConflictPolicy: result.GetValue(onConflictOption)!);
+ DefaultConflictPolicy: result.GetValue(onConflictOption)!,
+ SiteMappings: siteMappings.Count == 0 ? null : siteMappings,
+ ConnectionMappings: connectionMappings.Count == 0 ? null : connectionMappings,
+ CreateMissingSites: result.GetValue(createMissingSitesOption),
+ CreateMissingConnections: result.GetValue(createMissingConnectionsOption));
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
@@ -389,4 +494,82 @@ public static class BundleCommands
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
}
+
+ ///
+ /// Parses the repeatable --map-site tokens into
+ /// s. Each token is
+ /// srcIdentifier=dstIdentifier (map to an existing destination) or
+ /// srcIdentifier / srcIdentifier= (no/empty right-hand side →
+ /// CreateNew, surfaced as a null target). Exposed internal so the
+ /// flag-parse tests can assert the split + CreateNew convention.
+ ///
+ /// Raw --map-site token values, or null.
+ /// The parsed spec list (possibly empty).
+ /// A token has an empty source identifier.
+ internal static List ParseSiteMappings(string[]? tokens)
+ {
+ var result = new List();
+ if (tokens is null) return result;
+ foreach (var token in tokens)
+ {
+ if (string.IsNullOrWhiteSpace(token)) continue;
+ var (left, right) = SplitOnFirst(token, '=');
+ var src = left.Trim();
+ if (src.Length == 0)
+ {
+ throw new FormatException(
+ $"Invalid --map-site '{token}': source identifier is required (use 'src=dst' or 'src' for create-new).");
+ }
+ // null target == CreateNew, per the documented convention.
+ result.Add(new SiteMappingSpec(src, string.IsNullOrWhiteSpace(right) ? null : right!.Trim()));
+ }
+ return result;
+ }
+
+ ///
+ /// Parses the repeatable --map-connection tokens into
+ /// s. Each token is
+ /// srcSiteIdentifier/srcName=dstName (map to an existing destination)
+ /// or srcSiteIdentifier/srcName / …= (no/empty right-hand side →
+ /// CreateNew, surfaced as a null target). Exposed internal for the
+ /// flag-parse tests.
+ ///
+ /// Raw --map-connection token values, or null.
+ /// The parsed spec list (possibly empty).
+ /// A token is missing the site/name source shape.
+ internal static List ParseConnectionMappings(string[]? tokens)
+ {
+ var result = new List();
+ if (tokens is null) return result;
+ foreach (var token in tokens)
+ {
+ if (string.IsNullOrWhiteSpace(token)) continue;
+ var (lhs, right) = SplitOnFirst(token, '=');
+ var (site, name) = SplitOnFirst(lhs, '/');
+ var srcSite = site.Trim();
+ var srcName = (name ?? string.Empty).Trim();
+ if (srcSite.Length == 0 || name is null || srcName.Length == 0)
+ {
+ throw new FormatException(
+ $"Invalid --map-connection '{token}': expected 'srcSiteIdentifier/srcName[=dstName]'.");
+ }
+ // null target == CreateNew, per the documented convention.
+ result.Add(new ConnectionMappingSpec(srcSite, srcName, string.IsNullOrWhiteSpace(right) ? null : right!.Trim()));
+ }
+ return result;
+ }
+
+ ///
+ /// Splits on the first occurrence of
+ /// . When the separator is absent the right side
+ /// is null (distinguishing "src" from "src=" — both mean CreateNew here,
+ /// but callers that need to detect the separator's presence can).
+ ///
+ private static (string Left, string? Right) SplitOnFirst(string value, char separator)
+ {
+ var idx = value.IndexOf(separator);
+ return idx < 0
+ ? (value, null)
+ : (value[..idx], value[(idx + 1)..]);
+ }
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs
index 70343d2b..0c335674 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/TransportCommands.cs
@@ -53,7 +53,43 @@ public sealed record PreviewBundleResult(
int AddCount,
int ModifiedCount,
int IdenticalCount,
- int BlockerCount);
+ int BlockerCount,
+ // Additive (M8 D3): site/connection references the operator must resolve
+ // before import, carried verbatim from ImportPreview. Defaulted to empty so
+ // every existing positional caller keeps compiling.
+ IReadOnlyList? RequiredSiteMappings = null,
+ IReadOnlyList? RequiredConnectionMappings = null)
+{
+ /// Site references the operator must resolve before import (M8). Never null.
+ public IReadOnlyList RequiredSiteMappings { get; init; } =
+ RequiredSiteMappings ?? Array.Empty();
+
+ /// Connection references the operator must resolve before import (M8). Never null.
+ public IReadOnlyList RequiredConnectionMappings { get; init; } =
+ RequiredConnectionMappings ?? Array.Empty();
+}
+
+///
+/// One source-environment site → destination resolution carried on
+/// across the CLI ⇆ ManagementActor boundary.
+/// A null/blank means CreateNew; a
+/// present value means MapToExisting that target. Serializable via
+/// System.Text.Json.
+///
+public sealed record SiteMappingSpec(
+ string SourceSiteIdentifier,
+ string? TargetSiteIdentifier);
+
+///
+/// One source-environment data-connection (scoped to a source site) →
+/// destination resolution carried on . A
+/// null/blank means CreateNew; a present
+/// value means MapToExisting that target. Serializable via System.Text.Json.
+///
+public sealed record ConnectionMappingSpec(
+ string SourceSiteIdentifier,
+ string SourceConnectionName,
+ string? TargetConnectionName);
///
/// Loads, previews, and applies a bundle in a single call. The diff is built
@@ -66,8 +102,22 @@ public sealed record PreviewBundleResult(
/// Valid values: "skip",
/// "overwrite", "rename". Rename mints a unique suffix per row.
///
+///
+/// M8 (D3): /
+/// supply explicit operator resolutions for the source-environment site/connection
+/// references the bundle carries; the handler merges them with the preview's
+/// auto-matches and the /
+/// flags into a
+/// passed to Apply. All four are defaulted so every
+/// existing positional caller keeps compiling and central-config-only bundles
+/// (no site/instance payload) carry an empty map.
+///
///
public sealed record ImportBundleCommand(
string Base64Bundle,
string? Passphrase,
- string DefaultConflictPolicy);
+ string DefaultConflictPolicy,
+ IReadOnlyList? SiteMappings = null,
+ IReadOnlyList? ConnectionMappings = null,
+ bool CreateMissingSites = false,
+ bool CreateMissingConnections = false);
diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
index 9e4f24e1..f7c5924c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
@@ -2421,7 +2421,11 @@ public class ManagementActor : ReceiveActor
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
- return new PreviewBundleResult(preview.Items, adds, mods, ids, blocks);
+ // M8 (D3): surface the required site/connection mappings so an operator
+ // (CLI or UI) sees which references need resolving before applying.
+ return new PreviewBundleResult(
+ preview.Items, adds, mods, ids, blocks,
+ preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
}
///
@@ -2462,10 +2466,19 @@ public class ManagementActor : ReceiveActor
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
}
+ // M8 (D3): resolve every required site/connection reference into a
+ // BundleNameMap. Precedence: an explicit operator spec wins; otherwise
+ // fall back to the preview's auto-match; otherwise (no match) create-new
+ // ONLY if the create-missing flag is set, else fail with a clear message.
+ var nameMap = BuildNameMap(cmd, preview);
+
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
// entity (e.g. one row per modified member of a template), but ApplyAsync
// requires a unique resolution per key. Last-write-wins matches the
- // Central UI's TransportImport.BuildDefaultResolutions behavior.
+ // Central UI's TransportImport.BuildDefaultResolutions behavior. For
+ // DataConnection rows the preview item Name is already site-qualified
+ // ({site}/{name}, D1-FIX), so keying generically by (EntityType, Name) is
+ // automatically correct — no bare-connection-name special case needed.
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
@@ -2484,7 +2497,96 @@ public class ManagementActor : ReceiveActor
item.EntityType, item.Name, action, renameTo);
}
- return await importer.ApplyAsync(session.SessionId, resolutionsMap.Values.ToList(), username);
+ return await importer.ApplyAsync(
+ session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
+ }
+
+ ///
+ /// Merges the operator-supplied /
+ /// lists with the preview's auto-matches
+ /// and the create-missing flags into a .
+ ///
+ /// Per required site reference: an explicit spec wins (target present →
+ /// ; null/blank target →
+ /// ); otherwise the preview's
+ /// is used when
+ /// present (MapToExisting); otherwise CreateNew only when
+ /// is set, else the
+ /// reference is unresolved and the import fails. Connections mirror this with
+ /// .
+ ///
+ ///
+ private static BundleNameMap BuildNameMap(ImportBundleCommand cmd, ImportPreview preview)
+ {
+ var siteSpecs = (cmd.SiteMappings ?? Array.Empty())
+ .ToDictionary(s => s.SourceSiteIdentifier, StringComparer.Ordinal);
+ var connSpecs = (cmd.ConnectionMappings ?? Array.Empty())
+ .ToDictionary(
+ c => (c.SourceSiteIdentifier, c.SourceConnectionName),
+ c => c);
+
+ var siteMappings = new List();
+ var unresolved = new List();
+ foreach (var required in preview.RequiredSiteMappings)
+ {
+ if (siteSpecs.TryGetValue(required.SourceSiteIdentifier, out var spec))
+ {
+ siteMappings.Add(string.IsNullOrWhiteSpace(spec.TargetSiteIdentifier)
+ ? new SiteMapping(required.SourceSiteIdentifier, MappingAction.CreateNew, null)
+ : new SiteMapping(required.SourceSiteIdentifier, MappingAction.MapToExisting, spec.TargetSiteIdentifier));
+ }
+ else if (!string.IsNullOrWhiteSpace(required.AutoMatchTargetIdentifier))
+ {
+ siteMappings.Add(new SiteMapping(
+ required.SourceSiteIdentifier, MappingAction.MapToExisting, required.AutoMatchTargetIdentifier));
+ }
+ else if (cmd.CreateMissingSites)
+ {
+ siteMappings.Add(new SiteMapping(
+ required.SourceSiteIdentifier, MappingAction.CreateNew, null));
+ }
+ else
+ {
+ unresolved.Add($"site '{required.SourceSiteIdentifier}'");
+ }
+ }
+
+ var connMappings = new List();
+ foreach (var required in preview.RequiredConnectionMappings)
+ {
+ var key = (required.SourceSiteIdentifier, required.SourceConnectionName);
+ if (connSpecs.TryGetValue(key, out var spec))
+ {
+ connMappings.Add(string.IsNullOrWhiteSpace(spec.TargetConnectionName)
+ ? new ConnectionMapping(required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.CreateNew, null)
+ : new ConnectionMapping(required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.MapToExisting, spec.TargetConnectionName));
+ }
+ else if (!string.IsNullOrWhiteSpace(required.AutoMatchTargetName))
+ {
+ connMappings.Add(new ConnectionMapping(
+ required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.MapToExisting, required.AutoMatchTargetName));
+ }
+ else if (cmd.CreateMissingConnections)
+ {
+ connMappings.Add(new ConnectionMapping(
+ required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.CreateNew, null));
+ }
+ else
+ {
+ unresolved.Add($"connection '{required.SourceSiteIdentifier}/{required.SourceConnectionName}'");
+ }
+ }
+
+ if (unresolved.Count > 0)
+ {
+ throw new ManagementCommandException(
+ $"Import has {unresolved.Count} unresolved mapping(s): " +
+ $"{string.Join(", ", unresolved.OrderBy(u => u, StringComparer.Ordinal))}. " +
+ "Supply --map-site/--map-connection for each, or pass " +
+ "--create-missing-sites/--create-missing-connections to create them from the bundle.");
+ }
+
+ return new BundleNameMap(siteMappings, connMappings);
}
private static byte[] DecodeBundle(string base64)
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs
index e99ba4d9..b142703a 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/BundleCommandsStreamingTests.cs
@@ -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(() => 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(() => BundleCommands.ParseConnectionMappings(new[] { token }));
+ }
+
+ [Fact]
+ public void BundleImport_MapAndCreateMissingFlags_ParseWithoutError()
+ {
+ var url = new Option("--url") { Recursive = true };
+ var format = new Option("--format") { Recursive = true };
+ var username = new Option("--username") { Recursive = true };
+ var password = new Option("--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);
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
index 30c4dfdd..34b19d8e 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs
@@ -1677,12 +1677,16 @@ public class ManagementActorTests : TestKit, IDisposable
}));
IReadOnlyList? 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(),
Arg.Do>(
r => captured = r),
Arg.Any(),
- Arg.Any())
+ Arg.Any(),
+ Arg.Any())
.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.
+ // ------------------------------------------------------------------------
+
+ ///
+ /// 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
+ /// .
+ ///
+ private static (Guid SessionId, Func CapturedMap) ConfigureImportForMapping(
+ Commons.Interfaces.Transport.IBundleImporter importer,
+ IReadOnlyList siteMappings,
+ IReadOnlyList connectionMappings)
+ {
+ var sessionId = Guid.NewGuid();
+ importer.LoadAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(new Commons.Types.Transport.BundleSession
+ {
+ SessionId = sessionId,
+ Manifest = null!,
+ ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
+ });
+ importer.PreviewAsync(sessionId, Arg.Any())
+ .Returns(new Commons.Types.Transport.ImportPreview(
+ sessionId,
+ Array.Empty(),
+ siteMappings,
+ connectionMappings));
+
+ Commons.Types.Transport.BundleNameMap? captured = null;
+ importer.ApplyAsync(
+ Arg.Any(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Do(m => captured = m))
+ .Returns(new Commons.Types.Transport.ImportResult(
+ BundleImportId: Guid.NewGuid(),
+ Added: 0, Overwritten: 0, Skipped: 0, Renamed: 0,
+ StaleInstanceIds: Array.Empty(),
+ 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());
+
+ 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(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());
+
+ 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(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());
+
+ 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(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());
+
+ 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(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());
+
+ 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(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(),
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ [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(),
+ 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(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(), Arg.Any(), Arg.Any())
+ .Returns(new Commons.Types.Transport.BundleSession
+ {
+ SessionId = sessionId,
+ Manifest = null!,
+ ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
+ });
+ importer.PreviewAsync(sessionId, Arg.Any())
+ .Returns(new Commons.Types.Transport.ImportPreview(
+ sessionId,
+ Array.Empty(),
+ 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(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)
// ========================================================================