feat(ui): AuditResultsGrid + AuditLogQueryService with keyset paging (#23 M7)

Adds the results grid + query facade for the central Audit Log page
(#23 M7-T3):

* IAuditLogQueryService / AuditLogQueryService — CentralUI facade over
  IAuditLogRepository.QueryAsync so the grid can be tested with a stubbed
  query source. Default page size is 100; callers can override per call.

* AuditResultsGrid.razor + .razor.cs — Blazor Server component (Bootstrap
  only, no third-party UI libs). Renders the 10 columns from
  Component-AuditLog.md §10 (OccurredAtUtc, Site, Channel, Kind, Status,
  Target, Actor, DurationMs, HttpStatus, ErrorMessage). Keyset-paged via
  the last visible row's (OccurredAtUtc, EventId) as the cursor; Next-page
  button disabled when the current page is short (no count query). Row
  clicks emit OnRowSelected(AuditEvent) for Bundle C's drilldown drawer.
  Status badges are colour-coded (Delivered=green; Failed/Parked/Discarded
  =red; other=gray). Error messages truncated to 80 chars with full text
  on hover.

* Column model framework: a ColumnOrder [Parameter] reorders columns by
  stable string keys; unknown keys are dropped. M7 scope decision (in the
  class doc): the framework is in place but drag-reorder / resize UX is
  not implemented — M7.x can add persisted-per-user reordering without
  rewriting the column model.

* AuditLogPage wired: hosts AuditFilterBar + AuditResultsGrid, threads
  the filter through and stubs OnRowSelected for Bundle C.

* AuditLogQueryService registered as scoped in AddCentralUI.

* Tests: 4 grid bUnit tests (10 columns rendered, next-page cursor
  carries last row, row click raises callback, badge classes for
  Failed vs Delivered), 2 service tests (filter+paging pass-through,
  default page size of 100). AuditLogPageScaffoldTests updated to
  provide the new ISiteRepository + IAuditLogQueryService stubs the
  page now resolves.
This commit is contained in:
Joseph Doherty
2026-05-20 20:02:46 -04:00
parent 13e84a76a7
commit e052aa4ff8
10 changed files with 611 additions and 7 deletions

View File

@@ -0,0 +1,134 @@
using Bunit;
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.Types.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditResultsGrid"/> (#23 M7-T3 / Bundle B). The grid
/// renders 10 columns, paginates via keyset (passing the last row's
/// (OccurredAtUtc, EventId) back to the service), raises a row-click callback
/// that Bundle C wires to the drilldown drawer, and styles non-success status
/// rows with an error-coded badge.
/// </summary>
public class AuditResultsGridTests : BunitContext
{
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a")
=> new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = occurredAtUtc,
Channel = channel,
Kind = kind,
Status = status,
SourceSiteId = site,
Target = "demo-target",
Actor = "tester",
DurationMs = 42,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
};
public AuditResultsGridTests()
{
_service = Substitute.For<IAuditLogQueryService>();
_service.DefaultPageSize.Returns(100);
Services.AddSingleton(_service);
}
private void StubPage(IReadOnlyList<AuditEvent> rows)
{
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
_calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1]));
return Task.FromResult(rows);
});
}
[Fact]
public void Render_TenColumns_FromStubService()
{
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// 10 column headers per Component-AuditLog.md §10.
var expectedHeaders = new[]
{
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
"Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage",
};
foreach (var header in expectedHeaders)
{
Assert.Contains($"data-test=\"col-header-{header}\"", cut.Markup);
}
}
[Fact]
public void Click_NextPage_CallsService_WithCursor_OfLastRow()
{
// First page: two rows, descending by OccurredAtUtc. The grid must pass the
// LAST row (the older one) back as the keyset cursor for the next page.
var first = MakeEvent(new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), AuditStatus.Delivered);
var second = MakeEvent(new DateTime(2026, 5, 20, 11, 30, 0, DateTimeKind.Utc), AuditStatus.Failed);
StubPage(new[] { first, second });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
cut.Find("[data-test=\"grid-next-page\"]").Click();
// Two service calls: initial + next.
Assert.Equal(2, _calls.Count);
var nextCall = _calls[1];
Assert.NotNull(nextCall.Paging);
Assert.Equal(second.OccurredAtUtc, nextCall.Paging!.AfterOccurredAtUtc);
Assert.Equal(second.EventId, nextCall.Paging.AfterEventId);
}
[Fact]
public void Click_Row_RaisesOnRowSelected()
{
var target = MakeEvent(DateTime.UtcNow.AddMinutes(-5), AuditStatus.Delivered);
StubPage(new[] { target });
AuditEvent? captured = null;
var cut = Render<AuditResultsGrid>(p => p
.Add(c => c.Filter, new AuditLogQueryFilter())
.Add(c => c.OnRowSelected, EventCallback.Factory.Create<AuditEvent>(this, e => captured = e)));
cut.Find($"[data-test=\"grid-row-{target.EventId}\"]").Click();
Assert.NotNull(captured);
Assert.Equal(target.EventId, captured!.EventId);
}
[Fact]
public void Status_FailedRow_HasErrorBadgeClass()
{
var failed = MakeEvent(DateTime.UtcNow.AddMinutes(-2), AuditStatus.Failed);
var delivered = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered);
StubPage(new[] { delivered, failed });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Failed badge => bg-danger (red). Delivered => bg-success (green).
var failedBadge = cut.Find($"[data-test=\"status-badge-{failed.EventId}\"]");
Assert.Contains("bg-danger", failedBadge.GetAttribute("class") ?? string.Empty);
var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]");
Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty);
}
}

