fix(correctness): close Theme 10 — 5 data-integrity / serialisation findings

Final themed batch. 5 well-localised correctness fixes.

Serialisation precision:
- ESG-020: DatabaseGateway.JsonElementToParameterValue probes
  TryGetInt64 → TryGetDecimal → GetDouble, so a script's high-precision
  decimal SQL parameter survives the cached-write retry round-trip
  without silent precision loss. 3 new regression tests.

Template engine correctness:
- TE-018: DiffService gains ComputeConnectionsDiff over
  FlattenedConfiguration.Connections, mirroring the existing entity-diff
  shape and pairing with the Theme 1 TE-017 hash-coverage fix. A
  ConfigurationDiff record extension in Commons is flagged as a follow-up.
- TE-019: TemplateResolver.BuildInheritanceChain now walks via the
  int? ParentTemplateId directly — only null means "no parent". A real
  Id of 0 (the prior special-cased sentinel) now walks the chain like
  any other node, matching the TemplateEngine-013 CycleDetector fix.
  Regression of TE-013 closed.
- TE-020: All 5 Create* paths in TemplateService + SharedScriptService
  re-ordered to save-first → log-with-real-Id → save-audit (matching
  the InstanceService pattern). Create* audit rows no longer carry a
  literal "0" EntityId.

Doc deferral:
- Transport-012: Component-Transport.md §Audit Trail now spells out that
  the BundleImportId repository filter IS wired (in CentralUiRepository),
  but the Audit-Log-Viewer UI dropdown + summary-row hyperlink are a
  deferred CentralUI follow-up. CLI workaround documented
  (audit query --bundle-import-id).

11+ new regression tests (3 ESG, 4 DiffService, 3 TemplateResolver, 4
TemplateService, 1 SharedScriptService). Build clean; ESG 72/72,
TemplateEngine 324/324. README regenerated: 1 pending of 481 total.

