From e052aa4ff8ddc92683916a829489df95c0d324f5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 20:02:46 -0400 Subject: [PATCH] feat(ui): AuditResultsGrid + AuditLogQueryService with keyset paging (#23 M7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Components/Audit/AuditResultsGrid.razor | 111 ++++++++++ .../Audit/AuditResultsGrid.razor.cs | 199 ++++++++++++++++++ .../Components/Pages/Audit/AuditLogPage.razor | 13 +- .../Pages/Audit/AuditLogPage.razor.cs | 30 ++- .../ServiceCollectionExtensions.cs | 5 + .../Services/AuditLogQueryService.cs | 32 +++ .../Services/IAuditLogQueryService.cs | 30 +++ .../Components/Audit/AuditResultsGridTests.cs | 134 ++++++++++++ .../Pages/AuditLogPageScaffoldTests.cs | 7 + .../Services/AuditLogQueryServiceTests.cs | 57 +++++ 10 files changed, 611 insertions(+), 7 deletions(-) create mode 100644 src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor create mode 100644 src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs create mode 100644 src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs create mode 100644 src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor new file mode 100644 index 0000000..df000aa --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor @@ -0,0 +1,111 @@ +@using ScadaLink.CentralUI.Components.Shared +@using ScadaLink.CentralUI.Services +@using ScadaLink.Commons.Entities.Audit +@using ScadaLink.Commons.Types.Audit +@using ScadaLink.Commons.Types.Enums +@inject IAuditLogQueryService QueryService + +
+ @if (_error is not null) + { +
@_error
+ } + +
+ + + + @foreach (var col in OrderedColumns()) + { + + } + + + + @if (_rows.Count == 0) + { + + + + } + else + { + @foreach (var row in _rows) + { + + @foreach (var col in OrderedColumns()) + { + + } + + } + } + +
@col.Label
+ @if (_loading) + { + Loading… + } + else + { + No audit events match the current filter. + } +
+ @RenderCell(col.Key, row) +
+
+ +
+ Page @_pageNumber · @_rows.Count rows + +
+
+ +@code { + private RenderFragment RenderCell(string key, AuditEvent row) => __builder => + { + switch (key) + { + case "OccurredAtUtc": + var occurredOffset = new DateTimeOffset(DateTime.SpecifyKind(row.OccurredAtUtc, DateTimeKind.Utc)); + + + + break; + case "Site": + @(row.SourceSiteId ?? "—") + break; + case "Channel": + @row.Channel + break; + case "Kind": + @row.Kind + break; + case "Status": + @row.Status + break; + case "Target": + @(row.Target ?? "—") + break; + case "Actor": + @(row.Actor ?? "—") + break; + case "DurationMs": + @(row.DurationMs?.ToString() ?? "—") + break; + case "HttpStatus": + @(row.HttpStatus?.ToString() ?? "—") + break; + case "ErrorMessage": + @TruncateError(row.ErrorMessage) + break; + } + }; +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs new file mode 100644 index 0000000..cfbae61 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs @@ -0,0 +1,199 @@ +using Microsoft.AspNetCore.Components; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3). +/// Renders the 10 columns named in Component-AuditLog.md §10: +/// OccurredAtUtc, Site, Channel, Kind, Status, Target, Actor, DurationMs, +/// HttpStatus, ErrorMessage. Talks to +/// — never to IAuditLogRepository directly — so tests can stub the data +/// source without standing up EF Core. +/// +/// +/// Column model. Each column has a stable string key; the visible order +/// is the parameter. M7 scope: the column-model +/// framework is in place but resize / drag-reorder UX is intentionally NOT +/// implemented — the full spec calls for persisted-per-user reordering and +/// resizing, which M7.x can ship without rewriting the column model. Resizing +/// today is CSS-based via Bootstrap's .table-responsive wrapper. +/// +/// +/// +/// Pagination. Each page is a single call to +/// IAuditLogQueryService.QueryAsync. The "Next page" button uses the +/// LAST row of the current page as the keyset cursor — repository orders by +/// (OccurredAtUtc DESC, EventId DESC), so the oldest row in the visible +/// page becomes AfterOccurredAtUtc + AfterEventId on the next +/// request. The button is disabled when the current page is short (less than +/// rows) — that's the conventional "we've reached the +/// end" signal for keyset paging without a count query. +/// +/// +public partial class AuditResultsGrid +{ + private const int DefaultPageSize = 100; + + private readonly List _rows = new(); + private int _pageNumber = 1; + private bool _loading; + private string? _error; + + private AuditLogQueryFilter? _activeFilter; + + /// + /// Filter to apply. When this parameter changes the grid resets to page 1 and + /// reissues the query — that's the contract the parent page relies on so the + /// filter-bar Apply button does not need to drive grid state manually. + /// + [Parameter] public AuditLogQueryFilter? Filter { get; set; } + + /// Page size. Defaults to 100 to match the service-level default. + [Parameter] public int PageSize { get; set; } = DefaultPageSize; + + /// + /// Optional column order — list of column keys in display order. When null or + /// empty the default order from Component-AuditLog.md §10 is used. The grid + /// silently drops unknown keys. + /// + [Parameter] public IReadOnlyList? ColumnOrder { get; set; } + + /// + /// Raised when the user clicks a row. Bundle C wires this to the drilldown + /// drawer. The event payload is the full . + /// + [Parameter] public EventCallback OnRowSelected { get; set; } + + // Effective page size used when paging. Mirrors PageSize but bounded > 0. + private int _pageSize => Math.Max(1, PageSize); + + /// + /// Default column definitions. The key is the stable identifier (used by + /// data-test + the column-order parameter); the label is the user-facing + /// header text. Mirrors Component-AuditLog.md §10. + /// + private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[] + { + ("OccurredAtUtc", "OccurredAtUtc"), + ("Site", "Site"), + ("Channel", "Channel"), + ("Kind", "Kind"), + ("Status", "Status"), + ("Target", "Target"), + ("Actor", "Actor"), + ("DurationMs", "DurationMs"), + ("HttpStatus", "HttpStatus"), + ("ErrorMessage", "ErrorMessage"), + }; + + private IReadOnlyList<(string Key, string Label)> OrderedColumns() + { + if (ColumnOrder is null || ColumnOrder.Count == 0) + { + return AllColumns; + } + + var byKey = AllColumns.ToDictionary(c => c.Key, c => c); + var ordered = new List<(string Key, string Label)>(ColumnOrder.Count); + foreach (var key in ColumnOrder) + { + if (byKey.TryGetValue(key, out var col)) + { + ordered.Add(col); + } + } + return ordered.Count == 0 ? AllColumns : ordered; + } + + protected override async Task OnParametersSetAsync() + { + // Reset & reload whenever the filter reference changes. AuditLogQueryFilter + // is a record, so equality-by-value gives us a free "did the user click Apply + // with the same chips?" no-op signal. We pin to ReferenceEquals here so the + // grid reloads only when the parent hands us a new filter instance — the + // page wraps Apply in a fresh allocation, which is the canonical reload signal. + if (!ReferenceEquals(_activeFilter, Filter)) + { + _activeFilter = Filter; + _pageNumber = 1; + _rows.Clear(); + if (Filter is not null) + { + await LoadAsync(paging: null); + } + } + } + + private async Task NextPage() + { + if (_rows.Count == 0 || _activeFilter is null) + { + return; + } + + var last = _rows[^1]; + var cursor = new AuditLogPaging( + PageSize: _pageSize, + AfterOccurredAtUtc: last.OccurredAtUtc, + AfterEventId: last.EventId); + + await LoadAsync(cursor); + _pageNumber++; + } + + private async Task LoadAsync(AuditLogPaging? paging) + { + if (_activeFilter is null) + { + return; + } + + _loading = true; + _error = null; + try + { + var effective = paging ?? new AuditLogPaging(_pageSize); + var page = await QueryService.QueryAsync(_activeFilter, effective); + _rows.Clear(); + _rows.AddRange(page); + } + catch (Exception ex) + { + // Surface the error in-place; the grid stays alive so the user can + // adjust the filter and retry without a page refresh. + _error = $"Query failed: {ex.Message}"; + } + finally + { + _loading = false; + } + } + + private async Task HandleRowClick(AuditEvent row) + { + if (OnRowSelected.HasDelegate) + { + await OnRowSelected.InvokeAsync(row); + } + } + + private static string StatusBadgeClass(AuditStatus status) => status switch + { + AuditStatus.Delivered => "badge bg-success", + AuditStatus.Failed or AuditStatus.Parked or AuditStatus.Discarded => "badge bg-danger", + _ => "badge bg-secondary", + }; + + private static string TruncateError(string? message) + { + if (string.IsNullOrEmpty(message)) + { + return "—"; + } + const int max = 80; + return message.Length <= max ? message : string.Concat(message.AsSpan(0, max), "…"); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor index 8d250e7..0b759f2 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor @@ -1,16 +1,25 @@ @page "/audit/log" @attribute [Authorize] +@using ScadaLink.CentralUI.Components.Audit +@using ScadaLink.CentralUI.Services +@using ScadaLink.Commons.Entities.Audit +@using ScadaLink.Commons.Types.Audit +@inject IAuditLogQueryService AuditLogQueryService Audit Log

Audit Log

- @* AuditFilterBar will go here (Bundle B). *@ + @* Filter bar (Bundle B / M7-T2). Apply hands the collapsed filter to the grid. *@
+
- @* AuditResultsGrid will go here (Bundle B). *@ + @* Results grid (Bundle B / M7-T3). Row clicks emit OnRowSelected for Bundle C's + drilldown drawer; the grid stays in "no events" mode until the user applies a + filter so the page does not auto-load the full audit table on first render. *@
+
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index 311f369..6e80600 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -1,12 +1,32 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; + namespace ScadaLink.CentralUI.Components.Pages.Audit; /// -/// Code-behind for the central Audit Log page (#23 M7-T1). The Bundle A -/// scaffold has no behaviour — the filter bar and results grid arrive in -/// Bundle B (M7-T2..M7-T7). Keeping the partial class in place now lets -/// later bundles add injected services and event handlers without -/// touching the route or page-title markup. +/// Code-behind for the central Audit Log page (#23 M7). Bundle B (M7-T2 + M7-T3) +/// wires up AuditFilterBar and AuditResultsGrid: the page owns the +/// active and re-pushes a fresh instance to the +/// grid on every Apply (the grid uses reference identity as its "reload" +/// trigger). Row clicks land in — Bundle C wires +/// this to the drilldown drawer; for now it is a no-op seam so test stubs do +/// not error. /// public partial class AuditLogPage { + private AuditLogQueryFilter? _currentFilter; + + private void HandleFilterChanged(AuditLogQueryFilter filter) + { + // Always reassign — the grid keys reloads on reference change, so even a + // chip-for-chip identical filter must allocate a fresh instance. + _currentFilter = filter; + } + + private void HandleRowSelected(AuditEvent row) + { + // Reserved for Bundle C (drilldown drawer). Intentionally left empty: the + // grid still raises the event, but we do nothing with it yet. + _ = row; + } } diff --git a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs index 0caf98c..be2310b 100644 --- a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using ScadaLink.CentralUI.Auth; using ScadaLink.CentralUI.Components.Shared; using ScadaLink.CentralUI.ScriptAnalysis; +using ScadaLink.CentralUI.Services; namespace ScadaLink.CentralUI; @@ -27,6 +28,10 @@ public static class ServiceCollectionExtensions // Components/Shared/IDialogService.cs. services.AddScoped(); + // Audit Log (#23 M7-T3): CentralUI facade over IAuditLogRepository so the + // results grid can be tested with a stubbed query source. + services.AddScoped(); + // Roslyn-backed C# analysis for the Monaco script editor. // Scoped because SharedScriptCatalog wraps a scoped service. services.AddMemoryCache(o => o.SizeLimit = 200); diff --git a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs new file mode 100644 index 0000000..971960e --- /dev/null +++ b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs @@ -0,0 +1,32 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.CentralUI.Services; + +/// +/// Default implementation — a thin pass-through +/// to . Default page size is 100 (the +/// AuditResultsGrid default for #23 M7). +/// +public sealed class AuditLogQueryService : IAuditLogQueryService +{ + private readonly IAuditLogRepository _repository; + + public AuditLogQueryService(IAuditLogRepository repository) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + } + + public int DefaultPageSize => 100; + + public Task> QueryAsync( + AuditLogQueryFilter filter, + AuditLogPaging? paging = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(filter); + var effective = paging ?? new AuditLogPaging(DefaultPageSize); + return _repository.QueryAsync(filter, effective, ct); + } +} diff --git a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs new file mode 100644 index 0000000..b9236f9 --- /dev/null +++ b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs @@ -0,0 +1,30 @@ +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.CentralUI.Services; + +/// +/// CentralUI facade over +/// (#23 M7-T3). The Audit Log page's results grid talks to this service rather than +/// the repository directly so tests can substitute a fake without spinning up EF +/// Core, and so a future caching / shaping layer (e.g. server-side CSV streaming) +/// can hang off the same seam. +/// +public interface IAuditLogQueryService +{ + /// + /// Returns a keyset-paged result page for . When + /// is null, defaults to + /// rows with no cursor (first page). The repository orders by + /// (OccurredAtUtc DESC, EventId DESC); pass the last row's + /// + + /// back as the cursor for the next page. + /// + Task> QueryAsync( + AuditLogQueryFilter filter, + AuditLogPaging? paging = null, + CancellationToken ct = default); + + /// Default page size when callers don't specify one. + int DefaultPageSize { get; } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs new file mode 100644 index 0000000..fa8fdec --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs @@ -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; + +/// +/// bUnit tests for (#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. +/// +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(); + _service.DefaultPageSize.Returns(100); + Services.AddSingleton(_service); + } + + private void StubPage(IReadOnlyList rows) + { + _service.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + _calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1])); + return Task.FromResult(rows); + }); + } + + [Fact] + public void Render_TenColumns_FromStubService() + { + StubPage(new List + { + MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered), + }); + + var cut = Render(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(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(p => p + .Add(c => c.Filter, new AuditLogQueryFilter()) + .Add(c => c.OnRowSelected, EventCallback.Factory.Create(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(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); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index 3ab4892..6d5c07b 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -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(); + // 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()); + Services.AddSingleton(Substitute.For()); return Render(); } diff --git a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs new file mode 100644 index 0000000..97743bf --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs @@ -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; + +/// +/// Service-level tests for (#23 M7-T3). The +/// service is a thin pass-through over ; +/// these tests pin the filter forwarding contract and the 100-row default-page-size +/// rule the grid relies on. +/// +public class AuditLogQueryServiceTests +{ + [Fact] + public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository() + { + var repo = Substitute.For(); + var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound); + var paging = new AuditLogPaging(PageSize: 25); + var expected = new List + { + new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered } + }; + repo.QueryAsync(filter, paging, Arg.Any()) + .Returns(Task.FromResult>(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()); + } + + [Fact] + public async Task QueryAsync_AppliesDefaultPageSize_WhenNotSpecified() + { + var repo = Substitute.For(); + AuditLogPaging? observed = null; + repo.QueryAsync(Arg.Any(), Arg.Do(p => observed = p), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + + 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); + } +}