View File

@@ -4,6 +4,8 @@ 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.Security;
using AuditLogPage = ScadaLink.CentralUI.Components.Pages.Audit.AuditLogPage;
using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu;
@@ -36,6 +38,11 @@ public class AuditLogPageScaffoldTests : BunitContext
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
// The page now hosts AuditFilterBar + AuditResultsGrid which depend on
// ISiteRepository and IAuditLogQueryService respectively (Bundle B).
// Provide stand-ins so the scaffold smoke tests still render the page.
Services.AddSingleton(Substitute.For<ScadaLink.Commons.Interfaces.Repositories.ISiteRepository>());
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
return Render<AuditLogPage>();
}

View File

@@ -0,0 +1,57 @@
using NSubstitute;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.CentralUI.Tests.Services;
/// <summary>
/// Service-level tests for <see cref="AuditLogQueryService"/> (#23 M7-T3). The
/// service is a thin pass-through over <see cref="IAuditLogRepository.QueryAsync"/>;
/// these tests pin the filter forwarding contract and the 100-row default-page-size
/// rule the grid relies on.
/// </summary>
public class AuditLogQueryServiceTests
{
[Fact]
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
{
var repo = Substitute.For<IAuditLogRepository>();
var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound);
var paging = new AuditLogPaging(PageSize: 25);
var expected = new List<AuditEvent>
{
new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }
};
repo.QueryAsync(filter, paging, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(expected));
var sut = new AuditLogQueryService(repo);
var result = await sut.QueryAsync(filter, paging);
Assert.Same(expected, result);
await repo.Received(1).QueryAsync(filter, paging, Arg.Any<CancellationToken>());
}
[Fact]
public async Task QueryAsync_AppliesDefaultPageSize_WhenNotSpecified()
{
var repo = Substitute.For<IAuditLogRepository>();
AuditLogPaging? observed = null;
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => observed = p), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
var sut = new AuditLogQueryService(repo);
await sut.QueryAsync(new AuditLogQueryFilter(), paging: null);
Assert.NotNull(observed);
Assert.Equal(sut.DefaultPageSize, observed!.PageSize);
Assert.Equal(100, sut.DefaultPageSize);
Assert.Null(observed.AfterOccurredAtUtc);
Assert.Null(observed.AfterEventId);
}
}