From 5c52b4c80b16e1df25ab0ec357911318f7d8f143 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 9 Jul 2026 16:09:50 -0400 Subject: [PATCH] fix(transport): carry script cadence/timeout fields through bundle import (was silently reset to defaults) Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj --- .../Import/BundleImporter.cs | 10 +- .../Import/TemplateScriptFidelityTests.cs | 200 ++++++++++++++++++ 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index ddd9e89c..6587c10a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -1571,6 +1571,8 @@ public sealed class BundleImporter : IBundleImporter ParameterDefinitions = s.ParameterDefinitions, ReturnDefinition = s.ReturnDefinition, IsLocked = s.IsLocked, + MinTimeBetweenRuns = s.MinTimeBetweenRuns, + ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds, }); } return t; @@ -1854,7 +1856,9 @@ public sealed class BundleImporter : IBundleImporter !string.Equals(current.TriggerConfiguration, scriptDto.TriggerConfiguration, StringComparison.Ordinal) || !string.Equals(current.ParameterDefinitions, scriptDto.ParameterDefinitions, StringComparison.Ordinal) || !string.Equals(current.ReturnDefinition, scriptDto.ReturnDefinition, StringComparison.Ordinal) || - current.IsLocked != scriptDto.IsLocked; + current.IsLocked != scriptDto.IsLocked || + current.MinTimeBetweenRuns != scriptDto.MinTimeBetweenRuns || + current.ExecutionTimeoutSeconds != scriptDto.ExecutionTimeoutSeconds; if (!changed) continue; current.Code = scriptDto.Code; @@ -1863,6 +1867,8 @@ public sealed class BundleImporter : IBundleImporter current.ParameterDefinitions = scriptDto.ParameterDefinitions; current.ReturnDefinition = scriptDto.ReturnDefinition; current.IsLocked = scriptDto.IsLocked; + current.MinTimeBetweenRuns = scriptDto.MinTimeBetweenRuns; + current.ExecutionTimeoutSeconds = scriptDto.ExecutionTimeoutSeconds; await _templateRepo.UpdateTemplateScriptAsync(current, ct).ConfigureAwait(false); await _auditService.LogAsync( user, @@ -1889,6 +1895,8 @@ public sealed class BundleImporter : IBundleImporter ParameterDefinitions = scriptDto.ParameterDefinitions, ReturnDefinition = scriptDto.ReturnDefinition, IsLocked = scriptDto.IsLocked, + MinTimeBetweenRuns = scriptDto.MinTimeBetweenRuns, + ExecutionTimeoutSeconds = scriptDto.ExecutionTimeoutSeconds, }; ex.Scripts.Add(newScript); await _auditService.LogAsync( diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs new file mode 100644 index 00000000..0bbc4ab3 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs @@ -0,0 +1,200 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; +using ZB.MOM.WW.ScadaBridge.TemplateEngine; +using ZB.MOM.WW.ScadaBridge.Transport; +using ZB.MOM.WW.ScadaBridge.Transport.Serialization; + +namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import; + +/// +/// #05-T3: a template script's and +/// must survive an export→import +/// round-trip. The exporter serializes both fields (EntitySerializer) and the DTO +/// carries them; before this fix the importer's BuildTemplate (Add) and +/// SyncTemplateScriptsAsync (Overwrite) rebuild dropped them, silently resetting +/// the persisted script to schema defaults. Same in-memory host pattern as +/// . +/// +public sealed class TemplateScriptFidelityTests : IDisposable +{ + private readonly ServiceProvider _provider; + + public TemplateScriptFidelityTests() + { + var services = new ServiceCollection(); + services.AddSingleton( + new ConfigurationBuilder().AddInMemoryCollection().Build()); + + var dbName = $"TemplateScriptFidelityTests_{Guid.NewGuid()}"; + services.AddDbContext(opts => opts + .UseInMemoryDatabase(dbName) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); + + services.AddSingleton(new EphemeralDataProtectionProvider()); + services.AddScoped(sp => new ScadaBridgeDbContext( + sp.GetRequiredService>(), + sp.GetRequiredService())); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddTransport(); + services.AddTemplateEngine(); + services.AddDeploymentManager(); + + _provider = services.BuildServiceProvider(); + } + + public void Dispose() => _provider.Dispose(); + + private async Task ExportAndLoadAsync() + { + Stream bundleStream; + await using (var scope = _provider.CreateAsyncScope()) + { + var exporter = scope.ServiceProvider.GetRequiredService(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync(); + var selection = new ExportSelection( + TemplateIds: templateIds, + SharedScriptIds: Array.Empty(), + ExternalSystemIds: Array.Empty(), + DatabaseConnectionIds: Array.Empty(), + NotificationListIds: Array.Empty(), + SmtpConfigurationIds: Array.Empty(), + ApiMethodIds: Array.Empty(), + IncludeDependencies: false); + bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev", + passphrase: null, cancellationToken: CancellationToken.None); + } + + using var ms = new MemoryStream(); + await bundleStream.CopyToAsync(ms); + ms.Position = 0; + + await using var loadScope = _provider.CreateAsyncScope(); + var importer = loadScope.ServiceProvider.GetRequiredService(); + var session = await importer.LoadAsync(ms, passphrase: null); + return session.SessionId; + } + + [Fact] + public async Task Add_preserves_MinTimeBetweenRuns_and_ExecutionTimeoutSeconds() + { + // Arrange: seed a template whose script carries non-default cadence + timeout. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = new Template("Pump") { Description = "fresh" }; + t.Scripts.Add(new TemplateScript("cadence", "return 1;") + { + MinTimeBetweenRuns = TimeSpan.FromSeconds(30), + ExecutionTimeoutSeconds = 42, + }); + ctx.Templates.Add(t); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + + // Wipe so the import exercises the Add path. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Templates.RemoveRange(ctx.Templates); + await ctx.SaveChangesAsync(); + } + + // Act + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + await importer.ApplyAsync(sessionId, + new List { new("Template", "Pump", ResolutionAction.Add, null) }, + user: "bob"); + } + + // Assert: both fields survived the rebuild. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var script = await ctx.Templates + .Where(t => t.Name == "Pump") + .SelectMany(t => t.Scripts) + .SingleAsync(s => s.Name == "cadence"); + Assert.Equal(TimeSpan.FromSeconds(30), script.MinTimeBetweenRuns); + Assert.Equal(42, script.ExecutionTimeoutSeconds); + } + } + + [Fact] + public async Task Overwrite_updates_ExecutionTimeoutSeconds_only_diff_and_emits_audit() + { + // Arrange: seed script with ExecutionTimeoutSeconds=42; export; then mutate the + // target script so it differs from the bundle ONLY in ExecutionTimeoutSeconds. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = new Template("Pump") { Description = "same" }; + t.Scripts.Add(new TemplateScript("cadence", "return 1;") + { + MinTimeBetweenRuns = TimeSpan.FromSeconds(30), + ExecutionTimeoutSeconds = 42, + }); + ctx.Templates.Add(t); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var script = await ctx.Templates + .Where(t => t.Name == "Pump") + .SelectMany(t => t.Scripts) + .SingleAsync(s => s.Name == "cadence"); + script.ExecutionTimeoutSeconds = 10; // now the only field that differs from the bundle + await ctx.SaveChangesAsync(); + } + + // Act + await using (var scope = _provider.CreateAsyncScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + await importer.ApplyAsync(sessionId, + new List { new("Template", "Pump", ResolutionAction.Overwrite, null) }, + user: "bob"); + } + + // Assert: field restored to the bundle value AND a TemplateScriptUpdated audit row emitted. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var script = await ctx.Templates + .Where(t => t.Name == "Pump") + .SelectMany(t => t.Scripts) + .SingleAsync(s => s.Name == "cadence"); + Assert.Equal(42, script.ExecutionTimeoutSeconds); + + Assert.True(await ctx.AuditLogEntries.AnyAsync(a => + a.Action == "TemplateScriptUpdated" && a.EntityName == "Pump.cadence")); + } + } +}