Session-to-date: 135 of 136 originally-open Theme findings closed
across 10 themes in 10 commits.
This commit is contained in:
Joseph Doherty
2026-05-28 08:48:44 -04:00
parent 77cb0ad0e2
commit 11950b0a8e
15 changed files with 620 additions and 42 deletions
@@ -251,4 +251,122 @@ public class DiffServiceTests
Assert.False(DiffService.ConnectionsEqual(a, b));
}
// ── TemplateEngine-018: ComputeConnectionsDiff produces Added/Removed/Changed entries ──
[Fact]
public void ComputeConnectionsDiff_NewBindingAdded_ReportedAsAdded()
{
// First-time binding (or instance gains its first data-sourced
// attribute) — old config has no Connections map, new config does.
// The pre-018 diff shape silently dropped this so operators saw
// "no changes" when the deployment package was structurally larger.
var oldConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1" };
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host\"}",
FailoverRetryCount = 3,
}
}
};
var diff = _sut.ComputeConnectionsDiff(oldConfig, newConfig);
Assert.Single(diff);
Assert.Equal("plc1", diff[0].CanonicalName);
Assert.Equal(DiffChangeType.Added, diff[0].ChangeType);
Assert.Null(diff[0].OldValue);
Assert.NotNull(diff[0].NewValue);
Assert.Equal("OpcUa", diff[0].NewValue!.Protocol);
}
[Fact]
public void ComputeConnectionsDiff_BindingCleared_ReportedAsRemoved()
{
// Last data-sourced attribute removed — old config carried a
// connection, new config does not.
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{}" }
}
};
var newConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1" };
var diff = _sut.ComputeConnectionsDiff(oldConfig, newConfig);
Assert.Single(diff);
Assert.Equal("plc1", diff[0].CanonicalName);
Assert.Equal(DiffChangeType.Removed, diff[0].ChangeType);
Assert.NotNull(diff[0].OldValue);
Assert.Null(diff[0].NewValue);
}
[Fact]
public void ComputeConnectionsDiff_EndpointEdit_ReportedAsChanged()
{
// A connection-endpoint edit must surface as a Changed diff entry —
// the deployment package will ship a different ConnectionConfig and
// the operator-facing diff view must say so.
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-a:4840\"}",
FailoverRetryCount = 3,
}
}
};
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-b:4840\"}",
FailoverRetryCount = 3,
}
}
};
var diff = _sut.ComputeConnectionsDiff(oldConfig, newConfig);
Assert.Single(diff);
Assert.Equal("plc1", diff[0].CanonicalName);
Assert.Equal(DiffChangeType.Changed, diff[0].ChangeType);
Assert.Contains("host-a", diff[0].OldValue!.ConfigurationJson);
Assert.Contains("host-b", diff[0].NewValue!.ConfigurationJson);
}
[Fact]
public void ComputeConnectionsDiff_IdenticalConnections_NoEntries()
{
// Sanity check: an unchanged connection produces no diff entry, so
// ComputeConnectionsDiff stays quiet when nothing relevant has
// changed.
var connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{}" }
};
var oldConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Connections = connections };
var newConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Connections = connections };
var diff = _sut.ComputeConnectionsDiff(oldConfig, newConfig);
Assert.Empty(diff);
}
}
@@ -156,4 +156,48 @@ public class SharedScriptServiceTests
{
Assert.Null(SharedScriptService.ValidateSyntax(code));
}
// --- TemplateEngine-020 regression: audit row carries the real script Id ---
[Fact]
public async Task CreateSharedScript_AuditRowCarriesRealScriptIdNotLiteralZero()
{
// Pre-020: AddSharedScriptAsync → LogAsync("0", ...) → SaveChangesAsync.
// The audit row was queued with EntityId = "0" because EF Core had
// not yet populated the auto-generated key. Post-020: save first,
// then log with the real Id, then save the audit row.
_repoMock.Setup(r => r.GetSharedScriptByNameAsync("Helpers", It.IsAny<CancellationToken>()))
.ReturnsAsync((SharedScript?)null);
SharedScript? added = null;
_repoMock.Setup(r => r.AddSharedScriptAsync(It.IsAny<SharedScript>(), It.IsAny<CancellationToken>()))
.Callback<SharedScript, CancellationToken>((s, _) => added = s)
.Returns(Task.CompletedTask);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback<CancellationToken>(_ =>
{
if (added != null && added.Id == 0) added.Id = 314;
})
.ReturnsAsync(1);
string? auditedEntityId = null;
_auditMock.Setup(a => a.LogAsync(
It.IsAny<string>(),
"Create",
"SharedScript",
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, string, object?, CancellationToken>(
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
.Returns(Task.CompletedTask);
var result = await _service.CreateSharedScriptAsync(
"Helpers", "public static int Add(int a, int b) { return a + b; }", null, null, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("314", auditedEntityId);
Assert.NotEqual("0", auditedEntityId);
}
}
@@ -174,4 +174,80 @@ public class TemplateResolverTests
Assert.Equal("P", chain[1].Name);
Assert.Equal("C", chain[2].Name);
}
// ── TemplateEngine-019: a real Id of 0 must walk the inheritance chain ──
[Fact]
public void BuildInheritanceChain_RealIdZero_IsTreatedAsParentReferenceNotAsNoParent()
{
// TemplateEngine-013 removed the 0-as-no-parent sentinel from
// CycleDetector; the same fix had not propagated into the resolver,
// so seeding BuildInheritanceChain with templateId == 0 returned an
// empty chain even when a template with Id 0 existed (e.g. an
// import-staging row, or any in-memory test setup). Post-019: only a
// null ParentTemplateId means "no parent", and an Id of 0 walks the
// chain like any other node.
var orphaned = new Template("OrphanedId0") { Id = 0 };
orphaned.Attributes.Add(new TemplateAttribute("Speed")
{
Id = 1,
TemplateId = 0,
DataType = DataType.Float,
});
var lookup = new Dictionary<int, Template> { [0] = orphaned };
var chain = TemplateResolver.BuildInheritanceChain(0, lookup);
// Pre-019: while (currentId != 0 && ...) was false on the first
// iteration, so the chain was empty and the orphaned template's
// members were silently dropped from every flatten/resolve through
// it. Post-019: the orphan is the chain.
Assert.Single(chain);
Assert.Equal("OrphanedId0", chain[0].Name);
}
[Fact]
public void BuildInheritanceChain_ParentChainThroughIdZero_DoesNotTruncateChainAtZero()
{
// A template whose real parent has Id 0 must include the Id 0 parent
// in the chain. Pre-019: `current.ParentTemplateId ?? 0` paired with
// `currentId != 0` exited the loop as soon as the walk reached a real
// Id of 0 — silently truncating the inheritance contribution from the
// root template.
var parent = new Template("ParentWithIdZero") { Id = 0 };
parent.Attributes.Add(new TemplateAttribute("RootAttr")
{
Id = 1,
TemplateId = 0,
DataType = DataType.String,
});
var child = new Template("Child") { Id = 5, ParentTemplateId = 0 };
var lookup = new Dictionary<int, Template> { [0] = parent, [5] = child };
var chain = TemplateResolver.BuildInheritanceChain(5, lookup);
Assert.Equal(2, chain.Count);
Assert.Equal("ParentWithIdZero", chain[0].Name); // root first
Assert.Equal("Child", chain[1].Name);
}
[Fact]
public void ResolveAllMembers_TemplateWithRealIdZero_StillResolvesItsMembers()
{
// End-to-end: ResolveAllMembers piggybacks on BuildInheritanceChain,
// so the chain truncation regression also dropped every member of an
// Id-0 template from the resolved-member set. Lock this in too.
var orphan = new Template("OrphanedId0") { Id = 0 };
orphan.Attributes.Add(new TemplateAttribute("Speed")
{
Id = 1,
TemplateId = 0,
DataType = DataType.Float,
});
var members = TemplateResolver.ResolveAllMembers(0, new List<Template> { orphan });
Assert.Single(members);
Assert.Equal("Speed", members[0].CanonicalName);
}
}
@@ -34,7 +34,11 @@ public class TemplateServiceTests
Assert.True(result.IsSuccess);
Assert.Equal("Pump", result.Value.Name);
_repoMock.Verify(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
// TemplateEngine-020: SaveChangesAsync runs twice — once after the
// AddTemplateAsync to populate the auto-generated key (so the audit
// row can carry the real Id), and once after LogAsync to persist the
// audit row itself.
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Exactly(2));
_auditMock.Verify(a => a.LogAsync("admin", "Create", "Template", It.IsAny<string>(), "Pump", It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Once);
}
@@ -1195,4 +1199,177 @@ public class TemplateServiceTests
Assert.Contains("locked-in-derived", result.Error);
Assert.True(existing.LockedInDerived); // unchanged
}
// ========================================================================
// TemplateEngine-020: Create* audit rows must carry the real entity Id,
// not the literal "0" that EF Core leaves on the entity until
// SaveChangesAsync populates the auto-generated key.
// ========================================================================
[Fact]
public async Task CreateTemplate_AuditRowCarriesRealTemplateIdNotLiteralZero()
{
// Simulate EF Core's identity-key population: the repository's
// AddTemplateAsync stamps Id during SaveChangesAsync. The pre-020
// order (Add → LogAsync → Save) queued the audit row before the Id
// existed, so every Create audit entry persisted with EntityId = "0"
// and the audit trail lost its link back to the created template.
// Post-020: save first, then log with the real Id, then save the
// audit row.
Template? added = null;
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
.Callback<Template, CancellationToken>((t, _) => added = t)
.Returns(Task.CompletedTask);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback<CancellationToken>(_ =>
{
// First SaveChangesAsync after Add: assign the identity key.
if (added != null && added.Id == 0) added.Id = 42;
})
.ReturnsAsync(1);
string? auditedEntityId = null;
_auditMock.Setup(a => a.LogAsync(
It.IsAny<string>(),
"Create",
"Template",
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, string, object?, CancellationToken>(
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
.Returns(Task.CompletedTask);
var result = await _service.CreateTemplateAsync("Pump", null, null, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("42", auditedEntityId);
Assert.NotEqual("0", auditedEntityId);
}
[Fact]
public async Task AddAttribute_AuditRowCarriesRealAttributeIdNotLiteralZero()
{
var template = new Template("Pump") { Id = 1 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
TemplateAttribute? added = null;
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => added = a)
.Returns(Task.CompletedTask);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback<CancellationToken>(_ =>
{
if (added != null && added.Id == 0) added.Id = 77;
})
.ReturnsAsync(1);
string? auditedEntityId = null;
_auditMock.Setup(a => a.LogAsync(
It.IsAny<string>(),
"Create",
"TemplateAttribute",
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, string, object?, CancellationToken>(
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
.Returns(Task.CompletedTask);
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, Value = "0.0" };
var result = await _service.AddAttributeAsync(1, attr, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("77", auditedEntityId);
Assert.NotEqual("0", auditedEntityId);
}
[Fact]
public async Task AddAlarm_AuditRowCarriesRealAlarmIdNotLiteralZero()
{
var template = new Template("Pump") { Id = 1 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
TemplateAlarm? added = null;
_repoMock.Setup(r => r.AddTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAlarm, CancellationToken>((a, _) => added = a)
.Returns(Task.CompletedTask);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback<CancellationToken>(_ =>
{
if (added != null && added.Id == 0) added.Id = 99;
})
.ReturnsAsync(1);
string? auditedEntityId = null;
_auditMock.Setup(a => a.LogAsync(
It.IsAny<string>(),
"Create",
"TemplateAlarm",
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, string, object?, CancellationToken>(
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
.Returns(Task.CompletedTask);
var alarm = new TemplateAlarm("HighTemp")
{
PriorityLevel = 500,
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = """{"Max": 100}"""
};
var result = await _service.AddAlarmAsync(1, alarm, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("99", auditedEntityId);
Assert.NotEqual("0", auditedEntityId);
}
[Fact]
public async Task AddScript_AuditRowCarriesRealScriptIdNotLiteralZero()
{
var template = new Template("Pump") { Id = 1 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
TemplateScript? added = null;
_repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
.Callback<TemplateScript, CancellationToken>((s, _) => added = s)
.Returns(Task.CompletedTask);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback<CancellationToken>(_ =>
{
if (added != null && added.Id == 0) added.Id = 123;
})
.ReturnsAsync(1);
string? auditedEntityId = null;
_auditMock.Setup(a => a.LogAsync(
It.IsAny<string>(),
"Create",
"TemplateScript",
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, string, object?, CancellationToken>(
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
.Returns(Task.CompletedTask);
var script = new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" };
var result = await _service.AddScriptAsync(1, script, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("123", auditedEntityId);
Assert.NotEqual("0", auditedEntityId);
}
}