feat(ui): add Node column + filter to AuditLog grid

This commit is contained in:
Joseph Doherty
2026-05-23 18:01:36 -04:00
parent 466e1454fe
commit bb29d65a94
19 changed files with 392 additions and 8 deletions

View File

@@ -1,8 +1,10 @@
@using ScadaLink.CentralUI.Services
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Audit
@using ScadaLink.Commons.Types.Enums
@inject ISiteRepository SiteRepository
@inject IAuditLogQueryService AuditLogQueryService
<div class="card mb-3" data-test="audit-filter-bar">
<div class="card-body py-2">
@@ -58,6 +60,21 @@
</div>
</div>
@* Node multi-select. Options are the distinct SourceNode values
observed in the AuditLog table; the service-side lookup is cached
for 60s so a render of this bar costs at most one DB hit per
minute per circuit. *@
<div class="col-auto" data-test="filter-node">
<label class="form-label small mb-1">Node</label>
<div>
<MultiSelectDropdown TValue="string"
Items="_sourceNodes"
Selected="_model.SourceNodes"
EmptyText="No nodes available"
DataTest="filter-node-ms" />
</div>
</div>
<div class="col-auto" data-test="filter-time-range">
<label class="form-label small mb-1" for="audit-time-range">Time range</label>
<select id="audit-time-range" class="form-select form-select-sm"

View File

@@ -31,6 +31,15 @@ public partial class AuditFilterBar
/// <summary>Site identifiers in display order; rebuilt once when sites load.</summary>
private IReadOnlyList<string> _siteIds = Array.Empty<string>();
/// <summary>
/// Distinct <c>SourceNode</c> identifiers in display order; populated once
/// when the filter bar initialises from the cached
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService.GetDistinctSourceNodesAsync"/>
/// snapshot (60s TTL). Failure is non-fatal — the dropdown falls back to
/// "No nodes available", mirroring the site loader.
/// </summary>
private IReadOnlyList<string> _sourceNodes = Array.Empty<string>();
/// <summary>
/// Raised when the user clicks Apply. Carries the
/// <see cref="AuditLogQueryFilter"/> the parent page hands to
@@ -80,6 +89,20 @@ public partial class AuditFilterBar
}
_siteIds = _sites.Select(s => s.SiteIdentifier).ToArray();
// Populate the Node dropdown alongside the Site dropdown. The service
// caches the distinct-nodes lookup for 60s so this never costs more
// than one DB hit per minute per circuit; on failure the dropdown
// degrades to "No nodes available" like the site loader.
try
{
var nodes = await AuditLogQueryService.GetDistinctSourceNodesAsync();
_sourceNodes = nodes.ToArray();
}
catch
{
_sourceNodes = Array.Empty<string>();
}
}
/// <summary>
@@ -128,6 +151,7 @@ public partial class AuditFilterBar
_model.Kinds.Clear();
_model.Statuses.Clear();
_model.SiteIdentifiers.Clear();
_model.SourceNodes.Clear();
_model.TimeRange = AuditTimeRangePreset.LastHour;
_model.CustomFromUtc = null;
_model.CustomToUtc = null;

View File

@@ -38,6 +38,14 @@ public sealed class AuditQueryModel
public HashSet<AuditStatus> Statuses { get; } = new();
public HashSet<string> SiteIdentifiers { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Selected source-node identifiers (e.g. <c>"central-a"</c>,
/// <c>"site-plant-a-node-a"</c>). Mirrors <see cref="SiteIdentifiers"/> —
/// chip multi-select state, empty = "do not constrain", mapped through to
/// <see cref="AuditLogQueryFilter.SourceNodes"/> by <see cref="ToFilter"/>.
/// </summary>
public HashSet<string> SourceNodes { get; } = new(StringComparer.OrdinalIgnoreCase);
public AuditTimeRangePreset TimeRange { get; set; } = AuditTimeRangePreset.LastHour;
public DateTime? CustomFromUtc { get; set; }
public DateTime? CustomToUtc { get; set; }
@@ -153,7 +161,8 @@ public sealed class AuditQueryModel
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: fromUtc,
ToUtc: toUtc);
ToUtc: toUtc,
SourceNodes: SourceNodes.Count > 0 ? SourceNodes.ToArray() : null);
}
/// <summary>The non-success statuses targeted by the Errors-only toggle.</summary>

