fix(transport): warn on import of instance overrides targeting locked template members — inert rows no longer silent (plan R2-05 T7)

This commit is contained in:
Joseph Doherty
2026-07-13 10:04:43 -04:00
parent b02c15bbca
commit db623c1435
2 changed files with 273 additions and 2 deletions
@@ -1222,4 +1222,170 @@ public sealed class SiteInstanceImportTests : IDisposable
Assert.Equal(1, result.Added);
}
// ──────────────────────────────────────────────────────────────────────
// N4 (plan R2-05 T7): warn on overrides of LOCKED template members
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task Import_OverrideOnLockedAttribute_EmitsWarning_ImportStillSucceeds()
{
// Template attribute "SetPoint" IsLocked=true; instance carries an
// InstanceAttributeOverride for "SetPoint" — the flattener drops it, so the
// import surfaces an advisory warning but the row IS still written.
await SeedLockedMemberClosureAsync(lockAttribute: true);
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
await ctx.SaveChangesAsync();
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
Connections: Array.Empty<ConnectionMapping>());
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
Assert.Contains(result.Warnings, w =>
w.Contains("SetPoint", StringComparison.Ordinal)
&& w.Contains("locked", StringComparison.OrdinalIgnoreCase));
// Behaviour unchanged: the override row IS still written (bundle fidelity).
await using (var verify = _provider.CreateAsyncScope())
{
var ctx = verify.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.True(await ctx.InstanceAttributeOverrides.AnyAsync(o => o.AttributeName == "SetPoint"));
}
}
[Fact]
public async Task Preview_OverrideOnLockedNativeAlarmSource_ShowsWarningRow()
{
// Template native-alarm-source "NativeSrc" IsLocked=true; instance carries a
// NativeAlarmSourceOverride for it. The wizard shows a non-blocking Warning row.
await SeedLockedMemberClosureAsync(lockNativeSource: true);
var sessionId = await ExportAllSitesAndLoadAsync();
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
preview = await importer.PreviewAsync(sessionId);
}
Assert.Contains(preview.Items, b =>
b.Kind == ConflictKind.Warning && b.EntityType == "Instance"
&& b.BlockerReason!.Contains("locked", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Import_OverrideOnUnlockedAttribute_NoLockWarning()
{
// Same shape, but "SetPoint" is NOT locked — no lock warning must appear.
await SeedLockedMemberClosureAsync(lockAttribute: false);
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
await ctx.SaveChangesAsync();
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
Connections: Array.Empty<ConnectionMapping>());
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
Assert.DoesNotContain(result.Warnings, w =>
w.Contains("locked", StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Seeds a template ("Pump") + site ("plant-1") + instance ("Pump-01") whose
/// overrides target the given members; <paramref name="lockAttribute"/> /
/// <paramref name="lockNativeSource"/> toggle the template member's IsLocked flag.
/// The attribute override is always present; the native-source override (and its
/// connection) are seeded only when a native source is involved.
/// </summary>
private async Task SeedLockedMemberClosureAsync(bool lockAttribute = false, bool lockNativeSource = false)
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var template = new Template("Pump") { Description = "pump tpl" };
template.Attributes.Add(new TemplateAttribute("SetPoint") { Value = "0", IsLocked = lockAttribute });
if (lockNativeSource)
{
template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("NativeSrc")
{
ConnectionName = "OpcUaPrimary",
SourceReference = "ns=3;s=Pump.Alarm",
IsLocked = true,
});
}
ctx.Templates.Add(template);
var site = new Site("Plant 1", "plant-1")
{
NodeAAddress = "akka://site@10.0.0.1:2552",
NodeBAddress = "akka://site@10.0.0.2:2552",
GrpcNodeAAddress = "10.0.0.1:8083",
GrpcNodeBAddress = "10.0.0.2:8083",
};
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
DataConnection? conn = null;
if (lockNativeSource)
{
conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
FailoverRetryCount = 5,
};
ctx.DataConnections.Add(conn);
}
var instance = new Instance("Pump-01")
{
TemplateId = template.Id,
SiteId = site.Id,
State = InstanceState.Enabled,
};
instance.AttributeOverrides.Add(new InstanceAttributeOverride("SetPoint") { OverrideValue = "42" });
if (lockNativeSource)
{
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
{
ConnectionNameOverride = "OpcUaPrimary",
SourceReferenceOverride = "ns=3;s=Pump.Alarm2",
});
}
ctx.Instances.Add(instance);
await ctx.SaveChangesAsync();
}
}