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
@@ -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;
}
/// <summary>
/// Parses the <see cref="PreviewBundleResult"/> JSON envelope and writes a
/// human-readable summary of the <c>requiredSiteMappings</c> /
/// <c>requiredConnectionMappings</c> arrays to stdout. Best-effort: any parse
/// hiccup is swallowed so the raw JSON (printed by the caller) remains the
/// authoritative output.
/// </summary>
/// <param name="jsonOk">The success-body JSON returned by the management endpoint.</param>
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<string[]>("--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<string[]>("--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<bool>("--create-missing-sites")
{
Description = "Create any unmapped/unmatched source site from the bundle payload instead of failing.",
};
var createMissingConnectionsOption = new Option<bool>("--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<SiteMappingSpec> siteMappings;
List<ConnectionMappingSpec> 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();
}
/// <summary>
/// Parses the repeatable <c>--map-site</c> tokens into
/// <see cref="SiteMappingSpec"/>s. Each token is
/// <c>srcIdentifier=dstIdentifier</c> (map to an existing destination) or
/// <c>srcIdentifier</c> / <c>srcIdentifier=</c> (no/empty right-hand side →
/// CreateNew, surfaced as a null target). Exposed <c>internal</c> so the
/// flag-parse tests can assert the split + CreateNew convention.
/// </summary>
/// <param name="tokens">Raw <c>--map-site</c> token values, or <c>null</c>.</param>
/// <returns>The parsed spec list (possibly empty).</returns>
/// <exception cref="FormatException">A token has an empty source identifier.</exception>
internal static List<SiteMappingSpec> ParseSiteMappings(string[]? tokens)
{
var result = new List<SiteMappingSpec>();
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;
}
/// <summary>
/// Parses the repeatable <c>--map-connection</c> tokens into
/// <see cref="ConnectionMappingSpec"/>s. Each token is
/// <c>srcSiteIdentifier/srcName=dstName</c> (map to an existing destination)
/// or <c>srcSiteIdentifier/srcName</c> / <c>…=</c> (no/empty right-hand side →
/// CreateNew, surfaced as a null target). Exposed <c>internal</c> for the
/// flag-parse tests.
/// </summary>
/// <param name="tokens">Raw <c>--map-connection</c> token values, or <c>null</c>.</param>
/// <returns>The parsed spec list (possibly empty).</returns>
/// <exception cref="FormatException">A token is missing the <c>site/name</c> source shape.</exception>
internal static List<ConnectionMappingSpec> ParseConnectionMappings(string[]? tokens)
{
var result = new List<ConnectionMappingSpec>();
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;
}
/// <summary>
/// Splits <paramref name="value"/> on the first occurrence of
/// <paramref name="separator"/>. When the separator is absent the right side
/// is <c>null</c> (distinguishing "src" from "src=" — both mean CreateNew here,
/// but callers that need to detect the separator's presence can).
/// </summary>
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)..]);
}
}
@@ -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<RequiredSiteMapping>? RequiredSiteMappings = null,
IReadOnlyList<RequiredConnectionMapping>? RequiredConnectionMappings = null)
{
/// <summary>Site references the operator must resolve before import (M8). Never null.</summary>
public IReadOnlyList<RequiredSiteMapping> RequiredSiteMappings { get; init; } =
RequiredSiteMappings ?? Array.Empty<RequiredSiteMapping>();
/// <summary>Connection references the operator must resolve before import (M8). Never null.</summary>
public IReadOnlyList<RequiredConnectionMapping> RequiredConnectionMappings { get; init; } =
RequiredConnectionMappings ?? Array.Empty<RequiredConnectionMapping>();
}
/// <summary>
/// One source-environment site → destination resolution carried on
/// <see cref="ImportBundleCommand"/> across the CLI ⇆ ManagementActor boundary.
/// A null/blank <paramref name="TargetSiteIdentifier"/> means CreateNew; a
/// present value means MapToExisting that target. Serializable via
/// System.Text.Json.
/// </summary>
public sealed record SiteMappingSpec(
string SourceSiteIdentifier,
string? TargetSiteIdentifier);
/// <summary>
/// One source-environment data-connection (scoped to a source site) →
/// destination resolution carried on <see cref="ImportBundleCommand"/>. A
/// null/blank <paramref name="TargetConnectionName"/> means CreateNew; a present
/// value means MapToExisting that target. Serializable via System.Text.Json.
/// </summary>
public sealed record ConnectionMappingSpec(
string SourceSiteIdentifier,
string SourceConnectionName,
string? TargetConnectionName);
/// <summary>
/// Loads, previews, and applies a bundle in a single call. The diff is built
@@ -66,8 +102,22 @@ public sealed record PreviewBundleResult(
/// Valid <see cref="DefaultConflictPolicy"/> values: <c>"skip"</c>,
/// <c>"overwrite"</c>, <c>"rename"</c>. Rename mints a unique suffix per row.
/// </para>
/// <para>
/// M8 (D3): <paramref name="SiteMappings"/> / <paramref name="ConnectionMappings"/>
/// 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 <paramref name="CreateMissingSites"/> /
/// <paramref name="CreateMissingConnections"/> flags into a
/// <see cref="BundleNameMap"/> 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.
/// </para>
/// </summary>
public sealed record ImportBundleCommand(
string Base64Bundle,
string? Passphrase,
string DefaultConflictPolicy);
string DefaultConflictPolicy,
IReadOnlyList<SiteMappingSpec>? SiteMappings = null,
IReadOnlyList<ConnectionMappingSpec>? ConnectionMappings = null,
bool CreateMissingSites = false,
bool CreateMissingConnections = false);
@@ -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);
}
/// <summary>
@@ -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);
}
/// <summary>
/// Merges the operator-supplied <see cref="SiteMappingSpec"/> /
/// <see cref="ConnectionMappingSpec"/> lists with the preview's auto-matches
/// and the create-missing flags into a <see cref="BundleNameMap"/>.
/// <para>
/// Per required site reference: an explicit spec wins (target present →
/// <see cref="MappingAction.MapToExisting"/>; null/blank target →
/// <see cref="MappingAction.CreateNew"/>); otherwise the preview's
/// <see cref="RequiredSiteMapping.AutoMatchTargetIdentifier"/> is used when
/// present (MapToExisting); otherwise CreateNew only when
/// <see cref="ImportBundleCommand.CreateMissingSites"/> is set, else the
/// reference is unresolved and the import fails. Connections mirror this with
/// <see cref="ImportBundleCommand.CreateMissingConnections"/>.
/// </para>
/// </summary>
private static BundleNameMap BuildNameMap(ImportBundleCommand cmd, ImportPreview preview)
{
var siteSpecs = (cmd.SiteMappings ?? Array.Empty<SiteMappingSpec>())
.ToDictionary(s => s.SourceSiteIdentifier, StringComparer.Ordinal);
var connSpecs = (cmd.ConnectionMappings ?? Array.Empty<ConnectionMappingSpec>())
.ToDictionary(
c => (c.SourceSiteIdentifier, c.SourceConnectionName),
c => c);
var siteMappings = new List<SiteMapping>();
var unresolved = new List<string>();
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<ConnectionMapping>();
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)
@@ -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)
// ========================================================================