View File

@@ -105,6 +105,9 @@
case "Site":
<span class="small">@(row.SourceSiteId ?? "—")</span>
break;
case "Node":
<span class="small">@(row.SourceNode ?? "—")</span>
break;
case "Channel":
<span class="small">@row.Channel</span>
break;

View File

@@ -118,6 +118,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
{
("OccurredAtUtc", "OccurredAtUtc"),
("Site", "Site"),
("Node", "Node"),
("Channel", "Channel"),
("Kind", "Kind"),
("Status", "Status"),

View File

@@ -38,6 +38,12 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
// and "% errors over the last hour" as the KPI definition.
private static readonly TimeSpan KpiWindow = TimeSpan.FromHours(1);
// Audit Log Node filter (Task 15): the distinct-source-nodes lookup powers
// the filter dropdown population. A 60s cache keeps rendering the filter
// bar cheap — node membership changes (failover, scaling) surface within
// a minute, which is acceptable for a filter affordance.
private static readonly TimeSpan DistinctSourceNodesTtl = TimeSpan.FromSeconds(60);
// Production path: open a fresh scope per operation. Null in the test-seam ctor.
private readonly IServiceScopeFactory? _scopeFactory;
@@ -47,6 +53,12 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
private readonly ICentralHealthAggregator _healthAggregator;
// Distinct-source-nodes cache. Lock guards the (snapshot, expiry) pair so
// a stampede of concurrent filter-bar renders does not turn into N DB hits.
private readonly object _sourceNodesLock = new();
private IReadOnlyList<string>? _cachedSourceNodes;
private DateTime _cachedSourceNodesExpiryUtc = DateTime.MinValue;
/// <summary>
/// Production constructor — resolves <see cref="IAuditLogRepository"/> from a
/// fresh DI scope on every call so each query gets its own
@@ -151,4 +163,42 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
return await repository.GetExecutionTreeAsync(executionId, ct);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
{
// Fast path: a fresh cache entry served entirely from memory.
lock (_sourceNodesLock)
{
if (_cachedSourceNodes is not null && DateTime.UtcNow < _cachedSourceNodesExpiryUtc)
{
return _cachedSourceNodes;
}
}
IReadOnlyList<string> snapshot;
if (_injectedRepository is not null)
{
snapshot = await _injectedRepository.GetDistinctSourceNodesAsync(ct);
}
else
{
await using var scope = _scopeFactory!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
snapshot = await repository.GetDistinctSourceNodesAsync(ct);
}
// Slow path: replace the cache entry. Concurrent slow paths can race
// here — every winner stores a valid snapshot, so the last write wins
// and no caller sees stale data. The double-check inside the lock is
// deliberately omitted: redundant DB hits during a stampede are rare
// (60s TTL) and cheaper than holding the lock across the await.
lock (_sourceNodesLock)
{
_cachedSourceNodes = snapshot;
_cachedSourceNodesExpiryUtc = DateTime.UtcNow + DistinctSourceNodesTtl;
}
return snapshot;
}
}

View File

@@ -69,4 +69,15 @@ public interface IAuditLogQueryService
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default);
/// <summary>
/// Returns the distinct, non-null <c>SourceNode</c> values present in the
/// central <c>AuditLog</c> table, backing the Audit Log page's Node
/// multi-select filter. The implementation caches the result for ~60s so
/// rendering the filter bar never produces more than one DB hit per minute
/// per circuit. The cache is process-wide — node membership changes
/// (failover, scaling) surface within a minute, which is acceptable for a
/// filter affordance.
/// </summary>
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
}

View File

@@ -175,4 +175,12 @@ public interface IAuditLogRepository
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default);
/// <summary>
/// Returns the distinct, non-null <c>SourceNode</c> values present in the
/// <c>AuditLog</c> table, in ascending order. Backs the Audit Log page's
/// "Node" multi-select filter dropdown — the Central UI caches the result
/// for ~60s so the repository is hit at most once per minute per circuit.
/// </summary>
Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default);
}

