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")); } } }