60 KiB
Notifications Nav Group Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Consolidate all notification-related Central UI pages under a new Notifications left-menu section, split the combined Outbox page into a report page and a KPIs page, give Notification Lists its own page, and add a per-source-site KPI breakdown.
Architecture: Four pages move to a consistent /notifications/* route prefix and a new Notifications nav section. The existing Monitoring/NotificationOutbox.razor (KPI tiles + filterable table on one page) is split into NotificationReport.razor (table) and NotificationKpis.razor (tiles + per-site table). The KPI page's per-site breakdown is backed by a bounded, additive backend chain: a new repository method, message contract pair, actor handler, and CommunicationService method — each mirroring the existing global-KPI equivalent.
Tech Stack: .NET 10, Blazor Server + Bootstrap, Akka.NET (cluster singleton actor, TestKit), EF Core (MS SQL; SQLite in-memory for tests), xUnit + NSubstitute + bUnit.
Design doc: docs/plans/2026-05-19-notifications-nav-group-design.md
Conventions for every task
- TDD: write the failing test first, watch it fail, implement, watch it pass.
- Commit with explicit paths only. NEVER
git add -Aorgit add .. There are pre-existing uncommitted files underinfra/that MUST NOT be committed — alwaysgit add <explicit paths>. - Build the solution with
dotnet build ScadaLink.slnx.TreatWarningsAsErrorsis on — zero warnings. - Run a single test project with
dotnet test tests/<Project>/<Project>.csproj. - Entity/domain POCOs live in
ScadaLink.Commons; EF fluent configs inScadaLink.ConfigurationDatabase. - Message records follow additive-only evolution — only add new record types, never reorder/remove members of existing ones.
Task 1: Per-site KPI domain type + repository interface method
Files:
- Create:
src/ScadaLink.Commons/Types/Notifications/SiteNotificationKpiSnapshot.cs - Modify:
src/ScadaLink.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs - Test:
tests/ScadaLink.Commons.Tests/Types/SiteNotificationKpiSnapshotTests.cs
Step 1: Write the failing test
using ScadaLink.Commons.Types.Notifications;
namespace ScadaLink.Commons.Tests.Types;
public class SiteNotificationKpiSnapshotTests
{
[Fact]
public void Constructor_AssignsAllMembers()
{
var snapshot = new SiteNotificationKpiSnapshot(
SourceSiteId: "plant-a",
QueueDepth: 5,
StuckCount: 2,
ParkedCount: 1,
DeliveredLastInterval: 40,
OldestPendingAge: TimeSpan.FromMinutes(12));
Assert.Equal("plant-a", snapshot.SourceSiteId);
Assert.Equal(5, snapshot.QueueDepth);
Assert.Equal(2, snapshot.StuckCount);
Assert.Equal(1, snapshot.ParkedCount);
Assert.Equal(40, snapshot.DeliveredLastInterval);
Assert.Equal(TimeSpan.FromMinutes(12), snapshot.OldestPendingAge);
}
[Fact]
public void OldestPendingAge_IsNullableForSitesWithNoBacklog()
{
var snapshot = new SiteNotificationKpiSnapshot("plant-b", 0, 0, 0, 0, null);
Assert.Null(snapshot.OldestPendingAge);
}
}
Step 2: Run test to verify it fails
Run: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter SiteNotificationKpiSnapshotTests
Expected: FAIL — SiteNotificationKpiSnapshot does not exist.
Step 3: Write minimal implementation
SiteNotificationKpiSnapshot.cs:
namespace ScadaLink.Commons.Types.Notifications;
/// <summary>
/// Point-in-time notification-outbox metrics scoped to a single source site.
/// The per-site counterpart of <see cref="NotificationKpiSnapshot"/>; surfaced
/// in the per-site breakdown table on the Notification KPIs page.
/// </summary>
/// <param name="SourceSiteId">The site identifier these metrics are scoped to.</param>
/// <param name="QueueDepth">Count of this site's non-terminal rows (Pending + Retrying).</param>
/// <param name="StuckCount">
/// Count of this site's non-terminal rows whose <c>CreatedAt</c> is older than the stuck cutoff.
/// </param>
/// <param name="ParkedCount">Count of this site's rows in the Parked status.</param>
/// <param name="DeliveredLastInterval">
/// Count of this site's Delivered rows whose <c>DeliveredAt</c> is at or after the
/// "delivered since" timestamp.
/// </param>
/// <param name="OldestPendingAge">
/// Age of this site's oldest non-terminal row, or <c>null</c> when it has none.
/// </param>
public record SiteNotificationKpiSnapshot(
string SourceSiteId,
int QueueDepth,
int StuckCount,
int ParkedCount,
int DeliveredLastInterval,
TimeSpan? OldestPendingAge);
Add to INotificationOutboxRepository.cs, immediately after the ComputeKpisAsync method:
/// <summary>
/// Computes a point-in-time <see cref="SiteNotificationKpiSnapshot"/> per source site.
/// Sites with no notification rows at all are omitted. The stuck and delivered cutoffs
/// are supplied by the caller; the current time used for <c>OldestPendingAge</c> is
/// captured inside the method.
/// </summary>
Task<IReadOnlyList<SiteNotificationKpiSnapshot>> ComputePerSiteKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default);
Step 4: Run test to verify it passes
Run: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter SiteNotificationKpiSnapshotTests
Expected: PASS. (ScadaLink.ConfigurationDatabase will not yet compile — the interface method is unimplemented. That is fixed in Task 2; do not build the whole solution at this step.)
Step 5: Commit
git add src/ScadaLink.Commons/Types/Notifications/SiteNotificationKpiSnapshot.cs \
src/ScadaLink.Commons/Interfaces/Repositories/INotificationOutboxRepository.cs \
tests/ScadaLink.Commons.Tests/Types/SiteNotificationKpiSnapshotTests.cs
git commit -m "feat(notification-outbox): per-site KPI snapshot type + repository contract"
Task 2: ComputePerSiteKpisAsync repository implementation
Files:
- Modify:
src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs - Test:
tests/ScadaLink.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerSiteKpiTests.cs
Context: NotificationOutboxRepository is an EF Core repository over ScadaLinkDbContext.Notifications. The existing global ComputeKpisAsync (in the same file) shows the metric definitions and the DateTimeOffset-min workaround. The Notification entity has a SourceSiteId string and a Status (NotificationStatus enum: Pending, Retrying, Delivered, Parked, Discarded). Look at an existing repository test in tests/ScadaLink.ConfigurationDatabase.Tests/ to copy the SQLite-in-memory ScadaLinkDbContext setup pattern.
Step 1: Write the failing test
Mirror the existing repository-test setup (in-memory SQLite ScadaLinkDbContext). Seed Notification rows across two sites and assert the per-site aggregation:
[Fact]
public async Task ComputePerSiteKpisAsync_AggregatesMetricsPerSite()
{
await using var ctx = NewContext(); // copy the in-memory-SQLite helper used by sibling tests
var now = DateTimeOffset.UtcNow;
// plant-a: 1 pending (stuck, created 20m ago), 1 parked
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Pending, createdAt: now.AddMinutes(-20)));
ctx.Notifications.Add(NewNotification("plant-a", NotificationStatus.Parked, createdAt: now.AddMinutes(-5)));
// plant-b: 1 delivered in-window, 1 pending (fresh)
ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Delivered, createdAt: now.AddHours(-2), deliveredAt: now.AddMinutes(-2)));
ctx.Notifications.Add(NewNotification("plant-b", NotificationStatus.Pending, createdAt: now.AddMinutes(-1)));
await ctx.SaveChangesAsync();
var repo = new NotificationOutboxRepository(ctx);
var result = await repo.ComputePerSiteKpisAsync(
stuckCutoff: now.AddMinutes(-10), deliveredSince: now.AddMinutes(-30));
var a = result.Single(s => s.SourceSiteId == "plant-a");
Assert.Equal(1, a.QueueDepth);
Assert.Equal(1, a.StuckCount);
Assert.Equal(1, a.ParkedCount);
Assert.Equal(0, a.DeliveredLastInterval);
Assert.NotNull(a.OldestPendingAge);
var b = result.Single(s => s.SourceSiteId == "plant-b");
Assert.Equal(1, b.QueueDepth);
Assert.Equal(0, b.StuckCount);
Assert.Equal(1, b.DeliveredLastInterval);
}
[Fact]
public async Task ComputePerSiteKpisAsync_ReturnsEmpty_WhenNoNotifications()
{
await using var ctx = NewContext();
var repo = new NotificationOutboxRepository(ctx);
var result = await repo.ComputePerSiteKpisAsync(
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(-30));
Assert.Empty(result);
}
Define the NewContext() and NewNotification(...) helpers by copying the conventions from the nearest existing NotificationOutboxRepository test (check RepositoryCoverageTests.cs for how Notification rows are constructed).
Step 2: Run test to verify it fails
Run: dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj --filter NotificationOutboxRepositoryPerSiteKpiTests
Expected: FAIL — ComputePerSiteKpisAsync not implemented.
Step 3: Write minimal implementation
Add to NotificationOutboxRepository.cs, after ComputeKpisAsync:
public async Task<IReadOnlyList<SiteNotificationKpiSnapshot>> ComputePerSiteKpisAsync(
DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var queueDepth = await CountBySiteAsync(
n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying,
cancellationToken);
var stuck = await CountBySiteAsync(
n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
&& n.CreatedAt < stuckCutoff,
cancellationToken);
var parked = await CountBySiteAsync(
n => n.Status == NotificationStatus.Parked, cancellationToken);
var delivered = await CountBySiteAsync(
n => n.Status == NotificationStatus.Delivered
&& n.DeliveredAt != null && n.DeliveredAt >= deliveredSince,
cancellationToken);
// Oldest non-terminal CreatedAt per site. A SQL Min over the DateTimeOffset
// converter is awkward (see ComputeKpisAsync), so project the non-terminal
// (site, created) pairs — the live queue, which stays bounded — and reduce
// in memory.
var oldest = (await _context.Notifications
.Where(n => n.Status == NotificationStatus.Pending
|| n.Status == NotificationStatus.Retrying)
.Select(n => new { n.SourceSiteId, n.CreatedAt })
.ToListAsync(cancellationToken))
.GroupBy(x => x.SourceSiteId)
.ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt));
var siteIds = queueDepth.Keys
.Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys)
.Distinct()
.OrderBy(s => s, StringComparer.Ordinal);
return siteIds.Select(site => new SiteNotificationKpiSnapshot(
SourceSiteId: site,
QueueDepth: queueDepth.GetValueOrDefault(site),
StuckCount: stuck.GetValueOrDefault(site),
ParkedCount: parked.GetValueOrDefault(site),
DeliveredLastInterval: delivered.GetValueOrDefault(site),
OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
? now - createdAt
: null)).ToList();
}
/// <summary>Counts notification rows matching <paramref name="predicate"/>, grouped by source site.</summary>
private async Task<Dictionary<string, int>> CountBySiteAsync(
System.Linq.Expressions.Expression<Func<Notification, bool>> predicate,
CancellationToken cancellationToken)
{
return await _context.Notifications
.Where(predicate)
.GroupBy(n => n.SourceSiteId)
.Select(g => new { Site = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Site, x => x.Count, cancellationToken);
}
Step 4: Run test to verify it passes
Run: dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj --filter NotificationOutboxRepositoryPerSiteKpiTests
Expected: PASS.
Step 5: Commit
git add src/ScadaLink.ConfigurationDatabase/Repositories/NotificationOutboxRepository.cs \
tests/ScadaLink.ConfigurationDatabase.Tests/NotificationOutboxRepositoryPerSiteKpiTests.cs
git commit -m "feat(notification-outbox): per-site KPI aggregation in the repository"
Task 3: Per-site KPI message contracts
Files:
- Modify:
src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs - Test:
tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs(add cases)
Context: NotificationOutboxQueries.cs already holds NotificationKpiRequest / NotificationKpiResponse. The per-site response carries the SiteNotificationKpiSnapshot records directly (no flattening — the type is a clean serializable record in Commons). Additive-only: append new records, do not touch existing ones.
Step 1: Write the failing test
Add to NotificationMessagesTests.cs:
[Fact]
public void PerSiteNotificationKpiRequest_CarriesCorrelationId()
{
var request = new PerSiteNotificationKpiRequest("corr-1");
Assert.Equal("corr-1", request.CorrelationId);
}
[Fact]
public void PerSiteNotificationKpiResponse_CarriesPerSiteSnapshots()
{
var sites = new[]
{
new SiteNotificationKpiSnapshot("plant-a", 3, 1, 0, 10, TimeSpan.FromMinutes(4)),
};
var response = new PerSiteNotificationKpiResponse("corr-1", Success: true, ErrorMessage: null, sites);
Assert.True(response.Success);
Assert.Null(response.ErrorMessage);
Assert.Single(response.Sites);
Assert.Equal("plant-a", response.Sites[0].SourceSiteId);
}
Add using ScadaLink.Commons.Types.Notifications; to the test file if not present.
Step 2: Run test to verify it fails
Run: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter NotificationMessagesTests
Expected: FAIL — the two records do not exist.
Step 3: Write minimal implementation
Append to NotificationOutboxQueries.cs (add using ScadaLink.Commons.Types.Notifications; at the top if absent):
/// <summary>
/// Outbox UI -> Central: request for the per-source-site notification outbox KPI breakdown.
/// </summary>
public record PerSiteNotificationKpiRequest(
string CorrelationId);
/// <summary>
/// Central -> Outbox UI: per-site KPI breakdown for the Notification KPIs page.
/// On a repository fault <see cref="Success"/> is <c>false</c>, <see cref="ErrorMessage"/>
/// carries the cause, and <see cref="Sites"/> is empty.
/// </summary>
public record PerSiteNotificationKpiResponse(
string CorrelationId,
bool Success,
string? ErrorMessage,
IReadOnlyList<SiteNotificationKpiSnapshot> Sites);
Step 4: Run test to verify it passes
Run: dotnet test tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj --filter NotificationMessagesTests
Expected: PASS.
Step 5: Commit
git add src/ScadaLink.Commons/Messages/Notification/NotificationOutboxQueries.cs \
tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs
git commit -m "feat(notification-outbox): per-site KPI request/response message contracts"
Task 4: NotificationOutboxActor per-site KPI handler
Files:
- Modify:
src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs - Test:
tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs(add cases)
Context: The actor registers handlers in its constructor and resolves INotificationOutboxRepository per-request from a fresh DI scope (_serviceProvider.CreateScope()) to avoid a captive dependency. HandleKpiRequest / ComputeKpisAsync are the exact template to mirror. StuckCutoff(now) and _options.DeliveredKpiWindow already exist. NotificationOutboxActorQueryTests.cs shows how the actor is spun up with a substituted repository.
Step 1: Write the failing test
Add to NotificationOutboxActorQueryTests.cs, following the existing KPI-request test in that file:
[Fact]
public void PerSiteKpiRequest_RepliesWithPerSiteSnapshots()
{
var repo = Substitute.For<INotificationOutboxRepository>();
repo.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(new List<SiteNotificationKpiSnapshot>
{
new("plant-a", 4, 1, 0, 9, TimeSpan.FromMinutes(7)),
});
var actor = CreateActor(repo); // reuse the helper this test class already uses
actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor);
var response = ExpectMsg<PerSiteNotificationKpiResponse>();
Assert.True(response.Success);
Assert.Equal("corr-ps", response.CorrelationId);
Assert.Single(response.Sites);
Assert.Equal("plant-a", response.Sites[0].SourceSiteId);
}
[Fact]
public void PerSiteKpiRequest_RepositoryFault_RepliesUnsuccessful()
{
var repo = Substitute.For<INotificationOutboxRepository>();
repo.ComputePerSiteKpisAsync(Arg.Any<DateTimeOffset>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<SiteNotificationKpiSnapshot>>(_ => throw new InvalidOperationException("db down"));
var actor = CreateActor(repo);
actor.Tell(new PerSiteNotificationKpiRequest("corr-ps"), TestActor);
var response = ExpectMsg<PerSiteNotificationKpiResponse>();
Assert.False(response.Success);
Assert.Contains("db down", response.ErrorMessage);
Assert.Empty(response.Sites);
}
Match the seam the existing KPI test uses (the CreateActor helper name may differ — copy whatever the sibling KPI test does).
Step 2: Run test to verify it fails
Run: dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj --filter NotificationOutboxActorQueryTests
Expected: FAIL — PerSiteNotificationKpiRequest is unhandled (TestKit reports an unexpected message / no reply).
Step 3: Write minimal implementation
In the constructor of NotificationOutboxActor, after the Receive<NotificationKpiRequest>(HandleKpiRequest); line:
Receive<PerSiteNotificationKpiRequest>(HandlePerSiteKpiRequest);
After the ComputeKpisAsync method, add:
/// <summary>
/// Handles a per-site KPI request, computing the per-source-site outbox metrics with the
/// same stuck cutoff and delivered window as <see cref="HandleKpiRequest"/>.
/// </summary>
private void HandlePerSiteKpiRequest(PerSiteNotificationKpiRequest request)
{
var sender = Sender;
var now = DateTimeOffset.UtcNow;
var stuckCutoff = StuckCutoff(now);
var deliveredSince = now - _options.DeliveredKpiWindow;
ComputePerSiteKpisAsync(request.CorrelationId, stuckCutoff, deliveredSince).PipeTo(
sender,
success: response => response,
failure: ex => new PerSiteNotificationKpiResponse(
request.CorrelationId,
Success: false,
ErrorMessage: ex.GetBaseException().Message,
Sites: Array.Empty<SiteNotificationKpiSnapshot>()));
}
private async Task<PerSiteNotificationKpiResponse> ComputePerSiteKpisAsync(
string correlationId, DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince)
{
using var scope = _serviceProvider.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
var sites = await repository.ComputePerSiteKpisAsync(stuckCutoff, deliveredSince);
return new PerSiteNotificationKpiResponse(correlationId, Success: true, ErrorMessage: null, sites);
}
Step 4: Run test to verify it passes
Run: dotnet test tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj --filter NotificationOutboxActorQueryTests
Expected: PASS.
Step 5: Commit
git add src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs \
tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorQueryTests.cs
git commit -m "feat(notification-outbox): actor handler for per-site KPI requests"
Task 5: CommunicationService.GetPerSiteNotificationKpisAsync
Files:
- Modify:
src/ScadaLink.Communication/CommunicationService.cs - Test:
tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs(add a case)
Context: CommunicationService has a "Notification Outbox" region. GetNotificationKpisAsync is the exact template — it Asks the notification-outbox actor proxy (GetNotificationOutbox()) with _options.QueryTimeout. The test class wires a real lightweight ActorSystem with a scripted actor (the same SetNotificationOutbox seam the outbox page tests use).
Step 1: Write the failing test
Add to CommunicationServiceTests.cs, mirroring the existing GetNotificationKpisAsync test:
[Fact]
public async Task GetPerSiteNotificationKpisAsync_RoundTripsThroughTheOutboxActor()
{
var expected = new PerSiteNotificationKpiResponse("corr-ps", true, null,
new[] { new SiteNotificationKpiSnapshot("plant-a", 2, 0, 0, 5, null) });
// Scripted actor replies with `expected` to any PerSiteNotificationKpiRequest —
// copy the scripted-actor pattern used by the sibling KPI test.
var service = CreateServiceWithScriptedOutbox<PerSiteNotificationKpiRequest>(_ => expected);
var actual = await service.GetPerSiteNotificationKpisAsync(new PerSiteNotificationKpiRequest("corr-ps"));
Assert.True(actual.Success);
Assert.Single(actual.Sites);
Assert.Equal("plant-a", actual.Sites[0].SourceSiteId);
}
If the test class uses an inline scripted ReceiveActor rather than a CreateServiceWith... helper, follow that existing shape instead — the point is a scripted reply to PerSiteNotificationKpiRequest.
Step 2: Run test to verify it fails
Run: dotnet test tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj --filter GetPerSiteNotificationKpisAsync
Expected: FAIL — GetPerSiteNotificationKpisAsync does not exist.
Step 3: Write minimal implementation
In CommunicationService.cs, in the Notification Outbox region, after GetNotificationKpisAsync:
public async Task<PerSiteNotificationKpiResponse> GetPerSiteNotificationKpisAsync(
PerSiteNotificationKpiRequest request, CancellationToken cancellationToken = default)
{
return await GetNotificationOutbox().Ask<PerSiteNotificationKpiResponse>(
request, _options.QueryTimeout, cancellationToken);
}
Step 4: Run test to verify it passes
Run: dotnet test tests/ScadaLink.Communication.Tests/ScadaLink.Communication.Tests.csproj --filter GetPerSiteNotificationKpisAsync
Expected: PASS.
Step 5: Build the whole solution to confirm the backend chain is clean.
Run: dotnet build ScadaLink.slnx
Expected: Build succeeded, 0 warnings.
Step 6: Commit
git add src/ScadaLink.Communication/CommunicationService.cs \
tests/ScadaLink.Communication.Tests/CommunicationServiceTests.cs
git commit -m "feat(notification-outbox): CommunicationService per-site KPI accessor"
Task 6: Move SMTP Configuration page to /notifications/smtp
Files:
- Move:
src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor→src/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor - Move (if present):
Admin/SmtpConfiguration.razor.cs→Notifications/SmtpConfiguration.razor.cs - Modify:
tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs:30
Context: The page currently declares @page "/admin/smtp" and lives in the Admin Pages folder. Only the route and folder/namespace change — content and RequireAdmin policy stay. The Razor namespace follows the folder, so a moved .razor resolves to ScadaLink.CentralUI.Components.Pages.Notifications. There is no test that renders this page directly; the only reference is the Playwright NavigationTests inline data.
Step 1: Create the Components/Pages/Notifications/ folder by writing the moved file there.
git mv the file:
git mv src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor \
src/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor
If a code-behind SmtpConfiguration.razor.cs exists, git mv it too and update its namespace to ScadaLink.CentralUI.Components.Pages.Notifications.
Step 2: In the moved Notifications/SmtpConfiguration.razor, change line 1 from:
@page "/admin/smtp"
to:
@page "/notifications/smtp"
Step 3: Update tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs line 30 — change the inline data from:
[InlineData("SMTP Configuration", "/admin/smtp")]
to:
[InlineData("SMTP Configuration", "/notifications/smtp")]
Step 4: Build to verify
Run: dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj
Expected: Build succeeded, 0 warnings.
Step 5: Commit
git add src/ScadaLink.CentralUI/Components/Pages/Admin/SmtpConfiguration.razor \
src/ScadaLink.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor \
tests/ScadaLink.CentralUI.PlaywrightTests/NavigationTests.cs
git commit -m "refactor(central-ui): move SMTP Configuration page to /notifications/smtp"
(Include the code-behind paths in git add if one was moved.)
Task 7: New Notification Lists page
Files:
- Create:
src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationLists.razor - Test:
tests/ScadaLink.CentralUI.Tests/Pages/NotificationListsPageTests.cs
Context: Notification lists are currently shown only inside the "Notification Lists" tab of Components/Pages/Design/ExternalSystems.razor (the RenderNotificationLists render fragment, lines ~301-380). This task creates a standalone page; Task 8 removes that tab. INotificationRepository exposes GetAllNotificationListsAsync(), GetRecipientsByListIdAsync(int), DeleteNotificationListAsync(int), SaveChangesAsync(). NotificationList has Id and Name; NotificationRecipient has Name and EmailAddress. The page is RequireDesign. Look at NotificationOutboxPageTests.cs for the bUnit AuthorizeView + DI wiring pattern (substituted repositories, a TestAuthorizationContext-style claims principal).
Step 1: Write the failing test
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Interfaces.Repositories;
using NotificationListsPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationLists;
namespace ScadaLink.CentralUI.Tests.Pages;
public class NotificationListsPageTests : BunitContext
{
[Fact]
public void RendersNotificationListRows()
{
var repo = Substitute.For<INotificationRepository>();
repo.GetAllNotificationListsAsync()
.Returns(new List<NotificationList> { new("Ops On-Call") { Id = 1 } });
repo.GetRecipientsByListIdAsync(1)
.Returns(new List<NotificationRecipient> { new("Jane", "jane@example.com") });
Services.AddSingleton(repo);
// wire IDialogService + an authorized Design-role principal — copy
// NotificationOutboxPageTests' auth/DI setup.
var cut = Render<NotificationListsPage>();
Assert.Contains("Ops On-Call", cut.Markup);
}
[Fact]
public void ShowsEmptyState_WhenNoLists()
{
var repo = Substitute.For<INotificationRepository>();
repo.GetAllNotificationListsAsync().Returns(new List<NotificationList>());
Services.AddSingleton(repo);
// ...same auth/DI setup...
var cut = Render<NotificationListsPage>();
Assert.Contains("No notification lists", cut.Markup);
}
}
Step 2: Run test to verify it fails
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationListsPageTests
Expected: FAIL — NotificationLists page does not exist.
Step 3: Write minimal implementation
NotificationLists.razor:
@page "/notifications/lists"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Notifications
@using ScadaLink.Commons.Interfaces.Repositories
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject INotificationRepository NotificationRepository
@inject NavigationManager NavigationManager
@inject IDialogService Dialog
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Notification Lists</h4>
<button class="btn btn-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo("/notifications/lists/create")'>
Add Notification List
</button>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_errorMessage != null)
{
<div class="alert alert-danger">@_errorMessage</div>
}
else if (_lists.Count == 0)
{
<div class="card">
<div class="card-body text-center text-muted py-5">
<div class="fs-5 mb-2">No notification lists</div>
<button class="btn btn-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo("/notifications/lists/create")'>
Add your first notification list
</button>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Recipients</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var list in _lists)
{
var recipients = _recipients.GetValueOrDefault(list.Id) ?? new();
<tr @key="list.Id">
<td>@list.Name</td>
<td>
@if (recipients.Count == 0)
{
<span class="text-muted small fst-italic">No recipients</span>
}
else
{
@foreach (var r in recipients)
{
<span class="badge bg-light text-dark me-1 mb-1">@r.Name <@r.EmailAddress></span>
}
}
</td>
<td class="text-end">
<button class="btn btn-outline-primary btn-sm me-1"
@onclick='() => NavigationManager.NavigateTo($"/notifications/lists/{list.Id}/edit")'>
Edit
</button>
<button class="btn btn-outline-danger btn-sm"
@onclick="() => DeleteList(list)">
Delete
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@code {
private bool _loading = true;
private string? _errorMessage;
private List<NotificationList> _lists = new();
private readonly Dictionary<int, List<NotificationRecipient>> _recipients = new();
private ToastNotification _toast = default!;
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
_loading = true;
_errorMessage = null;
try
{
_lists = (await NotificationRepository.GetAllNotificationListsAsync()).ToList();
_recipients.Clear();
foreach (var list in _lists)
{
var recips = await NotificationRepository.GetRecipientsByListIdAsync(list.Id);
_recipients[list.Id] = recips.ToList();
}
}
catch (Exception ex)
{
_errorMessage = ex.Message;
}
_loading = false;
}
private async Task DeleteList(NotificationList list)
{
if (!await Dialog.ConfirmAsync("Delete", $"Delete notification list '{list.Name}'?", danger: true))
{
return;
}
try
{
await NotificationRepository.DeleteNotificationListAsync(list.Id);
await NotificationRepository.SaveChangesAsync();
_toast.ShowSuccess("Deleted.");
await LoadAsync();
}
catch (Exception ex)
{
_toast.ShowError(ex.Message);
}
}
}
Step 4: Run test to verify it passes
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationListsPageTests
Expected: PASS.
Step 5: Commit
git add src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationLists.razor \
tests/ScadaLink.CentralUI.Tests/Pages/NotificationListsPageTests.cs
git commit -m "feat(central-ui): standalone Notification Lists page"
Task 8: Move the Notification List form route; drop the External Systems tab
Files:
- Modify:
src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor - Modify:
src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor
Context: NotificationListForm.razor has two @page routes under /design/notification-lists/... and navigates back to /design/external-systems (GoBack() and the post-Save() redirect). ExternalSystems.razor is a 4-tab page ("Integration Definitions"): External Systems, Database Connections, Notification Lists, Inbound API Methods. Removing the Notification Lists tab leaves three. This task has no behavioral test of its own (Task 7 covers the new lists page); verify by build + a render-smoke check.
Step 1: Re-point the form routes. In NotificationListForm.razor, change lines 1-2:
@page "/design/notification-lists/create"
@page "/design/notification-lists/{Id:int}/edit"
to:
@page "/notifications/lists/create"
@page "/notifications/lists/{Id:int}/edit"
Step 2: Re-point the form's back-navigation. In NotificationListForm.razor:
- In
Save(), changeNavigationManager.NavigateTo("/design/external-systems");toNavigationManager.NavigateTo("/notifications/lists"); - In
GoBack(), changeNavigationManager.NavigateTo("/design/external-systems");toNavigationManager.NavigateTo("/notifications/lists");
Step 3: Remove the Notification Lists tab from ExternalSystems.razor. Delete, from that file:
- The tab
<li>button (the_tab == "notif"nav-item, ~lines 50-58). - The tab-panel branch
else if (_tab == "notif") { ... }(~lines 78-81). - The
RenderNotificationLists()render fragment method (~lines 301-373). - The
DeleteNotifList(...)method (~lines 375-380). - The Notification Lists
@codefields:_notificationLists,_recipients,_notifSearch,FilteredNotificationLists(~lines 110-117). - In
LoadAllAsync(), the lines that populate_notificationListsand the_recipientsloop (~lines 141-148). - The now-unused
@inject INotificationRepository NotificationRepository(line 9) and@using ScadaLink.Commons.Entities.Notifications(line 4) — remove only if nothing else in the file still uses them (verify by search).
Step 4: Build to verify
Run: dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj
Expected: Build succeeded, 0 warnings. (A warning here usually means a leftover unused using/@inject — remove it.)
Step 5: Commit
git add src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor \
src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor
git commit -m "refactor(central-ui): move Notification List form to /notifications, drop External Systems tab"
Task 9: New Notification Report page; retire the Outbox page
Files:
- Create:
src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor - Delete:
src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor - Rename:
tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs→NotificationReportPageTests.cs
Context: Monitoring/NotificationOutbox.razor currently combines KPI tiles (markup lines 23-72) with a filterable, paginated notifications table + Retry/Discard (lines 74-253). The new NotificationReport.razor keeps everything except the KPI tiles. Its existing test, NotificationOutboxPageTests.cs, renders the page via a scripted Akka actor seam and asserts on both KPI tiles and table rows.
Step 1: Create NotificationReport.razor as a copy of Monitoring/NotificationOutbox.razor with these changes:
- Line 1 route:
@page "/monitoring/notification-outbox"→@page "/notifications/report". - The
@inject ILogger<NotificationOutbox> Loggertype argument →ILogger<NotificationReport>(the class name follows the file). - Page heading
<h4 ...>Notification Outbox</h4>→<h4 ...>Notification Report</h4>. - Delete the KPI tiles markup block — the
@* ── KPI tiles ── *@comment through the closing of itselse { ... }(lines 23-72). - Delete the KPI
@codemembers:_kpi,_kpiError, and theLoadKpis()method. - Simplify
RefreshAll()— it currently doesawait Task.WhenAll(LoadKpis(), FetchPage());. Replace its body withawait FetchPage();(keep the method name so the Retry/Discard callers are untouched), or inlineFetchPage()at the three call sites and deleteRefreshAll(). KeepFormatAgeonly if still referenced; if the KPI removal orphans it, delete it (build will flag it underTreatWarningsAsErrors).
Step 2: Delete the old page
git rm src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor
Step 3: Migrate the test. Rename the test file and update it:
git mv tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs \
tests/ScadaLink.CentralUI.Tests/Pages/NotificationReportPageTests.cs
In NotificationReportPageTests.cs:
- Rename the class to
NotificationReportPageTests. - Change the page alias:
using NotificationReportPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationReport;and update render calls. - Delete the KPI-tile assertions (any test asserting on Queue Depth / Stuck / Parked tile values, and the
_kpiReplyfield if now unused — but_queryReply, retry/discard scripting stay). - Keep the table-rendering, filter, pagination, and Retry/Discard tests.
Step 4: Run test to verify it passes
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationReportPageTests
Expected: PASS.
Also build the UI project to confirm no dangling references to the deleted page:
Run: dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj
Expected: Build succeeded, 0 warnings.
Step 5: Commit
git add src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationReport.razor \
src/ScadaLink.CentralUI/Components/Pages/Monitoring/NotificationOutbox.razor \
tests/ScadaLink.CentralUI.Tests/Pages/NotificationOutboxPageTests.cs \
tests/ScadaLink.CentralUI.Tests/Pages/NotificationReportPageTests.cs
git commit -m "refactor(central-ui): split Notification Report out of the Outbox page"
Task 10: New Notification KPIs page
Files:
- Create:
src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationKpis.razor - Test:
tests/ScadaLink.CentralUI.Tests/Pages/NotificationKpisPageTests.cs
Context: This page shows the five global KPI tiles (lifted from the old Outbox page, markup lines 30-71) plus a per-source-site breakdown table backed by CommunicationService.GetPerSiteNotificationKpisAsync (Task 5). CommunicationService is a concrete class with non-virtual methods, so tests drive it through the scripted-actor seam — copy the ActorSystem + scripted ReceiveActor + SetNotificationOutbox setup verbatim from NotificationReportPageTests.cs (Task 9). The scripted actor must answer both NotificationKpiRequest and PerSiteNotificationKpiRequest. Site friendly-names come from ISiteRepository.GetAllSitesAsync() (Site.SiteIdentifier / Site.Name), exactly as the old Outbox page resolved the source-site filter.
Step 1: Write the failing test
// Copy the ActorSystem / scripted-actor / CommunicationService / ISiteRepository / auth
// wiring from NotificationReportPageTests. Script the actor to reply:
// NotificationKpiRequest -> a NotificationKpiResponse with known tile values
// PerSiteNotificationKpiRequest -> a PerSiteNotificationKpiResponse with one site row
[Fact]
public void RendersGlobalTilesAndPerSiteRows()
{
// _kpiReply: QueueDepth 7, StuckCount 2, ParkedCount 1, DeliveredLastInterval 42
// _perSiteReply: [ SiteNotificationKpiSnapshot("plant-a", 4, 1, 0, 9, 7m) ]
var cut = Render<NotificationKpisPage>();
Assert.Contains("Queue Depth", cut.Markup);
Assert.Contains("7", cut.Markup); // global tile value
Assert.Contains("plant-a", cut.Markup); // per-site row
}
[Fact]
public void ShowsKpiError_WhenGlobalKpiQueryFails()
{
// script NotificationKpiRequest -> response with Success:false, ErrorMessage:"kpi down"
var cut = Render<NotificationKpisPage>();
Assert.Contains("kpi down", cut.Markup);
}
[Fact]
public void ShowsPerSiteEmptyState_WhenNoSites()
{
// script PerSiteNotificationKpiRequest -> Success:true, empty Sites
var cut = Render<NotificationKpisPage>();
Assert.Contains("No per-site activity", cut.Markup);
}
Use using NotificationKpisPage = ScadaLink.CentralUI.Components.Pages.Notifications.NotificationKpis;.
Step 2: Run test to verify it fails
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationKpisPageTests
Expected: FAIL — NotificationKpis page does not exist.
Step 3: Write minimal implementation
NotificationKpis.razor:
@page "/notifications/kpis"
@attribute [Authorize(Policy = ScadaLink.Security.AuthorizationPolicies.RequireDeployment)]
@using ScadaLink.Commons.Entities.Sites
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Messages.Notification
@using ScadaLink.Commons.Types.Notifications
@using ScadaLink.Communication
@inject CommunicationService CommunicationService
@inject ISiteRepository SiteRepository
@inject ILogger<NotificationKpis> Logger
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Notification KPIs</h4>
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshAll" disabled="@_loading">
@if (_loading) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Refresh
</button>
</div>
@* ── Global KPI tiles ── *@
@if (_kpiError != null)
{
<div class="alert alert-warning py-2">KPIs unavailable: @_kpiError</div>
}
else
{
<div class="row g-3 mb-4">
<div class="col-lg col-md-4 col-6">
<div class="card h-100">
<div class="card-body text-center py-3">
<h3 class="mb-0">@_kpi.QueueDepth</h3>
<small class="text-muted">Queue Depth</small>
</div>
</div>
</div>
<div class="col-lg col-md-4 col-6">
<div class="card h-100 @(_kpi.StuckCount > 0 ? "border-warning" : "")">
<div class="card-body text-center py-3">
<h3 class="mb-0 @(_kpi.StuckCount > 0 ? "text-warning" : "")">@_kpi.StuckCount</h3>
<small class="text-muted">Stuck</small>
</div>
</div>
</div>
<div class="col-lg col-md-4 col-6">
<div class="card h-100 @(_kpi.ParkedCount > 0 ? "border-danger" : "")">
<div class="card-body text-center py-3">
<h3 class="mb-0 @(_kpi.ParkedCount > 0 ? "text-danger" : "")">@_kpi.ParkedCount</h3>
<small class="text-muted">Parked</small>
</div>
</div>
</div>
<div class="col-lg col-md-4 col-6">
<div class="card h-100">
<div class="card-body text-center py-3">
<h3 class="mb-0 text-success">@_kpi.DeliveredLastInterval</h3>
<small class="text-muted">Delivered (last interval)</small>
</div>
</div>
</div>
<div class="col-lg col-md-4 col-6">
<div class="card h-100">
<div class="card-body text-center py-3">
<h3 class="mb-0">@FormatAge(_kpi.OldestPendingAge)</h3>
<small class="text-muted">Oldest Pending Age</small>
</div>
</div>
</div>
</div>
}
@* ── Per-site breakdown ── *@
<h5 class="mb-2">Per-site breakdown</h5>
@if (_perSiteError != null)
{
<div class="alert alert-warning py-2">Per-site KPIs unavailable: @_perSiteError</div>
}
else if (_perSite.Count == 0)
{
<div class="card">
<div class="card-body text-center text-muted py-4">
<div class="small">No per-site activity.</div>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Site</th>
<th class="text-end">Queue Depth</th>
<th class="text-end">Stuck</th>
<th class="text-end">Parked</th>
<th class="text-end">Delivered (last interval)</th>
<th class="text-end">Oldest Pending Age</th>
</tr>
</thead>
<tbody>
@foreach (var s in _perSite)
{
<tr @key="s.SourceSiteId" class="@(s.StuckCount > 0 ? "table-warning" : "")">
<td>@SiteName(s.SourceSiteId)</td>
<td class="text-end font-monospace">@s.QueueDepth</td>
<td class="text-end font-monospace @(s.StuckCount > 0 ? "text-warning" : "")">@s.StuckCount</td>
<td class="text-end font-monospace @(s.ParkedCount > 0 ? "text-danger" : "")">@s.ParkedCount</td>
<td class="text-end font-monospace text-success">@s.DeliveredLastInterval</td>
<td class="text-end font-monospace">@FormatAge(s.OldestPendingAge)</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@code {
private List<Site> _sites = new();
private NotificationKpiResponse _kpi = new(string.Empty, true, null, 0, 0, 0, 0, null);
private string? _kpiError;
private IReadOnlyList<SiteNotificationKpiSnapshot> _perSite = Array.Empty<SiteNotificationKpiSnapshot>();
private string? _perSiteError;
private bool _loading;
protected override async Task OnInitializedAsync()
{
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
}
catch (Exception ex)
{
// Non-fatal — the per-site table falls back to raw site identifiers.
Logger.LogWarning(ex, "Failed to load sites for the KPI per-site breakdown.");
}
await RefreshAll();
}
private async Task RefreshAll()
{
_loading = true;
// Race-free despite both tasks mutating component fields: Blazor Server runs
// every continuation on the circuit's single-threaded synchronization context.
await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis());
_loading = false;
}
private async Task LoadGlobalKpis()
{
try
{
var response = await CommunicationService.GetNotificationKpisAsync(
new NotificationKpiRequest(Guid.NewGuid().ToString("N")));
if (response.Success)
{
_kpi = response;
_kpiError = null;
}
else
{
_kpiError = response.ErrorMessage ?? "KPI query failed.";
}
}
catch (Exception ex)
{
_kpiError = $"KPI query failed: {ex.Message}";
}
}
private async Task LoadPerSiteKpis()
{
try
{
var response = await CommunicationService.GetPerSiteNotificationKpisAsync(
new PerSiteNotificationKpiRequest(Guid.NewGuid().ToString("N")));
if (response.Success)
{
_perSite = response.Sites;
_perSiteError = null;
}
else
{
_perSiteError = response.ErrorMessage ?? "Per-site KPI query failed.";
}
}
catch (Exception ex)
{
_perSiteError = $"Per-site KPI query failed: {ex.Message}";
}
}
private string SiteName(string siteId) =>
_sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId;
private static string FormatAge(TimeSpan? age)
{
if (age == null) return "—";
var t = age.Value;
if (t.TotalSeconds < 60) return $"{(int)t.TotalSeconds}s";
if (t.TotalMinutes < 60) return $"{(int)t.TotalMinutes}m";
if (t.TotalHours < 24) return $"{(int)t.TotalHours}h";
return $"{(int)t.TotalDays}d";
}
}
Step 4: Run test to verify it passes
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NotificationKpisPageTests
Expected: PASS.
Step 5: Commit
git add src/ScadaLink.CentralUI/Components/Pages/Notifications/NotificationKpis.razor \
tests/ScadaLink.CentralUI.Tests/Pages/NotificationKpisPageTests.cs
git commit -m "feat(central-ui): Notification KPIs page with per-site breakdown"
Task 11: NavMenu — add the Notifications section
Files:
- Modify:
src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor - Test:
tests/ScadaLink.CentralUI.Tests/Layout/NavMenuTests.cs(create)
Context: NavMenu.razor builds the sidebar. It has gated sections (Admin / Design / Deployment each wrapped in one AuthorizeView Policy=...) and a Monitoring section with per-item gating. Two existing items must move out: the SMTP Configuration <li> in the Admin section and the Notification Outbox <li> in the Monitoring section. The new Notifications section goes between the Deployment section and the Monitoring section. Each item is wrapped in its own per-item AuthorizeView (the Monitoring section already does this — copy that shape). The plain-div section header needs no gating: every authenticated user holds at least one of Admin/Design/Deployment.
Step 1: Write the failing test
bUnit-render NavMenu under different role principals. Copy the authorization wiring from an existing nav/auth test (e.g. Monitoring/MonitoringAuthorizationTests.cs or NotificationReportPageTests).
using Bunit;
using ScadaLink.Security;
using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu;
namespace ScadaLink.CentralUI.Tests.Layout;
public class NavMenuTests : BunitContext
{
[Fact]
public void NotificationsSection_ShowsAllItems_ForMultiRoleUser()
{
// authorize a principal holding Admin + Design + Deployment roles
var cut = Render<NavMenu>(); // with the authorized principal wired in
Assert.Contains("Notifications", cut.Markup);
Assert.Contains("/notifications/smtp", cut.Markup);
Assert.Contains("/notifications/lists", cut.Markup);
Assert.Contains("/notifications/report", cut.Markup);
Assert.Contains("/notifications/kpis", cut.Markup);
}
[Fact]
public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp()
{
// authorize a principal holding only the Admin role
var cut = Render<NavMenu>();
Assert.Contains("/notifications/smtp", cut.Markup);
Assert.DoesNotContain("/notifications/report", cut.Markup);
Assert.DoesNotContain("/notifications/lists", cut.Markup);
}
[Fact]
public void OldRoutes_AreNoLongerLinked()
{
// authorize a multi-role principal
var cut = Render<NavMenu>();
Assert.DoesNotContain("/admin/smtp", cut.Markup);
Assert.DoesNotContain("/monitoring/notification-outbox", cut.Markup);
}
}
Step 2: Run test to verify it fails
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NavMenuTests
Expected: FAIL — old routes still present, new section absent.
Step 3: Write minimal implementation
In NavMenu.razor:
-
Remove the SMTP
<li>from the Admin section:<li class="nav-item"> <NavLink class="nav-link" href="/admin/smtp">SMTP Configuration</NavLink> </li> -
Remove the Notification Outbox
<li>from the Monitoring section:<li class="nav-item"> <NavLink class="nav-link" href="/monitoring/notification-outbox">Notification Outbox</NavLink> </li> -
Insert the Notifications section between the closing of the Deployment
AuthorizeViewand the Monitoring<div role="presentation" class="nav-section-header">Monitoring</div>line:@* Notifications — mixed-role section; each item gated by its own policy. The header is ungated: every authenticated user holds at least one of Admin/Design/Deployment, so it always has a visible child. *@ <div role="presentation" class="nav-section-header">Notifications</div> <AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin"> <Authorized Context="notifAdminContext"> <li class="nav-item"> <NavLink class="nav-link" href="/notifications/smtp">SMTP Configuration</NavLink> </li> </Authorized> </AuthorizeView> <AuthorizeView Policy="@AuthorizationPolicies.RequireDesign"> <Authorized Context="notifDesignContext"> <li class="nav-item"> <NavLink class="nav-link" href="/notifications/lists">Notification Lists</NavLink> </li> </Authorized> </AuthorizeView> <AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment"> <Authorized Context="notifDeploymentContext"> <li class="nav-item"> <NavLink class="nav-link" href="/notifications/report">Notification Report</NavLink> </li> <li class="nav-item"> <NavLink class="nav-link" href="/notifications/kpis">Notification KPIs</NavLink> </li> </Authorized> </AuthorizeView>Each
<Authorized Context="...">needs a unique context name (nestedAuthorizeViews require it — the file already uses this pattern).
Step 4: Run test to verify it passes
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter NavMenuTests
Expected: PASS.
Step 5: Commit
git add src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor \
tests/ScadaLink.CentralUI.Tests/Layout/NavMenuTests.cs
git commit -m "feat(central-ui): add the Notifications nav section"
Task 12: Health dashboard — link to the KPI page
Files:
- Modify:
src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor - Test:
tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs(add a case)
Context: Health.razor keeps a "Notification Outbox" headline tile row (<h6 ...>Notification Outbox</h6> at line ~24, three tiles below). Add a "View details →" link to /notifications/kpis next to that heading. The tiles and their data path are unchanged.
Step 1: Write the failing test
Add to HealthPageTests.cs, following its existing render setup:
[Fact]
public void RendersLinkToTheNotificationKpisPage()
{
var cut = Render<Health>(); // match the existing HealthPageTests render call
Assert.Contains("/notifications/kpis", cut.Markup);
}
Step 2: Run test to verify it fails
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter RendersLinkToTheNotificationKpisPage
Expected: FAIL — no such link.
Step 3: Write minimal implementation
In Health.razor, replace the bare heading line:
<h6 class="text-muted mb-2">Notification Outbox</h6>
with a heading row carrying the link:
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-muted mb-0">Notification Outbox</h6>
<a class="small" href="/notifications/kpis">View details →</a>
</div>
Step 4: Run test to verify it passes
Run: dotnet test tests/ScadaLink.CentralUI.Tests/ScadaLink.CentralUI.Tests.csproj --filter HealthPageTests
Expected: PASS.
Step 5: Commit
git add src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor \
tests/ScadaLink.CentralUI.Tests/Pages/HealthPageTests.cs
git commit -m "feat(central-ui): link Health outbox tiles to the Notification KPIs page"
Task 13: Full build + suite verification
Files: none (verification only).
Step 1: Clean build
Run: dotnet build ScadaLink.slnx
Expected: Build succeeded, 0 warnings (TreatWarningsAsErrors is on).
Step 2: Full test suite
Run: dotnet test ScadaLink.slnx
Expected: all test projects green. If SandboxTests.Sandbox_LongRunningScript_TimesOut or an InstanceActorChildAttributeRace test fails, that is a known pre-existing CPU-timing flake under parallel load — re-run tests/ScadaLink.SiteRuntime.Tests in isolation to confirm it is not a regression from this work.
Step 3: Grep for stale references
Run:
grep -rn "monitoring/notification-outbox\|admin/smtp\|design/notification-lists" \
src tests --include="*.razor" --include="*.cs" | grep -v "/obj/" | grep -v "/bin/"
Expected: no matches. Any hit is a stale link to fix before finishing.
Step 4: Commit (only if Step 3 required fixes)
git add <explicit fixed paths>
git commit -m "fix(central-ui): clear stale notification route references"
If no fixes were needed, there is nothing to commit — the work is complete.
Follow-ups (not in scope, track separately)
- Per-site KPI trend charts would need a time-series store — deliberately omitted (no historical persistence; KPIs are point-in-time).
- The
Notification Outboxcomponent name (#21 in CLAUDE.md) is unchanged; only UI page names changed. If the component is ever renamed, update CLAUDE.md andREADME.mdtogether.