feat(centralui): execution-chain tree view on the Audit Log page
This commit is contained in:
@@ -429,6 +429,88 @@ public class AuditLogPageTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DrillInToExecutionChain_RendersTree_AndNodeClickFiltersGrid()
|
||||
{
|
||||
// Audit Log ParentExecutionId feature, Task 10: the drawer's "View
|
||||
// execution chain" action opens /audit/execution-tree?executionId={id}.
|
||||
// We seed a spawner row + a child row, open the child's drawer, click
|
||||
// "View execution chain", assert the tree renders BOTH executions, then
|
||||
// click the spawner node and assert the Audit Log grid filters to it.
|
||||
if (!await AuditDataSeeder.IsAvailableAsync())
|
||||
{
|
||||
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
|
||||
}
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/exec-chain-tree/{runId}/";
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
var childExecutionId = Guid.NewGuid();
|
||||
var spawnerEventId = Guid.NewGuid();
|
||||
var childEventId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// Spawner execution's own row.
|
||||
await AuditDataSeeder.InsertAuditEventAsync(
|
||||
eventId: spawnerEventId,
|
||||
occurredAtUtc: now,
|
||||
channel: "ApiInbound",
|
||||
kind: "InboundRequest",
|
||||
status: "Delivered",
|
||||
target: targetPrefix + "spawner",
|
||||
executionId: parentExecutionId,
|
||||
httpStatus: 200,
|
||||
durationMs: 7);
|
||||
|
||||
// Child (spawned) row — links to the spawner via ParentExecutionId.
|
||||
await AuditDataSeeder.InsertAuditEventAsync(
|
||||
eventId: childEventId,
|
||||
occurredAtUtc: now,
|
||||
channel: "ApiOutbound",
|
||||
kind: "ApiCall",
|
||||
status: "Delivered",
|
||||
target: targetPrefix + "child",
|
||||
executionId: childExecutionId,
|
||||
parentExecutionId: parentExecutionId,
|
||||
httpStatus: 200,
|
||||
durationMs: 13);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
// Open the child row's drawer via its ExecutionId filter.
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={childExecutionId}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var childRow = page.Locator($"[data-test='grid-row-{childEventId}']");
|
||||
await Assertions.Expect(childRow).ToBeVisibleAsync();
|
||||
await childRow.ClickAsync();
|
||||
|
||||
// "View execution chain" opens the tree view.
|
||||
var viewChain = page.Locator("[data-test='view-execution-chain']");
|
||||
await Assertions.Expect(viewChain).ToBeVisibleAsync();
|
||||
await viewChain.ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The tree page rendered both executions as nodes.
|
||||
Assert.Contains($"executionId={childExecutionId}", page.Url);
|
||||
await Assertions.Expect(page.Locator($"[data-test='tree-node-{parentExecutionId}']")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator($"[data-test='tree-node-{childExecutionId}']")).ToBeVisibleAsync();
|
||||
|
||||
// Clicking the spawner node's link filters the Audit Log to its rows.
|
||||
await page.Locator($"[data-test='tree-node-link-{parentExecutionId}']").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
Assert.Contains($"executionId={parentExecutionId}", page.Url);
|
||||
await Assertions.Expect(page.Locator($"[data-test='grid-row-{spawnerEventId}']")).ToBeVisibleAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
|
||||
{
|
||||
|
||||
@@ -303,6 +303,48 @@ public class AuditDrilldownDrawerTests : BunitContext
|
||||
Assert.Contains($"/audit/log?executionId={parent}", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drawer_NullExecutionId_HidesViewExecutionChainButton()
|
||||
{
|
||||
var ev = MakeEvent(executionId: null);
|
||||
|
||||
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||
.Add(c => c.Event, ev)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
Assert.DoesNotContain("data-test=\"view-execution-chain\"", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drawer_NonNullExecutionId_ShowsViewExecutionChainButton()
|
||||
{
|
||||
var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-9999-8888-7777-666666666666"));
|
||||
|
||||
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||
.Add(c => c.Event, ev)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ViewExecutionChain_Navigates_ToExecutionTreePage()
|
||||
{
|
||||
// The "View execution chain" action opens the tree view rooted at the
|
||||
// chain containing this row's ExecutionId.
|
||||
var exec = Guid.Parse("12345678-aaaa-bbbb-cccc-1234567890ab");
|
||||
var ev = MakeEvent(executionId: exec);
|
||||
|
||||
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||
.Add(c => c.Event, ev)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
cut.Find("[data-test=\"view-execution-chain\"]").Click();
|
||||
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
using Bunit;
|
||||
using ScadaLink.CentralUI.Components.Audit;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Components.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="ExecutionTree"/> (Audit Log ParentExecutionId
|
||||
/// feature, Task 10). The component takes the FLAT
|
||||
/// <see cref="ExecutionTreeNode"/> list the repository returns, assembles it
|
||||
/// into a tree by joining <see cref="ExecutionTreeNode.ParentExecutionId"/> to a
|
||||
/// parent node's <see cref="ExecutionTreeNode.ExecutionId"/>, and renders it
|
||||
/// recursively. Tests pin: single-node tree, multi-level assembly, stub-node
|
||||
/// presentation, the arrived-from highlight, node-click navigation, and
|
||||
/// cycle-safety (a corrupt flat list must not infinite-loop).
|
||||
/// </summary>
|
||||
public class ExecutionTreeTests : BunitContext
|
||||
{
|
||||
private static ExecutionTreeNode Node(
|
||||
Guid executionId,
|
||||
Guid? parentExecutionId,
|
||||
int rowCount = 2,
|
||||
string? site = "plant-a",
|
||||
string? instance = "boiler-3")
|
||||
=> new(
|
||||
executionId,
|
||||
parentExecutionId,
|
||||
rowCount,
|
||||
rowCount == 0 ? Array.Empty<string>() : new[] { "ApiOutbound" },
|
||||
rowCount == 0 ? Array.Empty<string>() : new[] { "Delivered" },
|
||||
rowCount == 0 ? null : site,
|
||||
rowCount == 0 ? null : instance,
|
||||
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
|
||||
|
||||
[Fact]
|
||||
public void SingleNode_RendersOneTreeNode()
|
||||
{
|
||||
var id = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var nodes = new List<ExecutionTreeNode> { Node(id, null) };
|
||||
|
||||
var cut = Render<ExecutionTree>(p => p
|
||||
.Add(c => c.Nodes, nodes)
|
||||
.Add(c => c.ArrivedFromExecutionId, id));
|
||||
|
||||
Assert.Contains($"data-test=\"tree-node-{id}\"", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiLevel_AssemblesTree_FromFlatList()
|
||||
{
|
||||
// root → child → grandchild — a deliberately shuffled flat list so the
|
||||
// component must reconstruct parent/child links rather than rely on
|
||||
// input ordering.
|
||||
var root = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000");
|
||||
var child = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000");
|
||||
var grandchild = Guid.Parse("cccccccc-0000-0000-0000-000000000000");
|
||||
var nodes = new List<ExecutionTreeNode>
|
||||
{
|
||||
Node(grandchild, child),
|
||||
Node(root, null),
|
||||
Node(child, root),
|
||||
};
|
||||
|
||||
var cut = Render<ExecutionTree>(p => p
|
||||
.Add(c => c.Nodes, nodes)
|
||||
.Add(c => c.ArrivedFromExecutionId, child));
|
||||
|
||||
// All three executions render as nodes.
|
||||
Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup);
|
||||
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
|
||||
Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
|
||||
|
||||
// The root must appear before the child, and the child before the
|
||||
// grandchild — recursive depth-first rendering preserves ancestry.
|
||||
var rootIdx = cut.Markup.IndexOf($"tree-node-{root}", StringComparison.Ordinal);
|
||||
var childIdx = cut.Markup.IndexOf($"tree-node-{child}", StringComparison.Ordinal);
|
||||
var grandIdx = cut.Markup.IndexOf($"tree-node-{grandchild}", StringComparison.Ordinal);
|
||||
Assert.True(rootIdx < childIdx, "root must render before child");
|
||||
Assert.True(childIdx < grandIdx, "child must render before grandchild");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StubNode_RendersStubMarker()
|
||||
{
|
||||
// A stub parent (RowCount = 0) referenced by a real child must still
|
||||
// render, visibly marked as "no audited actions".
|
||||
var stubParent = Guid.Parse("dddddddd-0000-0000-0000-000000000000");
|
||||
var child = Guid.Parse("eeeeeeee-0000-0000-0000-000000000000");
|
||||
var nodes = new List<ExecutionTreeNode>
|
||||
{
|
||||
Node(stubParent, null, rowCount: 0),
|
||||
Node(child, stubParent),
|
||||
};
|
||||
|
||||
var cut = Render<ExecutionTree>(p => p
|
||||
.Add(c => c.Nodes, nodes)
|
||||
.Add(c => c.ArrivedFromExecutionId, child));
|
||||
|
||||
Assert.Contains($"data-test=\"tree-node-{stubParent}\"", cut.Markup);
|
||||
Assert.Contains($"data-test=\"stub-node-{stubParent}\"", cut.Markup);
|
||||
Assert.Contains("no audited actions", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrivedFromNode_IsVisuallyHighlighted()
|
||||
{
|
||||
var root = Guid.Parse("aaaaaaaa-1111-1111-1111-111111111111");
|
||||
var child = Guid.Parse("bbbbbbbb-1111-1111-1111-111111111111");
|
||||
var nodes = new List<ExecutionTreeNode>
|
||||
{
|
||||
Node(root, null),
|
||||
Node(child, root),
|
||||
};
|
||||
|
||||
var cut = Render<ExecutionTree>(p => p
|
||||
.Add(c => c.Nodes, nodes)
|
||||
.Add(c => c.ArrivedFromExecutionId, child));
|
||||
|
||||
// The arrived-from node carries the highlight marker; a non-arrived
|
||||
// sibling does not.
|
||||
var arrived = cut.Find($"[data-test=\"tree-node-{child}\"]");
|
||||
Assert.Contains("execution-tree-node--current", arrived.GetAttribute("class"));
|
||||
|
||||
var other = cut.Find($"[data-test=\"tree-node-{root}\"]");
|
||||
Assert.DoesNotContain("execution-tree-node--current", other.GetAttribute("class") ?? string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeLink_PointsTo_AuditLogFilteredByThatExecution()
|
||||
{
|
||||
// Each node's id is a real <a href> deep link — clicking it lands on
|
||||
// the Audit Log filtered to that execution's rows. A genuine anchor
|
||||
// (rather than an @onclick navigate) keeps the link middle-click /
|
||||
// open-in-new-tab friendly, matching the rest of the Audit UI.
|
||||
var root = Guid.Parse("aaaaaaaa-2222-2222-2222-222222222222");
|
||||
var child = Guid.Parse("bbbbbbbb-2222-2222-2222-222222222222");
|
||||
var nodes = new List<ExecutionTreeNode>
|
||||
{
|
||||
Node(root, null),
|
||||
Node(child, root),
|
||||
};
|
||||
|
||||
var cut = Render<ExecutionTree>(p => p
|
||||
.Add(c => c.Nodes, nodes)
|
||||
.Add(c => c.ArrivedFromExecutionId, root));
|
||||
|
||||
var childLink = cut.Find($"[data-test=\"tree-node-link-{child}\"]");
|
||||
Assert.Equal($"/audit/log?executionId={child}", childLink.GetAttribute("href"));
|
||||
|
||||
var rootLink = cut.Find($"[data-test=\"tree-node-link-{root}\"]");
|
||||
Assert.Equal($"/audit/log?executionId={root}", rootLink.GetAttribute("href"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyNodeList_RendersNothingWithoutThrowing()
|
||||
{
|
||||
var cut = Render<ExecutionTree>(p => p
|
||||
.Add(c => c.Nodes, (IReadOnlyList<ExecutionTreeNode>)Array.Empty<ExecutionTreeNode>())
|
||||
.Add(c => c.ArrivedFromExecutionId, Guid.NewGuid()));
|
||||
|
||||
Assert.DoesNotContain("data-test=\"tree-node-", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CyclicFlatList_TerminatesWithoutInfiniteLoop()
|
||||
{
|
||||
// Defensive: a corrupt flat list where A→B and B→A must not hang the
|
||||
// renderer. Each execution is rendered at most once.
|
||||
var a = Guid.Parse("a0000000-0000-0000-0000-000000000000");
|
||||
var b = Guid.Parse("b0000000-0000-0000-0000-000000000000");
|
||||
var nodes = new List<ExecutionTreeNode>
|
||||
{
|
||||
Node(a, b),
|
||||
Node(b, a),
|
||||
};
|
||||
|
||||
var cut = Render<ExecutionTree>(p => p
|
||||
.Add(c => c.Nodes, nodes)
|
||||
.Add(c => c.ArrivedFromExecutionId, a));
|
||||
|
||||
// Both render exactly once — no runaway recursion.
|
||||
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{a}\""));
|
||||
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{b}\""));
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string haystack, string needle)
|
||||
{
|
||||
int count = 0, idx = 0;
|
||||
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
idx += needle.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
124
tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs
Normal file
124
tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Bunit.TestDoubles;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ScadaLink.CentralUI.Services;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
using ScadaLink.Security;
|
||||
using ExecutionTreePage = ScadaLink.CentralUI.Components.Pages.Audit.ExecutionTreePage;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Pages;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="ExecutionTreePage"/> (Audit Log ParentExecutionId
|
||||
/// feature, Task 10). The page is reached via the "View execution chain"
|
||||
/// drill-in at <c>/audit/execution-tree?executionId={guid}</c>. It parses the
|
||||
/// query-string id, calls <see cref="IAuditLogQueryService.GetExecutionTreeAsync"/>,
|
||||
/// and hands the flat node list to the <c>ExecutionTree</c> component.
|
||||
/// </summary>
|
||||
public class ExecutionTreePageTests : BunitContext
|
||||
{
|
||||
private IAuditLogQueryService _queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new("Username", "tester") };
|
||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
private IRenderedComponent<ExecutionTreePage> RenderPage(string? query, params string[] roles)
|
||||
{
|
||||
var user = BuildPrincipal(roles);
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
|
||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||
Services.AddSingleton(_queryService);
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
{
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo($"/audit/execution-tree?{query}");
|
||||
}
|
||||
|
||||
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<ExecutionTreePage>(0);
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
|
||||
return host.FindComponent<ExecutionTreePage>();
|
||||
}
|
||||
|
||||
private static ExecutionTreeNode Node(Guid id, Guid? parent, int rowCount = 2)
|
||||
=> new(
|
||||
id, parent, rowCount,
|
||||
rowCount == 0 ? Array.Empty<string>() : new[] { "ApiOutbound" },
|
||||
rowCount == 0 ? Array.Empty<string>() : new[] { "Delivered" },
|
||||
rowCount == 0 ? null : "plant-a",
|
||||
rowCount == 0 ? null : "boiler-3",
|
||||
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithExecutionId_CallsService_AndRendersTree()
|
||||
{
|
||||
var root = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var child = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
_queryService.GetExecutionTreeAsync(child, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(new List<ExecutionTreeNode>
|
||||
{
|
||||
Node(root, null),
|
||||
Node(child, root),
|
||||
}));
|
||||
|
||||
var cut = RenderPage($"executionId={child}", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().GetExecutionTreeAsync(child, Arg.Any<CancellationToken>());
|
||||
Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup);
|
||||
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithoutExecutionId_RendersGuidancePrompt_NoServiceCall()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderPage(query: null, "Admin");
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
|
||||
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithUnparseableExecutionId_RendersGuidancePrompt_NoServiceCall()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderPage("executionId=not-a-guid", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
|
||||
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute()
|
||||
{
|
||||
var attributes = typeof(ExecutionTreePage)
|
||||
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
|
||||
.Cast<AuthorizeAttribute>()
|
||||
.ToList();
|
||||
|
||||
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,66 @@ public class AuditLogQueryServiceTests
|
||||
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Audit Log ParentExecutionId feature (Task 10): GetExecutionTreeAsync —
|
||||
// a thin pass-through over IAuditLogRepository.GetExecutionTreeAsync, mirroring
|
||||
// QueryAsync's scope-per-call contract on the production path.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetExecutionTreeAsync_ForwardsExecutionId_ToRepository()
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
var expected = new List<ExecutionTreeNode>
|
||||
{
|
||||
new(executionId, null, 3,
|
||||
new[] { "ApiOutbound" }, new[] { "Delivered" },
|
||||
"plant-a", "boiler-3",
|
||||
new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)),
|
||||
};
|
||||
repo.GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(expected));
|
||||
|
||||
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
||||
|
||||
var result = await sut.GetExecutionTreeAsync(executionId);
|
||||
|
||||
Assert.Same(expected, result);
|
||||
await repo.Received(1).GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExecutionTreeAsync_OpensFreshScopePerCall_OnProductionCtor()
|
||||
{
|
||||
// The production ctor must resolve a fresh repository per call — same
|
||||
// scope-per-query contract QueryAsync upholds, so the page's auto-load
|
||||
// never shares the circuit-scoped DbContext.
|
||||
var resolvedRepos = new List<IAuditLogRepository>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IAuditLogRepository>(_ =>
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()));
|
||||
resolvedRepos.Add(repo);
|
||||
return repo;
|
||||
});
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var sut = new AuditLogQueryService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
EmptyAggregator());
|
||||
|
||||
await sut.GetExecutionTreeAsync(Guid.NewGuid());
|
||||
await sut.GetExecutionTreeAsync(Guid.NewGuid());
|
||||
|
||||
Assert.Equal(2, resolvedRepos.Count);
|
||||
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
|
||||
}
|
||||
|
||||
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
|
||||
{
|
||||
SiteAuditBacklogSnapshot? backlog = pending.HasValue
|
||||
|
||||
Reference in New Issue
Block a user