View File

@@ -5,16 +5,24 @@ namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
/// Any field left <c>null</c> means "do not constrain on that column". The
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/> and
/// <see cref="SourceSiteIds"/> dimensions are multi-value: a <c>null</c> OR empty
/// list means "do not constrain", and a non-empty list is OR-combined within the
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
/// inclusive of the upper bound; the repository SQL uses <c>&gt;=</c> / <c>&lt;=</c>
/// <see cref="Channels"/>, <see cref="Kinds"/>, <see cref="Statuses"/>,
/// <see cref="SourceSiteIds"/> and <see cref="SourceNodes"/> dimensions are
/// multi-value: a <c>null</c> OR empty list means "do not constrain", and a
/// non-empty list is OR-combined within the dimension (translated to a SQL
/// <c>IN (…)</c>). Time bounds are half-open in the spec sense —
/// <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is inclusive of
/// the upper bound; the repository SQL uses <c>&gt;=</c> / <c>&lt;=</c>
/// respectively. All filter dimensions are AND-combined with one another. The
/// single-value <see cref="CorrelationId"/>, <see cref="ExecutionId"/> and
/// <see cref="ParentExecutionId"/> dimensions constrain on equality when set.
/// </summary>
/// <param name="SourceNodes">
/// Restrict to rows whose <c>SourceNode</c> matches one of the supplied node
/// identifiers (e.g. <c>"central-a"</c>, <c>"site-plant-a-node-a"</c>). A null
/// or empty list means "do not constrain"; a non-empty list is translated to
/// SQL <c>SourceNode IN (…)</c>. Rows with NULL <c>SourceNode</c> are excluded
/// when the filter is set (the same SourceSiteIds contract).
/// </param>
public sealed record AuditLogQueryFilter(
IReadOnlyList<AuditChannel>? Channels = null,
IReadOnlyList<AuditKind>? Kinds = null,
@@ -26,4 +34,5 @@ public sealed record AuditLogQueryFilter(
Guid? ExecutionId = null,
Guid? ParentExecutionId = null,
DateTime? FromUtc = null,
DateTime? ToUtc = null);
DateTime? ToUtc = null,
IReadOnlyList<string>? SourceNodes = null);

View File

@@ -140,6 +140,13 @@ VALUES
query = query.Where(e => e.SourceSiteId != null && sourceSiteIds.Contains(e.SourceSiteId));
}
// SourceNode filter mirrors SourceSiteIds: a non-empty list translates to
// SQL IN (…); NULL SourceNode rows are excluded when the filter is set.
if (filter.SourceNodes is { Count: > 0 } sourceNodes)
{
query = query.Where(e => e.SourceNode != null && sourceNodes.Contains(e.SourceNode));
}
if (!string.IsNullOrEmpty(filter.Target))
{
var target = filter.Target;
@@ -761,6 +768,25 @@ VALUES
}
}
/// <summary>
/// Distinct non-null <c>SourceNode</c> values for the Audit Log page's
/// Node filter dropdown. EF Core translates this to
/// <c>SELECT DISTINCT SourceNode FROM AuditLog WHERE SourceNode IS NOT NULL ORDER BY SourceNode</c>
/// — a single index-less scan, but the column is bounded (one entry per
/// node in the cluster, currently &lt;10) and the Central UI caches the
/// result for ~60s, so a periodic scan is acceptable.
/// </summary>
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
{
return await _context.Set<AuditEvent>()
.AsNoTracking()
.Where(e => e.SourceNode != null)
.Select(e => e.SourceNode!)
.Distinct()
.OrderBy(n => n)
.ToListAsync(ct);
}
/// <summary>
/// Splits a <c>STRING_AGG</c> comma-joined value into a distinct, ordered
/// list. A null/empty aggregate (a stub node with no rows) yields an empty

View File

@@ -228,5 +228,8 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
_inner.GetExecutionTreeAsync(executionId, ct);
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
_inner.GetDistinctSourceNodesAsync(ct);
}
}

View File

@@ -86,6 +86,9 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)

