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.
135 lines
5.3 KiB
C#
135 lines
5.3 KiB
C#
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);
|
|
}
|
|
}
|