From ab77094a260a8dc39a4545d7367c32959e8556b1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:50:32 -0400 Subject: [PATCH] =?UTF-8?q?fix(transport):=20script-artifact=20change=20pu?= =?UTF-8?q?blisher=20covers=20Add=20resolutions=20=E2=80=94=20delete-then-?= =?UTF-8?q?reimport=20no=20longer=20starves=20non-self-healing=20subscribe?= =?UTF-8?q?rs=20(plan=20R2-05=20T5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Import/BundleImporter.cs | 17 +++--- .../Import/BundleImporterApplyTests.cs | 55 +++++++++++++++++++ 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 50100d3b..99c2868d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -1767,12 +1767,15 @@ public sealed class BundleImporter : IBundleImporter /// /// /// Publishes one per script-bearing - /// artifact kind (ApiMethod / SharedScript / Template) whose resolution was an - /// Overwrite or Rename, using the post-resolution (renamed) names. Adds are - /// excluded — nothing is cached under a brand-new name yet. Over-approximation is - /// safe (an Overwrite that actually fell through to Add just triggers a harmless - /// consumer re-check), which matches the bus's advisory, at-least-once contract. - /// Fully guarded: never throws (it runs after the transaction has committed). + /// artifact kind (ApiMethod / SharedScript / Template) for every non-Skip + /// resolution — Overwrite, Rename, AND Add — using the post-resolution (renamed) + /// names. An Add is NOT "nothing cached yet": an artifact deleted on the target and + /// re-imported as Add under the same name can still have a stale + /// compiled-handler/_knownBadMethods entry on any node, so it must notify too. + /// Over-approximation stays safe (the bus is advisory, at-least-once; consumers + /// self-heal), which matches its stated contract. Only Skip resolutions are excluded + /// (nothing was written). Fully guarded: never throws (it runs after the transaction + /// has committed). /// private void PublishScriptArtifactChanges(IReadOnlyList resolutions) { @@ -1799,7 +1802,7 @@ public sealed class BundleImporter : IBundleImporter { var names = all .Where(r => string.Equals(r.EntityType, entityType, StringComparison.Ordinal) - && (r.Action is ResolutionAction.Overwrite or ResolutionAction.Rename)) + && r.Action is not ResolutionAction.Skip) .Select(r => r.Action == ResolutionAction.Rename ? r.RenameTo ?? r.Name : r.Name) .Distinct(StringComparer.Ordinal) .ToList(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs index 2be247be..f920ad0c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs @@ -1397,6 +1397,61 @@ public sealed class BundleImporterApplyTests : IDisposable Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source)); } + [Fact] + public async Task ApplyAsync_publishes_ScriptArtifactsChanged_for_Add_resolutions() + { + // ApiMethod imported as Add into a target where the name does not exist. + // Delete-then-reimport-as-Add is the N3 edge: a node can still hold a + // _knownBadMethods / compiled-handler entry under that very name, so Adds + // must notify too (over-approximation is safe per the bus contract). + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.ApiMethods.Add(new ApiMethod("DelmiaRecipeDownload", "return 1;") { TimeoutSeconds = 30 }); + await ctx.SaveChangesAsync(); + } + + Guid sessionId; + await using (var scope = _provider.CreateAsyncScope()) + { + var exporter = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var selection = new ExportSelection( + TemplateIds: Array.Empty(), + SharedScriptIds: Array.Empty(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(), + IncludeDependencies: false); + var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", + passphrase: null, cancellationToken: CancellationToken.None); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + ms.Position = 0; + + // Delete the target method so the resolution is a genuine Add. + ctx.ApiMethods.RemoveRange(await ctx.ApiMethods.ToListAsync()); + await ctx.SaveChangesAsync(); + + var importer = scope.ServiceProvider.GetRequiredService(); + sessionId = (await importer.LoadAsync(ms, passphrase: null)).SessionId; + } + + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + await importer.ApplyAsync(sessionId, + new List { new("ApiMethod", "DelmiaRecipeDownload", ResolutionAction.Add, null) }, + user: "bob"); + } + + var notification = Assert.Single(_artifactBus.Received, + n => n.ArtifactKind == ScriptArtifactKinds.ApiMethod); + Assert.Contains("DelmiaRecipeDownload", notification.Names); // FAILS today: Adds are filtered out + } + [Fact] public async Task ApplyAsync_failed_apply_publishes_nothing() {