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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user