Adds three KPI tiles to the central Health dashboard for the Audit channel: volume (rows in the last hour), error rate (Failed/Parked/Discarded over total), and backlog (sum of SiteAuditBacklog.PendingCount across all sites). Repo + service: - IAuditLogRepository.GetKpiSnapshotAsync(window, nowUtc) — single aggregate SELECT over the trailing window returning total + error counts; nowUtc is optional for production callers and pinned by integration tests against the shared MSSQL fixture so the global counts are deterministic. - AuditLogQueryService.GetKpiSnapshotAsync() — composes the repo aggregate with a sum of SiteAuditBacklog.PendingCount read from ICentralHealthAggregator. - AuditLogKpiSnapshot record in Commons/Types/. UI: - New AuditKpiTiles Blazor component (Components/Health/) — three Bootstrap card-tiles, click navigates to /audit/log with the matching pre-filter. - Health.razor wires the tiles in alongside the existing Notification Outbox KPIs; LoadAuditKpis() runs on every 10s refresh tick and degrades to em dashes + inline error if the query fails. - AuditLogPage extended to parse ?status= so the error-rate tile drill-in (?status=Failed) auto-loads the grid. Tests: - AuditLogRepositoryTests: GetKpiSnapshotAsync mixed-status + empty-window cases against the MSSQL migration fixture. - AuditLogQueryServiceTests: forwarding + backlog composition; sites with null SiteAuditBacklog contribute zero. - AuditKpiTilesTests: 9 bUnit tests covering tile render, error-rate maths with safe zero-events handling, em-dash unavailable path, click-through navigation, and warning/danger border thresholds. - HealthPageTests: new Renders_AuditKpiTiles_WithValues plus IAuditLogQueryService stub registration in the constructor so existing outbox tests still pass. - AuditLogPageScaffoldTests: ?status=Failed auto-load + unknown status drop.
159 lines
6.3 KiB
C#
159 lines
6.3 KiB
C#
using Bunit;
|
|
using Bunit.TestDoubles;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ScadaLink.CentralUI.Components.Health;
|
|
using ScadaLink.Commons.Types;
|
|
|
|
namespace ScadaLink.CentralUI.Tests.Components.Health;
|
|
|
|
/// <summary>
|
|
/// bUnit tests for <see cref="AuditKpiTiles"/> (#23 M7 Bundle E / M7-T13). The
|
|
/// component renders three Bootstrap-card tiles — Volume, Error Rate, Backlog —
|
|
/// from a single <see cref="AuditLogKpiSnapshot"/>. The tests pin:
|
|
///
|
|
/// <list type="bullet">
|
|
/// <item>Three-tile render contract (data-test attributes for stable selectors).</item>
|
|
/// <item>Error-rate maths: <c>ErrorEventsLastHour / TotalEventsLastHour</c> with
|
|
/// safe zero-events handling (no DivideByZero, displays "0.0%").</item>
|
|
/// <item>Unavailable snapshot renders em dashes plus the error message.</item>
|
|
/// <item>Tile clicks navigate to the correct pre-filtered Audit Log URL.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
public class AuditKpiTilesTests : BunitContext
|
|
{
|
|
private static AuditLogKpiSnapshot MakeSnapshot(long total, long errors, long backlog) =>
|
|
new(total, errors, backlog, new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc));
|
|
|
|
[Fact]
|
|
public void Renders_ThreeTiles_FromSnapshot()
|
|
{
|
|
var cut = Render<AuditKpiTiles>(p => p
|
|
.Add(c => c.Snapshot, MakeSnapshot(total: 120, errors: 3, backlog: 7))
|
|
.Add(c => c.IsAvailable, true));
|
|
|
|
// Three stable data-test selectors — these are the contract for both
|
|
// tests and any future Playwright sweep.
|
|
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
|
|
Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup);
|
|
Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup);
|
|
|
|
// Tile values render the snapshot's counters.
|
|
Assert.Contains("120", cut.Markup); // volume
|
|
Assert.Contains("7", cut.Markup); // backlog
|
|
}
|
|
|
|
[Fact]
|
|
public void ErrorRate_Computed_From_Total_AndErrors()
|
|
{
|
|
// 5 errors out of 100 → 5.0%.
|
|
var cut = Render<AuditKpiTiles>(p => p
|
|
.Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 5, backlog: 0))
|
|
.Add(c => c.IsAvailable, true));
|
|
|
|
Assert.Contains("5.0%", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public void ZeroEvents_DoesNotDivideByZero_RendersZeroPercent()
|
|
{
|
|
// Total = 0 → naïve division would throw or yield NaN. The tile must
|
|
// render "0.0%" instead (zero events means zero errors too — a real
|
|
// signal, not an unavailability marker).
|
|
var cut = Render<AuditKpiTiles>(p => p
|
|
.Add(c => c.Snapshot, MakeSnapshot(total: 0, errors: 0, backlog: 0))
|
|
.Add(c => c.IsAvailable, true));
|
|
|
|
Assert.Contains("0.0%", cut.Markup);
|
|
// And the volume tile shows "0", not an em dash — the snapshot itself
|
|
// is available; the system was just quiet for the hour.
|
|
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
|
|
{
|
|
var cut = Render<AuditKpiTiles>(p => p
|
|
.Add(c => c.Snapshot, (AuditLogKpiSnapshot?)null)
|
|
.Add(c => c.IsAvailable, false)
|
|
.Add(c => c.ErrorMessage, "DB connection refused"));
|
|
|
|
// All three tiles show em dashes — em dash (U+2014) "—" must appear.
|
|
Assert.Contains("—", cut.Markup);
|
|
// Inline error message renders below.
|
|
Assert.Contains("Audit KPIs unavailable", cut.Markup);
|
|
Assert.Contains("DB connection refused", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public void ErrorRateTile_Click_NavigatesToAuditLog_WithFailedStatusFilter()
|
|
{
|
|
var cut = Render<AuditKpiTiles>(p => p
|
|
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0))
|
|
.Add(c => c.IsAvailable, true));
|
|
|
|
// bUnit's BunitNavigationManager records the last URI a Navigation.NavigateTo call hit.
|
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
|
|
|
var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
|
|
tile.Click();
|
|
|
|
// Spec: error-rate tile drills into ?status=Failed.
|
|
Assert.Contains("/audit/log?status=Failed", nav.Uri);
|
|
}
|
|
|
|
[Fact]
|
|
public void VolumeTile_Click_NavigatesToUnfilteredAuditLog()
|
|
{
|
|
var cut = Render<AuditKpiTiles>(p => p
|
|
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0))
|
|
.Add(c => c.IsAvailable, true));
|
|
|
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
|
var tile = cut.Find("[data-test=\"audit-kpi-volume\"]");
|
|
tile.Click();
|
|
|
|
// Unfiltered /audit/log — no query string.
|
|
Assert.EndsWith("/audit/log", nav.Uri);
|
|
}
|
|
|
|
[Fact]
|
|
public void BacklogTile_Click_NavigatesToAuditLog()
|
|
{
|
|
var cut = Render<AuditKpiTiles>(p => p
|
|
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 0, backlog: 12))
|
|
.Add(c => c.IsAvailable, true));
|
|
|
|
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
|
var tile = cut.Find("[data-test=\"audit-kpi-backlog\"]");
|
|
tile.Click();
|
|
|
|
Assert.EndsWith("/audit/log", nav.Uri);
|
|
}
|
|
|
|
[Fact]
|
|
public void NonzeroErrorRate_GetsWarningBorder_NotDangerBelowTenPercent()
|
|
{
|
|
// 5% is < 10% → warning border, not danger.
|
|
var cut = Render<AuditKpiTiles>(p => p
|
|
.Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 5, backlog: 0))
|
|
.Add(c => c.IsAvailable, true));
|
|
|
|
var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
|
|
Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty);
|
|
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
|
|
}
|
|
|
|
[Fact]
|
|
public void HighErrorRate_GetsDangerBorder()
|
|
{
|
|
// 25% is > 10% → danger border.
|
|
var cut = Render<AuditKpiTiles>(p => p
|
|
.Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 25, backlog: 0))
|
|
.Add(c => c.IsAvailable, true));
|
|
|
|
var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
|
|
Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty);
|
|
}
|
|
}
|