792f16c2d5
Attribute/alarm/script add (collision check) and update (parent-lock lookup) used GetAllTemplatesForAnalysisAsync, materialising every template, attribute, alarm and script in the library per edit. They now call the new ITemplateEngineRepository.GetTemplateAnalysisClosureAsync: the same untracked, body-free analysis projection, restricted to the template's inheritance chain plus the composition closure of every chain member - exactly what TemplateResolver and CollisionDetector walk (a composed module's own parent chain is never climbed, so it is not loaded). Adds use the edited template's closure, updates the parent's. The walk (Commons TemplateAnalysisClosure) loads one batch per closure depth, requests each id once, terminates on cycles and skips dangling edges. Evidence: - TemplateAnalysisClosureTests: for every template of a graph with chains, nested + diamond compositions, a derived module, cycles, dangling edges and colliding unrelated templates, ResolveAllMembers / DetectCollisions / FindMemberByCanonicalName answer identically from the closure and the whole graph (mutation-checked). - TemplateAnalysisClosureRepositoryTests (SQLite, recorded SQL): 6 templates / 12 attributes loaded instead of 47 / 94, in 4 queries (one per depth), untracked, script bodies never selected. - TemplateServiceTests: two new tests with the whole-graph load wired to throw (fail on the old service); every existing add/update test now runs against the closure via AnalysisClosureMock. ReconcileDescendantsAsync still loads the tracked full graph; not in scope. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
119 lines
5.3 KiB
C#
119 lines
5.3 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();
|
|
_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());
|
|
}
|
|
}
|