c29b99c63b
TemplateService.ReconcileDescendantsAsync - run after every base member add/update/delete, template and native-alarm-source alike - loaded the whole tracked template graph (every template, member and script body) on each call, which after #66 was the dominant cost of those paths. It now calls the new ITemplateEngineRepository.GetTemplateReconcileClosureAsync: the same tracked, fully-included shape as GetAllTemplatesAsync, restricted to the edited template's derived subtree plus its own inheritance chain. The reconcile is inheritance-only (TemplateInheritanceResolver never follows compositions) and every descendant's chain runs through the edited template, so that set is everything it reads. The walk (Commons TemplateReconcileClosure, the descendant-direction twin of #66's TemplateAnalysisClosure) loads one children batch per subtree depth and one batch per ancestor level, terminates on parent cycles and stops at dangling parents. A template with no children - the common case - costs one empty probe and loads nothing. Evidence: - TemplateReconcileClosureTests: for every template of a graph with a deep subtree, an ancestor above the edited template, a composed module, look-alike unrelated trees, a parent cycle and a dangling parent, the closure-backed reconcile issues exactly the writes (add/update/delete, audit, save) of the old whole-graph reconcile, and the resolver answers identically for every descendant. Fails with the old service (whole-graph load wired to throw) and with the ancestor walk removed (mutation-checked). - TemplateReconcileClosureRepositoryTests (SQLite, recorded SQL): 5 templates / 10 attributes instead of 47 / 94, tracked with script bodies; a leaf costs one probe query and tracks nothing. - TemplateServiceTests: attribute add + update propagate two levels down with GetAllTemplatesAsync wired to throw; existing propagation tests now run against the closure via ServeReconcileClosureFromFullGraph. ResyncInheritedMembersAsync (operator action) still reads the whole graph. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
120 lines
5.4 KiB
C#
120 lines
5.4 KiB
C#
using System.Text.Json;
|
|
using Moq;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 2026-10-21 08/G1: the configuration audit (indefinitely retained) must
|
|
/// record a projected summary of a template — ids, names, member counts, changed
|
|
/// fields — never the whole tracked graph with every script body.
|
|
/// </summary>
|
|
public class TemplateAuditShapeTests
|
|
{
|
|
private const string Body = "return \"BODY_SENTINEL_7f3a\";";
|
|
|
|
private readonly Mock<ITemplateEngineRepository> _repoMock = new();
|
|
private readonly Mock<IAuditService> _auditMock = new();
|
|
private readonly List<object?> _afterStates = [];
|
|
|
|
/// <summary>Captures every audit after-state the service writes.</summary>
|
|
public TemplateAuditShapeTests()
|
|
{
|
|
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
|
_repoMock.ServeClosureFromFullGraph();
|
|
_repoMock.ServeReconcileClosureFromFullGraph();
|
|
_auditMock
|
|
.Setup(a => a.LogAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
|
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<object?>(), It.IsAny<CancellationToken>()))
|
|
.Callback<string, string, string, string, string, object?, CancellationToken>(
|
|
(_, _, _, _, _, after, _) => _afterStates.Add(after))
|
|
.Returns(Task.CompletedTask);
|
|
}
|
|
|
|
private Template SeedTemplateWithScripts()
|
|
{
|
|
var template = new Template("Pump") { Id = 1 };
|
|
template.Scripts.Add(new TemplateScript("Calc", Body) { Id = 10, TemplateId = 1 });
|
|
template.Scripts.Add(new TemplateScript("Calc2", Body + "//2") { Id = 11, TemplateId = 1 });
|
|
template.Attributes.Add(new TemplateAttribute("Temp") { Id = 20, TemplateId = 1 });
|
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<Template> { template });
|
|
return template;
|
|
}
|
|
|
|
private string SingleAfterStateJson()
|
|
{
|
|
var after = Assert.Single(_afterStates);
|
|
return JsonSerializer.Serialize(after);
|
|
}
|
|
|
|
/// <summary>Verifies that a template rename audits a summary without script bodies.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task UpdateTemplate_AuditsSummary_WithoutScriptBodies()
|
|
{
|
|
SeedTemplateWithScripts();
|
|
var sut = new TemplateService(_repoMock.Object, _auditMock.Object);
|
|
|
|
var result = await sut.UpdateTemplateAsync(1, "Pump2", null, null, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
var json = SingleAfterStateJson();
|
|
Assert.DoesNotContain("BODY_SENTINEL", json);
|
|
using var doc = JsonDocument.Parse(json);
|
|
Assert.Equal("Pump2", doc.RootElement.GetProperty("Name").GetString());
|
|
Assert.Equal(2, doc.RootElement.GetProperty("ScriptCount").GetInt32());
|
|
Assert.Equal(1, doc.RootElement.GetProperty("AttributeCount").GetInt32());
|
|
Assert.Contains("Name", doc.RootElement.GetProperty("ChangedFields").EnumerateArray().Select(e => e.GetString()));
|
|
Assert.False(doc.RootElement.TryGetProperty("Scripts", out _));
|
|
}
|
|
|
|
/// <summary>Verifies that a template move audits a summary without script bodies.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task MoveTemplate_AuditsSummary_WithoutScriptBodies()
|
|
{
|
|
SeedTemplateWithScripts();
|
|
var sut = new TemplateService(_repoMock.Object, _auditMock.Object);
|
|
|
|
var result = await sut.MoveTemplateAsync(1, null, "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.DoesNotContain("BODY_SENTINEL", SingleAfterStateJson());
|
|
}
|
|
|
|
/// <summary>Verifies that a script add audits the script's metadata and body hash, not its body.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AddScript_AuditsBodyHash_NotBody()
|
|
{
|
|
SeedTemplateWithScripts();
|
|
var sut = new TemplateService(_repoMock.Object, _auditMock.Object);
|
|
|
|
var result = await sut.AddScriptAsync(1, new TemplateScript("Calc3", Body + "//3"), "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
var json = SingleAfterStateJson();
|
|
Assert.DoesNotContain("BODY_SENTINEL", json);
|
|
using var doc = JsonDocument.Parse(json);
|
|
Assert.Equal("Calc3", doc.RootElement.GetProperty("Name").GetString());
|
|
Assert.Equal(64, doc.RootElement.GetProperty("CodeSha256").GetString()!.Length);
|
|
}
|
|
|
|
/// <summary>Verifies that a shared-script create audits its body hash, not its body.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task CreateSharedScript_AuditsBodyHash_NotBody()
|
|
{
|
|
var sut = new SharedScriptService(_repoMock.Object, _auditMock.Object);
|
|
|
|
var result = await sut.CreateSharedScriptAsync("Helpers", Body, null, null, "admin");
|
|
|
|
Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
|
|
Assert.DoesNotContain("BODY_SENTINEL", SingleAfterStateJson());
|
|
}
|
|
}
|