View File

@@ -55,6 +55,9 @@ public class CentralAuditWriteFailuresTests : TestKit
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
/// <summary>

View File

@@ -101,6 +101,9 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
/// <summary>

View File

@@ -3,8 +3,11 @@ using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.CentralUI.Components.Audit;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
@@ -29,6 +32,7 @@ namespace ScadaLink.CentralUI.Tests.Components.Audit;
public class AuditFilterBarTests : BunitContext
{
private readonly ISiteRepository _siteRepo;
private readonly IAuditLogQueryService _auditLogQueryService;
public AuditFilterBarTests()
{
@@ -40,6 +44,17 @@ public class AuditFilterBarTests : BunitContext
new("Plant B", "plant-b") { Id = 2 },
}));
Services.AddSingleton(_siteRepo);
// Task 15: the Node multi-select pulls its options from
// IAuditLogQueryService.GetDistinctSourceNodesAsync. The default stub
// returns the two central nodes the cluster uses; individual tests can
// override via _auditLogQueryService.GetDistinctSourceNodesAsync(...).Returns(...).
_auditLogQueryService = Substitute.For<IAuditLogQueryService>();
_auditLogQueryService.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a", "central-b" }));
_auditLogQueryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
Services.AddSingleton(_auditLogQueryService);
}
[Fact]
@@ -55,6 +70,7 @@ public class AuditFilterBarTests : BunitContext
"data-test=\"filter-kind\"",
"data-test=\"filter-status\"",
"data-test=\"filter-site\"",
"data-test=\"filter-node\"",
"data-test=\"filter-time-range\"",
"data-test=\"filter-custom-range\"",
"data-test=\"filter-instance\"",
@@ -160,6 +176,30 @@ public class AuditFilterBarTests : BunitContext
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
}
[Fact]
public void NodeMultiSelect_RendersOptions_FromQueryService_AndMapsThroughToFilter()
{
// Task 15: the Node filter pulls its option set from
// IAuditLogQueryService.GetDistinctSourceNodesAsync and threads the
// chip selection into AuditLogQueryFilter.SourceNodes.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// The bar marker plus the option checkboxes for the two cluster nodes
// are present after init (the constructor stubs return two nodes).
Assert.Contains("data-test=\"filter-node\"", cut.Markup);
Assert.Contains("data-test=\"filter-node-ms-opt-central-a\"", cut.Markup);
Assert.Contains("data-test=\"filter-node-ms-opt-central-b\"", cut.Markup);
cut.Find("[data-test=\"filter-node-ms-opt-central-a\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.NotNull(captured!.SourceNodes);
Assert.Equal(new[] { "central-a" }, captured.SourceNodes);
}
[Fact]
public void Apply_WithMultipleStatusChips_PassesAllSelectedStatuses()
{

View File

@@ -123,6 +123,28 @@ public class AuditResultsGridTests : BunitContext
Assert.Equal(target.EventId, captured!.EventId);
}
[Fact]
public void Render_IncludesNodeColumn_BetweenSiteAndChannel()
{
// Task 15: the grid surfaces SourceNode in a dedicated "Node" column
// positioned between Site and Channel.
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
Assert.Contains("data-test=\"col-header-Node\"", cut.Markup);
// The header order must place Node between Site and Channel.
var siteIdx = cut.Markup.IndexOf("data-test=\"col-header-Site\"", StringComparison.Ordinal);
var nodeIdx = cut.Markup.IndexOf("data-test=\"col-header-Node\"", StringComparison.Ordinal);
var channelIdx = cut.Markup.IndexOf("data-test=\"col-header-Channel\"", StringComparison.Ordinal);
Assert.True(siteIdx < nodeIdx, "Node column must follow Site.");
Assert.True(nodeIdx < channelIdx, "Node column must precede Channel.");
}
[Fact]
public void Render_IncludesExecutionIdColumn()
{

View File

@@ -282,6 +282,70 @@ public class AuditLogQueryServiceTests
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
}
// ─────────────────────────────────────────────────────────────────────────
// Task 15: SourceNode filter forwarding + distinct-nodes service contract.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task QueryAsync_ForwardsSourceNodesFilter_ToRepository()
{
// The Audit Log page's new Node multi-select pushes its chip set into
// AuditLogQueryFilter.SourceNodes; the service must thread it through
// unchanged so the repository's IN-list applies.
var repo = Substitute.For<IAuditLogRepository>();
var filter = new AuditLogQueryFilter(
SourceNodes: new[] { "central-a", "site-plant-a-node-a" });
repo.QueryAsync(filter, Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
await sut.QueryAsync(filter);
await repo.Received(1).QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.SourceNodes != null
&& f.SourceNodes.Count == 2
&& f.SourceNodes.Contains("central-a")
&& f.SourceNodes.Contains("site-plant-a-node-a")),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetDistinctSourceNodesAsync_ForwardsToRepository_OnFirstCall()
{
var repo = Substitute.For<IAuditLogRepository>();
var expected = new[] { "central-a", "central-b", "site-plant-a-node-a" };
repo.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(expected));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
var result = await sut.GetDistinctSourceNodesAsync();
Assert.Equal(expected, result);
await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetDistinctSourceNodesAsync_CachesSnapshot_AcrossRepeatedCalls()
{
// Two back-to-back calls within the 60s TTL must hit the repository
// exactly once — the filter bar should never produce N DB hits when
// the operator opens it twice in quick succession.
var repo = Substitute.For<IAuditLogRepository>();
repo.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a" }));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
var first = await sut.GetDistinctSourceNodesAsync();
var second = await sut.GetDistinctSourceNodesAsync();
Assert.Equal(first, second);
await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>());
}
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
{
SiteAuditBacklogSnapshot? backlog = pending.HasValue

View File

@@ -297,6 +297,91 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
}
// ──────────────────────────────────────────────────────────────────────
// Task 15: SourceNodes filter + GetDistinctSourceNodesAsync. Pins the new
// Node multi-select contract — non-empty list → SQL IN (…); NULL
// SourceNode rows are excluded when the filter is set; the distinct
// enumeration omits nulls and orders ascending.
// ──────────────────────────────────────────────────────────────────────
[SkippableFact]
public async Task QueryAsync_FilterBySourceNode_ReturnsMatchingRows()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 4, 10, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-a"));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-b"));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: null));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
SourceSiteIds: new[] { siteId },
SourceNodes: new[] { "central-a" }),
new AuditLogPaging(PageSize: 10));
Assert.Single(rows);
Assert.Equal("central-a", rows[0].SourceNode);
}
[SkippableFact]
public async Task QueryAsync_FilterByMultipleSourceNodes_ReturnsUnion()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 4, 11, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-a"));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-b"));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: "site-plant-a-node-a"));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
SourceSiteIds: new[] { siteId },
SourceNodes: new[] { "central-a", "central-b" }),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Contains(r.SourceNode, new[] { "central-a", "central-b" }));
}
[SkippableFact]
public async Task GetDistinctSourceNodesAsync_ReturnsDistinctNonNullValues_Ordered()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var t0 = new DateTime(2026, 5, 4, 12, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, sourceNode: "central-b"));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), sourceNode: "central-a"));
// Duplicate of "central-a" — must collapse to one entry.
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), sourceNode: "central-a"));
// Null row — must be excluded entirely.
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), sourceNode: null));
var nodes = await repo.GetDistinctSourceNodesAsync();
// The whole table is scanned (no per-test scoping on this query), so the
// assertion is "our seeded values appear once each, in order" rather
// than a strict equality on the full result.
Assert.Contains("central-a", nodes);
Assert.Contains("central-b", nodes);
// No null entry — the WHERE SourceNode IS NOT NULL clause drops them.
Assert.DoesNotContain(nodes, n => n is null);
// Ordered ascending.
Assert.Equal(nodes.OrderBy(n => n, StringComparer.Ordinal), nodes);
}
[SkippableFact]
public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows()
{

View File

@@ -100,6 +100,9 @@ public class SiteAuditPushFlowTests : TestKit
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
=> throw new NotSupportedException();
}
private static AuditEvent NewPendingEvent(Guid id) => new()