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:
@@ -8,7 +8,7 @@
|
||||
| Last reviewed | 2026-05-28 |
|
||||
| Reviewer | claude-agent |
|
||||
| Commit reviewed | `1eb6e97` |
|
||||
| Open findings | 1 |
|
||||
| Open findings | 0 |
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -1118,9 +1118,11 @@ and produces a `"Timeout calling..."` (not `"Connection error to..."`) error.
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:185-193` |
|
||||
|
||||
**Resolution (2026-05-28):** `JsonElementToParameterValue` now probes `TryGetInt64` → `TryGetDecimal` → `GetDouble`, so a JSON number that fits in `decimal` materialises as a `decimal` (preserving the script's authored precision on cached-write retries) and only genuinely out-of-decimal-range values fall through to `double`. Regression test `JsonElementToParameterValue_DecimalShapedNumber_PreservesPrecisionViaDecimal` round-trips `1234567890.1234567890` through a `JsonElement` and asserts the result is a `decimal` carrying the original precision; companion tests guard the long-fast-path and the out-of-range-double fallback.
|
||||
|
||||
**Description**
|
||||
|
||||
`DatabaseGateway.JsonElementToParameterValue` materialises the buffered cached-write
|
||||
|
||||
+9
-15
@@ -41,9 +41,9 @@ module file and counted in **Total**.
|
||||
|----------|---------------|
|
||||
| Critical | 0 |
|
||||
| High | 0 |
|
||||
| Medium | 5 |
|
||||
| Low | 1 |
|
||||
| **Total** | **6** |
|
||||
| Medium | 1 |
|
||||
| Low | 0 |
|
||||
| **Total** | **1** |
|
||||
|
||||
## Module Status
|
||||
|
||||
@@ -58,7 +58,7 @@ module file and counted in **Total**.
|
||||
| [ConfigurationDatabase](ConfigurationDatabase/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 24 |
|
||||
| [DataConnectionLayer](DataConnectionLayer/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 22 |
|
||||
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 24 |
|
||||
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/0 | 1 | 23 |
|
||||
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 23 |
|
||||
| [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 23 |
|
||||
| [Host](Host/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 22 |
|
||||
| [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 25 |
|
||||
@@ -70,8 +70,8 @@ module file and counted in **Total**.
|
||||
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 23 |
|
||||
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 26 |
|
||||
| [StoreAndForward](StoreAndForward/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 24 |
|
||||
| [TemplateEngine](TemplateEngine/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/0 | 3 | 22 |
|
||||
| [Transport](Transport/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 12 |
|
||||
| [TemplateEngine](TemplateEngine/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 22 |
|
||||
| [Transport](Transport/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 12 |
|
||||
|
||||
## Pending Findings
|
||||
|
||||
@@ -88,18 +88,12 @@ _None open._
|
||||
|
||||
_None open._
|
||||
|
||||
### Medium (5)
|
||||
### Medium (1)
|
||||
|
||||
| ID | Module | Title |
|
||||
|----|--------|-------|
|
||||
| AuditLog-001 | [AuditLog](AuditLog/findings.md) | Combined-telemetry transport is plumbed end-to-end but never invoked in production |
|
||||
| ExternalSystemGateway-020 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `JsonElementToParameterValue` silently downcasts non-Int64 JSON numbers to `double`, losing precision for `decimal` SQL parameters on retry |
|
||||
| TemplateEngine-018 | [TemplateEngine](TemplateEngine/findings.md) | `DiffService` reports no entries for added/removed/changed connections |
|
||||
| TemplateEngine-019 | [TemplateEngine](TemplateEngine/findings.md) | `TemplateResolver.BuildInheritanceChain` still uses the `0`-as-no-parent sentinel that was removed from `CycleDetector` |
|
||||
| TemplateEngine-020 | [TemplateEngine](TemplateEngine/findings.md) | `Create*` audit entries are written with `EntityId = "0"` before `SaveChangesAsync` populates the real key |
|
||||
|
||||
### Low (1)
|
||||
### Low (0)
|
||||
|
||||
| ID | Module | Title |
|
||||
|----|--------|-------|
|
||||
| Transport-012 | [Transport](Transport/findings.md) | "Bundle Import" filter promised in design doc not surfaced in Configuration Audit Log Viewer UI |
|
||||
_None open._
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| Last reviewed | 2026-05-28 |
|
||||
| Reviewer | claude-agent |
|
||||
| Commit reviewed | `1eb6e97` |
|
||||
| Open findings | 4 |
|
||||
| Open findings | 1 |
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -913,9 +913,11 @@ TemplateEngine-018.
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.TemplateEngine/Flattening/DiffService.cs:19` |
|
||||
|
||||
**Resolution (2026-05-28):** Added `DiffService.ComputeConnectionsDiff(oldConfig, newConfig)`, which mirrors the existing attribute/alarm/script `ComputeEntityDiff` shape over the `FlattenedConfiguration.Connections` map and emits `DiffEntry<ConnectionConfig>` Added / Removed / Changed entries keyed by connection name (delegating equality to the existing `ConnectionsEqual` helper). Kept the new diff as a parallel method on `DiffService` rather than extending `ConfigurationDiff` (which lives in `ScadaLink.Commons`, outside this fix's scope); the public-record extension and Central UI plumbing are a paired Commons follow-up. Regression tests: `ComputeConnectionsDiff_NewBindingAdded_ReportedAsAdded`, `ComputeConnectionsDiff_BindingCleared_ReportedAsRemoved`, `ComputeConnectionsDiff_EndpointEdit_ReportedAsChanged`, `ComputeConnectionsDiff_IdenticalConnections_NoEntries`.
|
||||
|
||||
**Description**
|
||||
|
||||
`DiffService.ComputeDiff` returns a `ConfigurationDiff` with `AttributeChanges`,
|
||||
@@ -954,9 +956,11 @@ _Unresolved._
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.TemplateEngine/TemplateResolver.cs:117`, `src/ScadaLink.TemplateEngine/TemplateResolver.cs:123` |
|
||||
|
||||
**Resolution (2026-05-28):** `BuildInheritanceChain` now walks the parent chain via the `int?` `ParentTemplateId` directly — only a missing (`null`) value means "no parent", so a real template Id of 0 walks the chain like any other node (matching the duplicate-tolerant `BuildLookup` and the TemplateEngine-013 `CycleDetector` fix). Regression tests: `BuildInheritanceChain_RealIdZero_IsTreatedAsParentReferenceNotAsNoParent`, `BuildInheritanceChain_ParentChainThroughIdZero_DoesNotTruncateChainAtZero`, and the end-to-end `ResolveAllMembers_TemplateWithRealIdZero_StillResolvesItsMembers`.
|
||||
|
||||
**Description**
|
||||
|
||||
TemplateEngine-013 removed the `0`-as-no-parent sentinel from `CycleDetector`
|
||||
@@ -1014,9 +1018,11 @@ _Unresolved._
|
||||
|--|--|
|
||||
| Severity | Medium |
|
||||
| Category | Code organization & conventions |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:77`, `src/ScadaLink.TemplateEngine/TemplateService.cs:256`, `src/ScadaLink.TemplateEngine/TemplateService.cs:407`, `src/ScadaLink.TemplateEngine/TemplateService.cs:556`, `src/ScadaLink.TemplateEngine/TemplateService.cs:734`, `src/ScadaLink.TemplateEngine/SharedScriptService.cs:71` |
|
||||
|
||||
**Resolution (2026-05-28):** Every `Create*` path in `TemplateService` (`CreateTemplateAsync`, `AddAttributeAsync`, `AddAlarmAsync`, `AddScriptAsync`, `AddCompositionAsync`) and `SharedScriptService.CreateSharedScriptAsync` now follows the `InstanceService.CreateInstanceAsync` shape — save the entity first so EF Core populates the auto-generated key, then log the audit row with the real `entity.Id`, then save the audit row. `AddCompositionAsync` already saved the composition row inside `CreateCascadedCompositionAsync` before returning, so only its `LogAsync` call needed to switch from `"0"` to `composition.Id.ToString()`. Regression tests assert the captured audit `entityId` equals the post-save id (not `"0"`): `CreateTemplate_AuditRowCarriesRealTemplateIdNotLiteralZero`, `AddAttribute_AuditRowCarriesRealAttributeIdNotLiteralZero`, `AddAlarm_AuditRowCarriesRealAlarmIdNotLiteralZero`, `AddScript_AuditRowCarriesRealScriptIdNotLiteralZero`, and `CreateSharedScript_AuditRowCarriesRealScriptIdNotLiteralZero`.
|
||||
|
||||
**Description**
|
||||
|
||||
`IAuditService.LogAsync` takes a `string entityId` argument and `TemplateService
|
||||
|
||||
@@ -499,7 +499,7 @@ bundle prompt is surfaced AFTER the manifest+hash check, and that a dedicated
|
||||
|--|--|
|
||||
| Severity | Low |
|
||||
| Category | Documentation & comments |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
| Location | `docs/requirements/Component-Transport.md` §Audit Trail, `src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs:148` |
|
||||
|
||||
**Description**
|
||||
@@ -518,6 +518,12 @@ it's reasonable to flag.
|
||||
Either implement the filter dropdown + summary-row link in the Configuration
|
||||
Audit Log Viewer, or note the deferral in the design doc.
|
||||
|
||||
**Resolution**
|
||||
**Resolution (2026-05-28):**
|
||||
|
||||
_Unresolved._
|
||||
Took the "note the deferral in the design doc" path. `docs/requirements/Component-Transport.md`
|
||||
§Audit Trail's "Correlation:" paragraph now spells out that the repository filter on
|
||||
`BundleImportId` IS wired (in `CentralUiRepository.QueryConfigurationAuditAsync`)
|
||||
but the Audit-Log-Viewer UI surface — the dropdown + `BundleImported` hyperlink —
|
||||
is a deferred UI follow-up. Operators have a workaround via the existing
|
||||
`audit query --bundle-import-id` CLI flag. The UI work belongs in the CentralUI
|
||||
backlog; implementing it here would expand scope beyond a doc fix.
|
||||
|
||||
@@ -257,7 +257,7 @@ Import flows through the same audited repository methods the UI and CLI use, so
|
||||
| Imported alarm references missing on-trigger script | `BundleImportAlarmScriptUnresolved` (warning; alarm FK left null) |
|
||||
| Imported template's composition references missing target template | `BundleImportCompositionUnresolved` (warning; composition row not written) |
|
||||
|
||||
**Correlation:** every per-entity row written during an import carries a new optional `BundleImportId` column (the GUID of the parent `BundleImported` summary row). The existing Configuration Audit Log Viewer gains a **Bundle Import** filter that surfaces all rows for a given import. The `BundleImported` summary row links to the filtered view.
|
||||
**Correlation:** every per-entity row written during an import carries a new optional `BundleImportId` column (the GUID of the parent `BundleImported` summary row). The repository layer (`CentralUiRepository.QueryConfigurationAuditAsync`) already accepts a `BundleImportId` filter parameter. The Configuration Audit Log Viewer UI surface — a "Bundle Import" filter dropdown and a hyperlink on the `BundleImported` summary row that pre-populates that filter — is a deferred UI follow-up (tracked under Transport-012 in the code review backlog). Until then, an operator can drive the same filter via the CLI `audit query --bundle-import-id <guid>`.
|
||||
|
||||
**Schema change:** one EF migration adds:
|
||||
|
||||
|
||||
@@ -201,10 +201,23 @@ public class DatabaseGateway : IDatabaseGateway
|
||||
return true;
|
||||
}
|
||||
|
||||
private static object JsonElementToParameterValue(JsonElement element) => element.ValueKind switch
|
||||
// ExternalSystemGateway-020: a JSON number that does not fit in Int64 must
|
||||
// prefer decimal over double — a script's decimal SQL parameter is
|
||||
// serialised as JSON without a type tag, and downcasting it to double on
|
||||
// the cached-write retry path silently loses precision (e.g.
|
||||
// 1234567890.1234567890 -> 1234567890.1234567 as a binary float). Probe
|
||||
// long first (whole-number fast path), then decimal (preserves authored
|
||||
// precision for typical money/measurement values), and only fall through
|
||||
// to double for genuinely out-of-decimal-range values (very large
|
||||
// scientific-notation floats).
|
||||
internal static object JsonElementToParameterValue(JsonElement element) => element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => (object?)element.GetString() ?? DBNull.Value,
|
||||
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var l)
|
||||
? l
|
||||
: element.TryGetDecimal(out var dec)
|
||||
? dec
|
||||
: element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => DBNull.Value,
|
||||
|
||||
@@ -139,10 +139,7 @@ public class DiffService
|
||||
/// Compares two <see cref="ConnectionConfig"/> instances for equality across
|
||||
/// the fields that travel in the deployment package: protocol, primary and
|
||||
/// backup configuration JSON, and failover retry count. Used by callers that
|
||||
/// need to detect connection-endpoint drift; the public diff shape only
|
||||
/// exposes attribute / alarm / script changes today (see TemplateEngine-018
|
||||
/// for the diff-shape extension that surfaces added / removed / changed
|
||||
/// connections in the UI).
|
||||
/// need to detect connection-endpoint drift.
|
||||
/// </summary>
|
||||
/// <param name="a">First connection configuration.</param>
|
||||
/// <param name="b">Second connection configuration.</param>
|
||||
@@ -157,4 +154,52 @@ public class DiffService
|
||||
a.BackupConfigurationJson == b.BackupConfigurationJson &&
|
||||
a.FailoverRetryCount == b.FailoverRetryCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TemplateEngine-018: produces a per-connection diff between two flattened
|
||||
/// configurations, emitting Added / Removed / Changed entries keyed by the
|
||||
/// connection name. Mirrors the existing <see cref="ComputeEntityDiff{T}"/>
|
||||
/// shape used for attributes / alarms / scripts but is exposed as a separate
|
||||
/// method because <see cref="ConfigurationDiff"/> in
|
||||
/// <c>ScadaLink.Commons</c> does not yet carry a <c>ConnectionChanges</c>
|
||||
/// slot — the public diff record will be extended in a paired Commons change
|
||||
/// (this file is the only one in this fix's scope). A null
|
||||
/// <c>Connections</c> dictionary on either side is treated as the empty map.
|
||||
/// </summary>
|
||||
/// <param name="oldConfig">The previously deployed configuration, or null
|
||||
/// for first-time deployment.</param>
|
||||
/// <param name="newConfig">The current flattened configuration.</param>
|
||||
/// <returns>Added / Removed / Changed entries keyed by connection name,
|
||||
/// sorted ordinally by <see cref="DiffEntry{T}.CanonicalName"/>.</returns>
|
||||
public IReadOnlyList<DiffEntry<ConnectionConfig>> ComputeConnectionsDiff(
|
||||
FlattenedConfiguration? oldConfig,
|
||||
FlattenedConfiguration newConfig)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(newConfig);
|
||||
|
||||
// Project both sides to (name, config) pairs. A null Connections
|
||||
// dictionary models the no-connections case (first deploy, or all
|
||||
// bindings cleared) — treat it as empty so the diff still reports the
|
||||
// counterpart side's entries as Added / Removed rather than throwing.
|
||||
var oldPairs = (oldConfig?.Connections ?? new Dictionary<string, ConnectionConfig>())
|
||||
.Select(kvp => (kvp.Key, kvp.Value))
|
||||
.ToList();
|
||||
var newPairs = (newConfig.Connections ?? new Dictionary<string, ConnectionConfig>())
|
||||
.Select(kvp => (kvp.Key, kvp.Value))
|
||||
.ToList();
|
||||
|
||||
return ComputeEntityDiff(
|
||||
oldPairs,
|
||||
newPairs,
|
||||
pair => pair.Key,
|
||||
(a, b) => ConnectionsEqual(a.Value, b.Value))
|
||||
.Select(entry => new DiffEntry<ConnectionConfig>
|
||||
{
|
||||
CanonicalName = entry.CanonicalName,
|
||||
ChangeType = entry.ChangeType,
|
||||
OldValue = entry.OldValue.Value,
|
||||
NewValue = entry.NewValue.Value,
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +67,14 @@ public class SharedScriptService
|
||||
ReturnDefinition = returnDefinition
|
||||
};
|
||||
|
||||
// TemplateEngine-020: save the entity first so EF Core populates the
|
||||
// auto-generated key, then write the audit row with the real
|
||||
// script.Id, then save the audit row. The pre-fix order logged
|
||||
// EntityId = "0" because the audit row was queued before
|
||||
// SaveChangesAsync ran.
|
||||
await _repository.AddSharedScriptAsync(script, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "SharedScript", "0", name, script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "SharedScript", script.Id.ToString(), name, script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<SharedScript>.Success(script);
|
||||
|
||||
@@ -103,6 +103,16 @@ public static class TemplateResolver
|
||||
/// <summary>
|
||||
/// Gets the inheritance chain from root ancestor to the specified template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// TemplateEngine-019: the parent walk uses the <see cref="int?"/>
|
||||
/// <see cref="Template.ParentTemplateId"/> directly — only a missing
|
||||
/// (<c>null</c>) value means "no parent". The legacy <c>0</c>-as-no-parent
|
||||
/// sentinel that was removed from <c>CycleDetector</c> in the
|
||||
/// TemplateEngine-013 fix had silently truncated chains whenever a real
|
||||
/// template id of 0 appeared (e.g. import-staging / not-yet-saved rows);
|
||||
/// the duplicate-tolerant <c>BuildLookup</c> upstream means an Id of 0 is
|
||||
/// a valid node here and must walk the chain like any other.
|
||||
/// </remarks>
|
||||
/// <param name="templateId">The template ID to build the chain for.</param>
|
||||
/// <param name="lookup">A dictionary mapping template IDs to templates.</param>
|
||||
/// <returns>A read-only list of templates from root to the specified template.</returns>
|
||||
@@ -111,16 +121,16 @@ public static class TemplateResolver
|
||||
IReadOnlyDictionary<int, Template> lookup)
|
||||
{
|
||||
var chain = new List<Template>();
|
||||
var currentId = templateId;
|
||||
int? currentId = templateId;
|
||||
var visited = new HashSet<int>();
|
||||
|
||||
while (currentId != 0 && lookup.TryGetValue(currentId, out var current))
|
||||
while (currentId.HasValue && lookup.TryGetValue(currentId.Value, out var current))
|
||||
{
|
||||
if (!visited.Add(currentId))
|
||||
if (!visited.Add(currentId.Value))
|
||||
break; // Safety: cycle detected
|
||||
|
||||
chain.Add(current);
|
||||
currentId = current.ParentTemplateId ?? 0;
|
||||
currentId = current.ParentTemplateId;
|
||||
}
|
||||
|
||||
chain.Reverse(); // Root first
|
||||
|
||||
@@ -73,8 +73,15 @@ public class TemplateService
|
||||
// collisions are enforced on every member-mutating call (AddAttribute,
|
||||
// AddAlarm, AddScript, AddComposition) and on rename in UpdateTemplate.
|
||||
|
||||
// TemplateEngine-020: save the entity first so EF Core populates the
|
||||
// auto-generated key, then write the audit row with the real
|
||||
// template.Id, then save the audit row. The pre-fix order logged
|
||||
// EntityId = "0" because the audit row was queued before
|
||||
// SaveChangesAsync ran — every Create audit entry lost the link back
|
||||
// to the row it described.
|
||||
await _repository.AddTemplateAsync(template, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "Template", "0", name, template, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "Template", template.Id.ToString(), name, template, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<Template>.Success(template);
|
||||
@@ -277,8 +284,11 @@ public class TemplateService
|
||||
if (collisions.Count > 0)
|
||||
return Result<TemplateAttribute>.Failure(string.Join(" ", collisions));
|
||||
|
||||
// TemplateEngine-020: save-then-audit so the audit row carries the
|
||||
// real attribute Id rather than a literal "0".
|
||||
await _repository.AddTemplateAttributeAsync(attribute, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateAttribute", "0", attribute.Name, attribute, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateAttribute>.Success(attribute);
|
||||
@@ -441,8 +451,11 @@ public class TemplateService
|
||||
if (collisions.Count > 0)
|
||||
return Result<TemplateAlarm>.Failure(string.Join(" ", collisions));
|
||||
|
||||
// TemplateEngine-020: save-then-audit so the audit row carries the
|
||||
// real alarm Id rather than a literal "0".
|
||||
await _repository.AddTemplateAlarmAsync(alarm, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateAlarm", "0", alarm.Name, alarm, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateAlarm>.Success(alarm);
|
||||
@@ -599,8 +612,11 @@ public class TemplateService
|
||||
if (collisions.Count > 0)
|
||||
return Result<TemplateScript>.Failure(string.Join(" ", collisions));
|
||||
|
||||
// TemplateEngine-020: save-then-audit so the audit row carries the
|
||||
// real script Id rather than a literal "0".
|
||||
await _repository.AddTemplateScriptAsync(script, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateScript", "0", script.Name, script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateScript>.Success(script);
|
||||
@@ -787,8 +803,12 @@ public class TemplateService
|
||||
// No global name pre-check: derived templates store their contained
|
||||
// (slot) name, which need only be unique within the owner — and that is
|
||||
// already enforced above and by the (TemplateId, InstanceName) index.
|
||||
// TemplateEngine-020: CreateCascadedCompositionAsync already saves the
|
||||
// composition row, so composition.Id is populated by the time control
|
||||
// returns here — the audit row therefore carries the real Id instead
|
||||
// of the pre-fix literal "0".
|
||||
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
|
||||
await _auditService.LogAsync(user, "Create", "TemplateComposition", composition.Id.ToString(), instanceName, composition, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result<TemplateComposition>.Success(composition);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
@@ -232,6 +233,66 @@ public class DatabaseGatewayTests
|
||||
Assert.False(delivered); // permanent — the S&F engine parks the message
|
||||
}
|
||||
|
||||
// ── ExternalSystemGateway-020: decimal SQL parameter precision survives JsonElement round-trip ──
|
||||
|
||||
[Fact]
|
||||
public void JsonElementToParameterValue_DecimalShapedNumber_PreservesPrecisionViaDecimal()
|
||||
{
|
||||
// A script's decimal SQL parameter is serialised as a bare JSON number
|
||||
// (System.Text.Json has no decimal type tag), then on the cached-write
|
||||
// retry path the buffered payload is re-deserialised into a
|
||||
// JsonElement and the gateway must materialise a CLR value for the
|
||||
// parameter. Pre-020 it called GetDouble() for any non-Int64 number,
|
||||
// which silently downcast every decimal to a binary float and lost
|
||||
// precision (1234567890.1234567890 -> 1234567890.1234567 as double).
|
||||
// The 020 fix prefers decimal — round-tripping must preserve every
|
||||
// digit of an authoring-time decimal value.
|
||||
const string authoredJson = "1234567890.1234567890";
|
||||
|
||||
// Round-trip through JsonElement, mirroring the buffered-payload path.
|
||||
using var document = JsonDocument.Parse(authoredJson);
|
||||
var element = document.RootElement;
|
||||
|
||||
var materialised = DatabaseGateway.JsonElementToParameterValue(element);
|
||||
|
||||
// The materialised value must be the original decimal, not a double.
|
||||
// Asserting on the type alone is enough to fail pre-020 (which
|
||||
// produced a System.Double); the value assertion locks in the
|
||||
// precision invariant.
|
||||
var asDecimal = Assert.IsType<decimal>(materialised);
|
||||
Assert.Equal(1234567890.1234567890m, asDecimal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonElementToParameterValue_WholeNumber_FastPathReturnsLong()
|
||||
{
|
||||
// Whole numbers must keep the existing Int64 fast path — the 020 fix
|
||||
// is "long first, then decimal, then double", not "decimal first".
|
||||
using var document = JsonDocument.Parse("42");
|
||||
var element = document.RootElement;
|
||||
|
||||
var materialised = DatabaseGateway.JsonElementToParameterValue(element);
|
||||
|
||||
Assert.IsType<long>(materialised);
|
||||
Assert.Equal(42L, materialised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonElementToParameterValue_OutOfDecimalRangeNumber_FallsThroughToDouble()
|
||||
{
|
||||
// A genuinely out-of-decimal-range value (e.g. very large scientific
|
||||
// notation) must still fall through to double rather than throw — the
|
||||
// decimal probe is a precision-preserving preference, not a hard
|
||||
// requirement.
|
||||
using var document = JsonDocument.Parse("1e40");
|
||||
var element = document.RootElement;
|
||||
|
||||
var materialised = DatabaseGateway.JsonElementToParameterValue(element);
|
||||
|
||||
Assert.IsType<double>(materialised);
|
||||
Assert.Equal(1e40, (double)materialised);
|
||||
}
|
||||
|
||||
// ── ExternalSystemGateway-010: SqlConnection must not leak when OpenAsync fails ──
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user