From bf17f60a048d6794cb7fdd2ffadb1ef8d1603221 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 9 Jul 2026 16:46:33 -0400 Subject: [PATCH] fix(transport): transport LockedInDerived on template attributes/alarms/scripts (additive DTO fields) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a trailing optional `bool LockedInDerived = false` to TemplateAttributeDto, TemplateAlarmDto and TemplateScriptDto (mirrors the ExecutionTimeoutSeconds additive precedent — no schemaVersion bump). Populated at export (ToBundleContent), consumed on import (FromBundleContent, BuildTemplate, and all three SyncTemplate*Async changed-predicate + copy + new-entity paths, incl. audit payloads). Old-form bundles that lack the property still deserialize to false. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj --- .../Import/BundleImporter.cs | 24 ++++- .../Serialization/EntityDtos.cs | 16 +++- .../Serialization/EntitySerializer.cs | 12 ++- .../Import/TemplateScriptFidelityTests.cs | 57 +++++++++++ .../Serialization/EntitySerializerTests.cs | 94 +++++++++++++++++++ 5 files changed, 194 insertions(+), 9 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 6587c10a..a225d713 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -1549,6 +1549,7 @@ public sealed class BundleImporter : IBundleImporter Description = a.Description, DataSourceReference = a.DataSourceReference, ElementDataType = a.ElementDataType, + LockedInDerived = a.LockedInDerived, }); } foreach (var al in dto.Alarms) @@ -1560,6 +1561,7 @@ public sealed class BundleImporter : IBundleImporter TriggerType = al.TriggerType, TriggerConfiguration = al.TriggerConfiguration, IsLocked = al.IsLocked, + LockedInDerived = al.LockedInDerived, }); } foreach (var s in dto.Scripts) @@ -1573,6 +1575,7 @@ public sealed class BundleImporter : IBundleImporter IsLocked = s.IsLocked, MinTimeBetweenRuns = s.MinTimeBetweenRuns, ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds, + LockedInDerived = s.LockedInDerived, }); } return t; @@ -1635,7 +1638,8 @@ public sealed class BundleImporter : IBundleImporter current.IsLocked != attrDto.IsLocked || !string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) || !string.Equals(current.DataSourceReference, attrDto.DataSourceReference, StringComparison.Ordinal) || - current.ElementDataType != attrDto.ElementDataType; + current.ElementDataType != attrDto.ElementDataType || + current.LockedInDerived != attrDto.LockedInDerived; if (!changed) continue; current.Value = normalizedValue; @@ -1644,6 +1648,7 @@ public sealed class BundleImporter : IBundleImporter current.Description = attrDto.Description; current.DataSourceReference = attrDto.DataSourceReference; current.ElementDataType = attrDto.ElementDataType; + current.LockedInDerived = attrDto.LockedInDerived; await _templateRepo.UpdateTemplateAttributeAsync(current, ct).ConfigureAwait(false); await _auditService.LogAsync( user, @@ -1659,6 +1664,7 @@ public sealed class BundleImporter : IBundleImporter current.DataType, current.ElementDataType, current.IsLocked, + current.LockedInDerived, current.Description, current.DataSourceReference, }, @@ -1674,6 +1680,7 @@ public sealed class BundleImporter : IBundleImporter Description = attrDto.Description, DataSourceReference = attrDto.DataSourceReference, ElementDataType = attrDto.ElementDataType, + LockedInDerived = attrDto.LockedInDerived, }; ex.Attributes.Add(newAttr); await _auditService.LogAsync( @@ -1690,6 +1697,7 @@ public sealed class BundleImporter : IBundleImporter newAttr.DataType, newAttr.ElementDataType, newAttr.IsLocked, + newAttr.LockedInDerived, newAttr.Description, newAttr.DataSourceReference, }, @@ -1740,7 +1748,8 @@ public sealed class BundleImporter : IBundleImporter current.PriorityLevel != alarmDto.PriorityLevel || current.TriggerType != alarmDto.TriggerType || !string.Equals(current.TriggerConfiguration, alarmDto.TriggerConfiguration, StringComparison.Ordinal) || - current.IsLocked != alarmDto.IsLocked; + current.IsLocked != alarmDto.IsLocked || + current.LockedInDerived != alarmDto.LockedInDerived; if (!changed) { // Always reset the script FK on Overwrite so the post-flush @@ -1762,6 +1771,7 @@ public sealed class BundleImporter : IBundleImporter current.TriggerType = alarmDto.TriggerType; current.TriggerConfiguration = alarmDto.TriggerConfiguration; current.IsLocked = alarmDto.IsLocked; + current.LockedInDerived = alarmDto.LockedInDerived; current.OnTriggerScriptId = null; // re-resolved post-flush. await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false); await _auditService.LogAsync( @@ -1779,6 +1789,7 @@ public sealed class BundleImporter : IBundleImporter current.TriggerType, current.TriggerConfiguration, current.IsLocked, + current.LockedInDerived, OnTriggerScriptName = alarmDto.OnTriggerScriptName, }, ct).ConfigureAwait(false); @@ -1792,6 +1803,7 @@ public sealed class BundleImporter : IBundleImporter TriggerType = alarmDto.TriggerType, TriggerConfiguration = alarmDto.TriggerConfiguration, IsLocked = alarmDto.IsLocked, + LockedInDerived = alarmDto.LockedInDerived, }; ex.Alarms.Add(newAlarm); await _auditService.LogAsync( @@ -1809,6 +1821,7 @@ public sealed class BundleImporter : IBundleImporter newAlarm.TriggerType, newAlarm.TriggerConfiguration, newAlarm.IsLocked, + newAlarm.LockedInDerived, OnTriggerScriptName = alarmDto.OnTriggerScriptName, }, ct).ConfigureAwait(false); @@ -1858,7 +1871,8 @@ public sealed class BundleImporter : IBundleImporter !string.Equals(current.ReturnDefinition, scriptDto.ReturnDefinition, StringComparison.Ordinal) || current.IsLocked != scriptDto.IsLocked || current.MinTimeBetweenRuns != scriptDto.MinTimeBetweenRuns || - current.ExecutionTimeoutSeconds != scriptDto.ExecutionTimeoutSeconds; + current.ExecutionTimeoutSeconds != scriptDto.ExecutionTimeoutSeconds || + current.LockedInDerived != scriptDto.LockedInDerived; if (!changed) continue; current.Code = scriptDto.Code; @@ -1869,6 +1883,7 @@ public sealed class BundleImporter : IBundleImporter current.IsLocked = scriptDto.IsLocked; current.MinTimeBetweenRuns = scriptDto.MinTimeBetweenRuns; current.ExecutionTimeoutSeconds = scriptDto.ExecutionTimeoutSeconds; + current.LockedInDerived = scriptDto.LockedInDerived; await _templateRepo.UpdateTemplateScriptAsync(current, ct).ConfigureAwait(false); await _auditService.LogAsync( user, @@ -1883,6 +1898,7 @@ public sealed class BundleImporter : IBundleImporter current.TriggerType, current.TriggerConfiguration, current.IsLocked, + current.LockedInDerived, }, ct).ConfigureAwait(false); } @@ -1897,6 +1913,7 @@ public sealed class BundleImporter : IBundleImporter IsLocked = scriptDto.IsLocked, MinTimeBetweenRuns = scriptDto.MinTimeBetweenRuns, ExecutionTimeoutSeconds = scriptDto.ExecutionTimeoutSeconds, + LockedInDerived = scriptDto.LockedInDerived, }; ex.Scripts.Add(newScript); await _auditService.LogAsync( @@ -1912,6 +1929,7 @@ public sealed class BundleImporter : IBundleImporter newScript.TriggerType, newScript.TriggerConfiguration, newScript.IsLocked, + newScript.LockedInDerived, }, ct).ConfigureAwait(false); } diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs index de2eb98f..80e35c21 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs @@ -135,7 +135,13 @@ public sealed record TemplateAttributeDto( bool IsLocked, string? Description, string? DataSourceReference, - DataType? ElementDataType = null); + DataType? ElementDataType = null, + // Whether this member is locked against override in derived templates. + // Additive trailing field; false on bundles written before it existed + // (dropped from JSON by WhenWritingNull? — no, bool is a value type, so it + // is always emitted; older bundles simply lack the property and deserialize + // to the default false). + bool LockedInDerived = false); public sealed record TemplateAlarmDto( string Name, @@ -144,7 +150,9 @@ public sealed record TemplateAlarmDto( AlarmTriggerType TriggerType, string? TriggerConfiguration, bool IsLocked, - string? OnTriggerScriptName); + string? OnTriggerScriptName, + // Derived-lock flag; additive trailing field (see TemplateAttributeDto). + bool LockedInDerived = false); public sealed record TemplateScriptDto( string Name, @@ -157,7 +165,9 @@ public sealed record TemplateScriptDto( TimeSpan? MinTimeBetweenRuns, // Per-script execution timeout (seconds). Additive trailing field; // null on bundles written before this field existed. - int? ExecutionTimeoutSeconds = null); + int? ExecutionTimeoutSeconds = null, + // Derived-lock flag; additive trailing field (see TemplateAttributeDto). + bool LockedInDerived = false); public sealed record TemplateCompositionDto( string InstanceName, diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs index 28e4a75f..49554f63 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs @@ -61,7 +61,8 @@ public sealed class EntitySerializer IsLocked: a.IsLocked, Description: a.Description, DataSourceReference: a.DataSourceReference, - ElementDataType: a.ElementDataType)).ToList(), + ElementDataType: a.ElementDataType, + LockedInDerived: a.LockedInDerived)).ToList(), Alarms: t.Alarms.Select(a => new TemplateAlarmDto( Name: a.Name, Description: a.Description, @@ -75,7 +76,8 @@ public sealed class EntitySerializer // resolve in this aggregate (e.g. corrupt/orphan row), the // name comes through as null and the importer leaves the // FK null on the imported alarm. - OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null)).ToList(), + OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null, + LockedInDerived: a.LockedInDerived)).ToList(), Scripts: t.Scripts.Select(s => new TemplateScriptDto( Name: s.Name, Code: s.Code, @@ -85,7 +87,8 @@ public sealed class EntitySerializer ReturnDefinition: s.ReturnDefinition, IsLocked: s.IsLocked, MinTimeBetweenRuns: s.MinTimeBetweenRuns, - ExecutionTimeoutSeconds: s.ExecutionTimeoutSeconds)).ToList(), + ExecutionTimeoutSeconds: s.ExecutionTimeoutSeconds, + LockedInDerived: s.LockedInDerived)).ToList(), Compositions: t.Compositions.Select(c => new TemplateCompositionDto( InstanceName: c.InstanceName, ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList()); @@ -318,6 +321,7 @@ public sealed class EntitySerializer Description = a.Description, DataSourceReference = a.DataSourceReference, ElementDataType = a.ElementDataType, + LockedInDerived = a.LockedInDerived, }); } foreach (var al in dto.Alarms) @@ -330,6 +334,7 @@ public sealed class EntitySerializer TriggerType = al.TriggerType, TriggerConfiguration = al.TriggerConfiguration, IsLocked = al.IsLocked, + LockedInDerived = al.LockedInDerived, }); } foreach (var s in dto.Scripts) @@ -344,6 +349,7 @@ public sealed class EntitySerializer IsLocked = s.IsLocked, MinTimeBetweenRuns = s.MinTimeBetweenRuns, ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds, + LockedInDerived = s.LockedInDerived, }); } return t; diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs index 0bbc4ab3..b9b7bffc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/TemplateScriptFidelityTests.cs @@ -197,4 +197,61 @@ public sealed class TemplateScriptFidelityTests : IDisposable a.Action == "TemplateScriptUpdated" && a.EntityName == "Pump.cadence")); } } + + [Fact] + public async Task Add_preserves_LockedInDerived_on_attribute_alarm_script() + { + // #05-T4: LockedInDerived must survive export→import on all three child kinds. + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = new Template("Pump") { Description = "fresh" }; + t.Attributes.Add(new TemplateAttribute("Pressure") + { + DataType = DataType.Double, + LockedInDerived = true, + }); + t.Alarms.Add(new TemplateAlarm("High") + { + TriggerType = AlarmTriggerType.RangeViolation, + LockedInDerived = true, + }); + t.Scripts.Add(new TemplateScript("cadence", "return 1;") + { + LockedInDerived = true, + }); + ctx.Templates.Add(t); + await ctx.SaveChangesAsync(); + } + var sessionId = await ExportAndLoadAsync(); + + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + ctx.Templates.RemoveRange(ctx.Templates); + await ctx.SaveChangesAsync(); + } + + 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"); + } + + await using (var scope = _provider.CreateAsyncScope()) + { + var ctx = scope.ServiceProvider.GetRequiredService(); + var t = await ctx.Templates + .Where(t => t.Name == "Pump") + .Include(t => t.Attributes) + .Include(t => t.Alarms) + .Include(t => t.Scripts) + .SingleAsync(); + Assert.True(t.Attributes.Single(a => a.Name == "Pressure").LockedInDerived); + Assert.True(t.Alarms.Single(a => a.Name == "High").LockedInDerived); + Assert.True(t.Scripts.Single(s => s.Name == "cadence").LockedInDerived); + } + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs index a1250d60..5bc8a7ac 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs @@ -5,6 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; @@ -158,6 +159,99 @@ public sealed class EntitySerializerTests Assert.Equal("MotorA", rtComp.InstanceName); } + [Fact] + public void Roundtrip_template_preserves_LockedInDerived_on_attribute_alarm_script() + { + var basic = new Template("Basic") { Id = 1 }; + basic.Attributes.Add(new TemplateAttribute("Pressure") + { + Id = 1, + TemplateId = 1, + DataType = DataType.Double, + LockedInDerived = true, + }); + basic.Alarms.Add(new TemplateAlarm("High") + { + Id = 1, + TemplateId = 1, + TriggerType = AlarmTriggerType.RangeViolation, + LockedInDerived = true, + }); + basic.Scripts.Add(new TemplateScript("OnUpdate", "return 1;") + { + Id = 1, + TemplateId = 1, + LockedInDerived = true, + }); + + var aggregate = MakeEmptyAggregate() with { Templates = new[] { basic } }; + + var sut = new EntitySerializer(); + var dto = sut.ToBundleContent(aggregate); + + // Export carries the flag on all three child DTOs. + var dtoBasic = Assert.Single(dto.Templates); + Assert.True(Assert.Single(dtoBasic.Attributes).LockedInDerived); + Assert.True(Assert.Single(dtoBasic.Alarms).LockedInDerived); + Assert.True(Assert.Single(dtoBasic.Scripts).LockedInDerived); + + // FromBundleContent restores it onto the entities. + var rtBasic = Assert.Single(sut.FromBundleContent(dto).Templates); + Assert.True(Assert.Single(rtBasic.Attributes).LockedInDerived); + Assert.True(Assert.Single(rtBasic.Alarms).LockedInDerived); + Assert.True(Assert.Single(rtBasic.Scripts).LockedInDerived); + } + + [Fact] + public void OldForm_bundle_json_without_LockedInDerived_deserializes_to_false() + { + // A bundle written before LockedInDerived existed: the property is absent + // from every template-child object. It must deserialize to false, not throw. + // PascalCase keys + string enum names — the on-wire form BundleJsonOptions + // produces (no naming policy, JsonStringEnumConverter, case-sensitive). All + // required collections present; LockedInDerived absent on every child. + const string json = """ + { + "TemplateFolders": [], + "Templates": [ + { + "Name": "Legacy", + "FolderName": null, + "BaseTemplateName": null, + "Description": null, + "Attributes": [ + { "Name": "A", "Value": "1", "DataType": "Double", "IsLocked": false, + "Description": null, "DataSourceReference": null } + ], + "Alarms": [ + { "Name": "Alm", "Description": null, "PriorityLevel": 1, + "TriggerType": "RangeViolation", "TriggerConfiguration": null, "IsLocked": false, + "OnTriggerScriptName": null } + ], + "Scripts": [ + { "Name": "S", "Code": "return 1;", "TriggerType": null, + "TriggerConfiguration": null, "ParameterDefinitions": null, + "ReturnDefinition": null, "IsLocked": false } + ], + "Compositions": [] + } + ], + "SharedScripts": [], + "ExternalSystems": [], + "DatabaseConnections": [], + "NotificationLists": [], + "SmtpConfigs": [], + "ApiMethods": [] + } + """; + + var content = JsonSerializer.Deserialize(json, BundleJsonOptions.Default)!; + var template = Assert.Single(content.Templates); + Assert.False(Assert.Single(template.Attributes).LockedInDerived); + Assert.False(Assert.Single(template.Alarms).LockedInDerived); + Assert.False(Assert.Single(template.Scripts).LockedInDerived); + } + [Fact] public void Roundtrip_template_folder_preserves_hierarchy() {