From 565d53d0fe5e9e942254cff61faf651be49ec274 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 07:08:33 -0400 Subject: [PATCH] feat(transport): import name-map plumbing via CLI + ManagementActor (M8 D3) --- .../Commands/BundleCommands.cs | 185 +++++++++++- .../Messages/Management/TransportCommands.cs | 54 +++- .../ManagementActor.cs | 108 ++++++- .../Commands/BundleCommandsStreamingTests.cs | 96 ++++++ .../ManagementActorTests.cs | 285 +++++++++++++++++- 5 files changed, 721 insertions(+), 7 deletions(-) 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) // ========================================================================