docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)

Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -6,8 +6,7 @@ using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.Audi
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// Unit tests for <see cref="AuditLogPage.BuildExportUrl"/> (#23 M7-T14 /
/// Bundle F). Builds the <c>?...</c> querystring the Export-CSV link points
/// Unit tests for <see cref="AuditLogPage.BuildExportUrl"/> (audit log export). Builds the <c>?...</c> querystring the Export-CSV link points
/// at; the same conversion is round-tripped on the server side by
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Audit.AuditExportEndpoints.ParseFilter"/>.
/// These tests pin the no-filter base path + the round-trip back through
@@ -15,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// </summary>
public class AuditLogPageExportUrlTests
{
/// <summary>Verifies that a null filter yields the base export path.</summary>
[Fact]
public void BuildExportUrl_NullFilter_ReturnsBasePath()
{
@@ -22,6 +22,7 @@ public class AuditLogPageExportUrlTests
Assert.Equal("/api/centralui/audit/export", url);
}
/// <summary>Verifies that an empty filter yields the base export path.</summary>
[Fact]
public void BuildExportUrl_EmptyFilter_ReturnsBasePath()
{
@@ -31,6 +32,7 @@ public class AuditLogPageExportUrlTests
Assert.Equal("/api/centralui/audit/export", url);
}
/// <summary>Verifies that a filter with every field set round-trips through the export URL.</summary>
[Fact]
public void BuildExportUrl_AllFiltersSet_RoundTrips()
{
@@ -62,6 +64,7 @@ public class AuditLogPageExportUrlTests
Assert.Equal("2026-05-20T23:59:59.0000000Z", query["to"]);
}
/// <summary>Verifies that a filter with only a channel set omits all other query parameters.</summary>
[Fact]
public void BuildExportUrl_OnlyChannelSet_OmitsOtherParams()
{
@@ -75,6 +78,7 @@ public class AuditLogPageExportUrlTests
Assert.Equal("Notification", query["channel"]);
}
/// <summary>Verifies that setting an execution id emits the execution id query parameter.</summary>
[Fact]
public void BuildExportUrl_ExecutionIdSet_EmitsExecutionIdParam()
{
@@ -89,6 +93,7 @@ public class AuditLogPageExportUrlTests
Assert.Equal(exec.ToString(), query["executionId"]);
}
/// <summary>Verifies that setting a parent execution id emits the parent execution id query parameter.</summary>
[Fact]
public void BuildExportUrl_ParentExecutionIdSet_EmitsParentExecutionIdParam()
{
@@ -103,10 +108,11 @@ public class AuditLogPageExportUrlTests
Assert.Equal(parent.ToString(), query["parentExecutionId"]);
}
/// <summary>Verifies that multi-value dimensions emit repeated query parameters.</summary>
[Fact]
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
{
// Task 9: each multi-value dimension emits one repeated query-string key
// Each multi-value dimension emits one repeated query-string key
// per selected value so the export endpoint's ParseFilter sees them all.
var filter = new AuditLogQueryFilter(
Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound },
@@ -28,7 +28,7 @@ using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.Audi
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// Permission-gating tests for the Audit Log surface (#23 M7-T15 / Bundle G).
/// Permission-gating tests for the Audit Log surface (Bundle G).
///
/// <para>
/// Bundle G introduces two new policies:
@@ -38,7 +38,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <item><c>AuditExport</c> — additional gate on the Export-CSV button and
/// the streaming export endpoint.</item>
/// </list>
/// Post Task 1.7: <c>AuditExport</c> is satisfied only by the
/// After the role consolidation, <c>AuditExport</c> is satisfied only by the
/// <c>Administrator</c> role (which absorbed the former <c>Audit</c> role);
/// <c>OperationalAudit</c> is additionally satisfied by the <c>Viewer</c> role
/// (the home of the former <c>AuditReadOnly</c> role) — Viewer reads but cannot
@@ -48,6 +48,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// </summary>
public class AuditLogPagePermissionTests : BunitContext
{
/// <summary>Initializes the test context and registers the fake authentication and service stand-ins.</summary>
public AuditLogPagePermissionTests()
{
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
@@ -71,7 +72,7 @@ public class AuditLogPagePermissionTests : BunitContext
// a permitted render is exercised end-to-end.
Services.AddSingleton(Substitute.For<ISiteRepository>());
Services.AddSingleton(Substitute.For<IAuditLogQueryService>());
// M6 (K15): the page now injects IKpiHistoryQueryService for the Trends
// The page now injects IKpiHistoryQueryService for the Trends
// panel — register a stand-in so a permitted render is exercised end-to-end.
Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>());
}
@@ -109,6 +110,8 @@ public class AuditLogPagePermissionTests : BunitContext
// so we verify the attribute is present + the policy denies a principal
// that holds none of the permitting roles.
/// <summary>Verifies that a user without the OperationalAudit permission is denied by the policy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WithoutOperationalAudit_PolicyDenies()
{
@@ -127,6 +130,7 @@ public class AuditLogPagePermissionTests : BunitContext
Assert.False(result.Succeeded);
}
/// <summary>Verifies that the Audit Log page carries the OperationalAudit authorize attribute.</summary>
[Fact]
public void AuditLogPage_HasOperationalAuditAuthorizeAttribute()
{
@@ -140,6 +144,7 @@ public class AuditLogPagePermissionTests : BunitContext
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
}
/// <summary>Verifies that the Configuration Audit Log page carries the OperationalAudit authorize attribute.</summary>
[Fact]
public void ConfigurationAuditLogPage_HasOperationalAuditAuthorizeAttribute()
{
@@ -159,13 +164,14 @@ public class AuditLogPagePermissionTests : BunitContext
// Test 2 + 3: Export button visibility split.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a user with OperationalAudit but without AuditExport sees the page with the export button hidden.</summary>
[Fact]
public void WithOperationalAudit_NoAuditExport_PageRenders_ExportButtonHidden()
{
// The "Administrator" role grants OperationalAudit + AuditExport, so we
// test the split by handing the user ONLY the "Viewer" role, which maps
// to OperationalAudit (read) but NOT AuditExport — the preserved
// half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse
// half-SoD after the AuditReadOnly→Viewer collapse
// (see AuthorizationPolicies).
var cut = RenderAuditLogPage("Viewer");
@@ -178,6 +184,7 @@ public class AuditLogPagePermissionTests : BunitContext
});
}
/// <summary>Verifies that a user with both OperationalAudit and AuditExport sees the page with the export button visible.</summary>
[Fact]
public void WithOperationalAudit_AndAuditExport_PageRenders_ExportButtonVisible()
{
@@ -190,6 +197,7 @@ public class AuditLogPagePermissionTests : BunitContext
});
}
/// <summary>Verifies that an Administrator sees the page and the export button.</summary>
[Fact]
public void AdminUser_SeesPage_AndExportButton()
{
@@ -208,6 +216,8 @@ public class AuditLogPagePermissionTests : BunitContext
// Test 4 + 5: Endpoint-level enforcement.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the export endpoint returns 403 for a caller without AuditExport.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditExportEndpoint_WithoutAuditExport_Returns403()
{
@@ -221,6 +231,8 @@ public class AuditLogPagePermissionTests : BunitContext
}
}
/// <summary>Verifies that the export endpoint returns 200 for a caller with AuditExport.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditExportEndpoint_WithAuditExport_Returns200()
{
@@ -232,6 +244,8 @@ public class AuditLogPagePermissionTests : BunitContext
}
}
/// <summary>Verifies that the export endpoint returns 200 for an Administrator alone.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditExportEndpoint_AdminAlone_Returns200()
{
@@ -245,6 +259,8 @@ public class AuditLogPagePermissionTests : BunitContext
}
}
/// <summary>Verifies that the export endpoint returns 403 for a Viewer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditExportEndpoint_Viewer_Returns403()
{
@@ -313,12 +329,17 @@ public class AuditLogPagePermissionTests : BunitContext
{
public const string SchemeName = "FakeAuth";
/// <summary>Initializes the handler with the standard authentication handler dependencies.</summary>
/// <param name="options">The options monitor for the fake handler.</param>
/// <param name="logger">The logger factory.</param>
/// <param name="encoder">The URL encoder.</param>
public FakeAuthHandler(
IOptionsMonitor<FakeAuthHandlerOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<Claim> { new(ClaimTypes.Name, "test-user") };
@@ -335,6 +356,7 @@ public class AuditLogPagePermissionTests : BunitContext
private sealed class FakeAuthHandlerOptions : AuthenticationSchemeOptions
{
/// <summary>Gets or sets the roles granted to the fake authenticated user.</summary>
public string[] Roles { get; set; } = Array.Empty<string>();
}
}
@@ -16,9 +16,8 @@ using NavMenu = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout.NavMenu;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// Scaffold tests for the new Audit Log page (#23 M7-T1) and the Audit
/// nav group that hosts both it and the renamed Configuration Audit Log
/// (#23 M7 Bundle A).
/// Scaffold tests for the new Audit Log page and the Audit
/// nav group that hosts both it and the renamed Configuration Audit Log.
///
/// These are render-only smoke tests — the filter bar and results grid
/// are intentional placeholders that Bundle B fills in. The tests pin
@@ -27,6 +26,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// </summary>
public class AuditLogPageScaffoldTests : BunitContext
{
/// <summary>Configures bUnit's JS interop in loose mode so unconfigured grid interop calls are no-ops.</summary>
public AuditLogPageScaffoldTests()
{
// The page hosts AuditResultsGrid, whose OnAfterRenderAsync wires the
@@ -63,7 +63,7 @@ public class AuditLogPageScaffoldTests : BunitContext
// Provide stand-ins so the scaffold smoke tests still render the page.
Services.AddSingleton(Substitute.For<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ISiteRepository>());
Services.AddSingleton(_queryService);
// M6 (K15): the page now injects IKpiHistoryQueryService for the Trends
// The page now injects IKpiHistoryQueryService for the Trends
// panel. Provide a stand-in so the scaffold/drill-in renders still work.
Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>());
@@ -73,7 +73,7 @@ public class AuditLogPageScaffoldTests : BunitContext
nav.NavigateTo($"/audit/log?{query}");
}
// Bundle G (#23 M7-T15): the page now hosts an in-component
// The page now hosts an in-component
// AuthorizeView around the Export-CSV button, so the page MUST
// render inside a CascadingAuthenticationState. The router supplies
// this in production; bUnit hosts the page directly so we wrap it
@@ -109,7 +109,7 @@ public class AuditLogPageScaffoldTests : BunitContext
/// <summary>
/// Asserts the named rail section is present. After the ZB.MOM.WW.Theme
/// adoption (Task 2.4) nav sections are the kit's <c>NavRailSection</c> —
/// adoption nav sections are the kit's <c>NavRailSection</c> —
/// native <c>&lt;details class="rail-section"&gt;</c> disclosures that are
/// open by default, so a section's items are already in the DOM (no click
/// needed, no JS). This helper just confirms the section's
@@ -122,6 +122,7 @@ public class AuditLogPageScaffoldTests : BunitContext
s => s.TextContent.Contains(title, StringComparison.Ordinal));
}
/// <summary>Verifies that the Audit Log page renders its page heading.</summary>
[Fact]
public void AuditLogPage_Renders_PageHeading()
{
@@ -136,6 +137,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that the nav menu contains an Audit group with a link to the Audit Log page.</summary>
[Fact]
public void NavMenu_Contains_AuditGroup_With_AuditLog_Link()
{
@@ -149,6 +151,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that the nav menu lists the Configuration Audit Log link under the Audit group.</summary>
[Fact]
public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup()
{
@@ -174,9 +177,10 @@ public class AuditLogPageScaffoldTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────────
// Bundle D — query-string drill-in parsing (#23 M7-T10..T12)
// Query-string drill-in parsing
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that navigating with a correlation id applies the filter and loads results automatically.</summary>
[Fact]
public void NavigateWithCorrelationId_AppliesFilter_AndAutoLoads()
{
@@ -197,6 +201,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that navigating with an execution id applies the filter and loads results automatically.</summary>
[Fact]
public void NavigateWithExecutionIdParam_AppliesFilter_AndAutoLoads()
{
@@ -219,6 +224,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that an unparseable execution id is silently dropped and nothing loads automatically.</summary>
[Fact]
public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
{
@@ -235,6 +241,7 @@ public class AuditLogPageScaffoldTests : BunitContext
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that navigating with a parent execution id applies the filter and loads results automatically.</summary>
[Fact]
public void NavigateWithParentExecutionIdParam_AppliesFilter_AndAutoLoads()
{
@@ -257,6 +264,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that an unparseable parent execution id is silently dropped and nothing loads automatically.</summary>
[Fact]
public void NavigateWithUnparseableParentExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
{
@@ -274,6 +282,7 @@ public class AuditLogPageScaffoldTests : BunitContext
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that navigating with a target parameter applies the target filter.</summary>
[Fact]
public void NavigateWithTargetParam_AppliesTargetFilter()
{
@@ -292,6 +301,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that navigating with a site parameter applies the site filter.</summary>
[Fact]
public void NavigateWithSiteParam_AppliesSiteFilter()
{
@@ -311,10 +321,11 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that navigating with a status parameter applies the status filter.</summary>
[Fact]
public void NavigateWithStatusParam_AppliesStatusFilter()
{
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills
// The Health-dashboard Audit error-rate tile drills
// in with ?status=Failed. The page parses the enum (case-insensitive),
// builds an AuditLogQueryFilter with Status set, and auto-loads.
_queryService = Substitute.For<IAuditLogQueryService>();
@@ -333,6 +344,7 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
/// <summary>Verifies that an unknown status value is silently dropped and nothing loads automatically.</summary>
[Fact]
public void NavigateWithUnknownStatusParam_IsSilentlyDropped_NoAutoLoad()
{
@@ -350,6 +362,7 @@ public class AuditLogPageScaffoldTests : BunitContext
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that navigating without parameters leaves the filter empty and does not load automatically.</summary>
[Fact]
public void NavigateWithNoParams_LeavesFilterEmpty_NoAutoLoad()
{
@@ -15,7 +15,7 @@ using AuditLogPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Audit.Audi
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// Tests for the M6 (K15) Trends panel added to the Audit Log page — one
/// Tests for the Trends panel added to the Audit Log page — one
/// <c>KpiTrendChart</c> per AuditLog global metric, over a 24h/7d window. The
/// panel is best-effort: a failing <see cref="IKpiHistoryQueryService"/> must
/// degrade the affected charts without breaking the audit query UI (the filter
@@ -37,6 +37,7 @@ public class AuditLogPageTrendTests : BunitContext
{
private static readonly DateTime Base = new(2026, 6, 15, 10, 0, 0, DateTimeKind.Utc);
/// <summary>Initializes a new instance of the <see cref="AuditLogPageTrendTests"/> class.</summary>
public AuditLogPageTrendTests()
{
// AuditResultsGrid's OnAfterRenderAsync wires its column resize/reorder UX
@@ -84,6 +85,7 @@ public class AuditLogPageTrendTests : BunitContext
return host.FindComponent<AuditLogPage>();
}
/// <summary>Verifies the case: trends Panel, with Series, renders Charts With Polyline.</summary>
[Fact]
public void TrendsPanel_WithSeries_RendersChartsWithPolyline()
{
@@ -118,11 +120,12 @@ public class AuditLogPageTrendTests : BunitContext
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies the case: trends Panel, long Range Windows, render And Refetch With Matching Span.</summary>
[Fact]
public void TrendsPanel_LongRangeWindows_RenderAndRefetchWithMatchingSpan()
{
// The 30d (720h) / 90d (2160h) buttons added for the hourly-rollup path
// (plan #22 T7) must render alongside 24h/7d and, when clicked, re-fetch
// must render alongside 24h/7d and, when clicked, re-fetch
// every series over the corresponding range — the query service then
// transparently routes the long windows to hourly rollups.
var ranges = new List<(DateTime From, DateTime To)>();
@@ -156,6 +159,7 @@ public class AuditLogPageTrendTests : BunitContext
Assert.Contains(ranges, r => Math.Abs((r.To - r.From).TotalHours - 2160) < 0.5));
}
/// <summary>Verifies the case: trends Panel, when Query Service Throws, page Still Renders.</summary>
[Fact]
public void TrendsPanel_WhenQueryServiceThrows_PageStillRenders()
{
@@ -23,7 +23,7 @@ using TransportExportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Des
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
/// <summary>
/// bUnit + logic tests for the TransportExport wizard (Component #24, Task T21).
/// bUnit + logic tests for the TransportExport wizard (Transport component).
///
/// <para>
/// Covers the four contract points the design plan calls out:
@@ -54,6 +54,7 @@ public class TransportExportPageTests : BunitContext
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IBundleExporter _exporter = Substitute.For<IBundleExporter>();
/// <summary>Initializes a new instance of <c>TransportExportPageTests</c>.</summary>
public TransportExportPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -76,7 +77,7 @@ public class TransportExportPageTests : BunitContext
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>()));
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
// Empty site/instance defaults — the M8 site/instance picker calls these on load
// Empty site/instance defaults — the site/instance picker calls these on load
// and the DependencyResolver fans out over sites on resolve.
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
@@ -92,7 +93,7 @@ public class TransportExportPageTests : BunitContext
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_exporter);
// DependencyResolver is sealed but its dependencies are the repositories above
// (template/external/notification/inbound + ISiteRepository for M8 site/instance
// (template/external/notification/inbound + ISiteRepository for site/instance
// export) — registering the concrete type is enough.
Services.AddSingleton<DependencyResolver>();
Services.AddSingleton<IOptions<TransportOptions>>(
@@ -117,6 +118,7 @@ public class TransportExportPageTests : BunitContext
// ─────────────────────────────────────────────────────────────────────
// Test 1: Step 1 renders the template tree and every flat artifact group.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that renders step 1 with template tree and artifact checkboxes.</summary>
[Fact]
public void Renders_step1_with_template_tree_and_artifact_checkboxes()
{
@@ -201,6 +203,8 @@ public class TransportExportPageTests : BunitContext
// Test 2: Step 2 shows resolved dependencies — auto-included templates pulled
// in because a seed template composes them.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that step 2 shows resolved dependencies after clicking next.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Step2_shows_resolved_dependencies_after_clicking_next()
{
@@ -252,6 +256,8 @@ public class TransportExportPageTests : BunitContext
// Test 3: Walks the wizard end-to-end and verifies BundleExporter.ExportAsync
// is invoked with the user-selected ids and the authenticated identity.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that step 4 triggers export async with selected artifacts and user identity.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Step4_triggers_ExportAsync_with_selected_artifacts_and_user_identity()
{
@@ -318,6 +324,8 @@ public class TransportExportPageTests : BunitContext
// doesn't model routing, so we verify the policy itself denies the
// principal (the same gate the router consults).
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that page returns unauthorized for user without design role.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Page_returns_unauthorized_for_user_without_Design_role()
{
@@ -336,9 +344,10 @@ public class TransportExportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 5 (M8 E1): Step 1 renders the Sites & Instances group, listing each
// Test 5: Step 1 renders the Sites & Instances group, listing each
// site and (when expanded) its instances.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that renders step 1 sites group with sites and instances.</summary>
[Fact]
public void Renders_step1_sites_group_with_sites_and_instances()
{
@@ -372,10 +381,12 @@ public class TransportExportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 6 (M8 E1): Selecting a site flows its id into ExportSelection.SiteIds;
// Test 6: Selecting a site flows its id into ExportSelection.SiteIds;
// selecting an individual instance flows its id into InstanceIds. Verified at
// the resolver/exporter boundary (the same contract DependencyResolver reads).
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that step 4 export carries selected site and instance ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Step4_export_carries_selected_site_and_instance_ids()
{
@@ -441,6 +452,8 @@ public class TransportExportPageTests : BunitContext
// AccountSid (never AuthToken), and ticking one flows its entity id into
// ExportSelection.SmsConfigurationIds — mirroring the SMTP-by-Host contract.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that step 4 export carries selected sms configuration ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Step4_export_carries_selected_sms_configuration_ids()
{
@@ -494,6 +507,7 @@ public class TransportExportPageTests : BunitContext
// Test 9 (S10c): CountSecrets counts a populated SMS AuthToken, mirroring
// the SMTP Credentials contribution to the Step 3 secrets banner.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that count secrets includes sms configuration auth token.</summary>
[Fact]
public void CountSecrets_includes_sms_configuration_auth_token()
{
@@ -523,6 +537,7 @@ public class TransportExportPageTests : BunitContext
// Static helpers — exercised directly so the file-naming + secret-count
// contract is unit-pinned independently of the rendering surface.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that build filename produces pattern and sanitises source environment.</summary>
[Fact]
public void BuildFilename_produces_pattern_and_sanitises_source_environment()
{
@@ -532,9 +547,10 @@ public class TransportExportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 7 (M8 C2-SECRET-1): CountSecrets includes site data-connection
// Test 7: CountSecrets includes site data-connection
// PrimaryConfiguration and BackupConfiguration fields in the banner total.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that count secrets includes data connection primary and backup configurations.</summary>
[Fact]
public void CountSecrets_includes_data_connection_primary_and_backup_configurations()
{
@@ -560,6 +576,7 @@ public class TransportExportPageTests : BunitContext
Assert.Equal(2, TransportExportPage.CountSecrets(resolved));
}
/// <summary>Verifies that count secrets counts only non empty data connection configurations.</summary>
[Fact]
public void CountSecrets_counts_only_non_empty_data_connection_configurations()
{
@@ -589,6 +606,7 @@ public class TransportExportPageTests : BunitContext
Assert.Equal(1, TransportExportPage.CountSecrets(resolved));
}
/// <summary>Verifies that count secrets returns zero when no data connections.</summary>
[Fact]
public void CountSecrets_returns_zero_when_no_data_connections()
{
@@ -24,7 +24,7 @@ using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Des
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
/// <summary>
/// bUnit + logic tests for the TransportImport wizard (Component #24, Task T22).
/// bUnit + logic tests for the TransportImport wizard.
///
/// <para>
/// The wizard has five steps (Upload / Passphrase / Diff / Confirm / Result).
@@ -41,7 +41,7 @@ public class TransportImportPageTests : BunitContext
{
private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>();
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
// M8 E2: the Map step injects ISiteRepository to populate target-site +
// The Map step injects ISiteRepository to populate target-site +
// target-connection dropdowns. Register a substitute so every test (not just
// the Map tests) can render the wizard without a missing-service failure.
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
@@ -49,6 +49,7 @@ public class TransportImportPageTests : BunitContext
/// <summary>Client address the wizard reports for the per-IP unlock throttle (register row 44).</summary>
private const string TestClientIp = "203.0.113.7";
/// <summary>Initializes the test context with stubbed importer, audit, repository and authentication services.</summary>
public TransportImportPageTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -124,6 +125,7 @@ public class TransportImportPageTests : BunitContext
// ─────────────────────────────────────────────────────────────────────
// Test 1: Step 1 renders the InputFile upload control.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the wizard renders the step 1 upload input.</summary>
[Fact]
public void Renders_step1_upload_input()
{
@@ -138,6 +140,8 @@ public class TransportImportPageTests : BunitContext
// Test 2: Wrong passphrase increments the failure counter without
// advancing past Step 2.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a decryption failure increments the passphrase attempt counter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Decryption_failure_increments_attempt_counter()
{
@@ -169,6 +173,8 @@ public class TransportImportPageTests : BunitContext
// Test 3: After MaxUnlockAttemptsPerSession failures the wizard returns
// to Step 1 with an explanatory error.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that three failed unlock attempts force the user to upload the bundle again.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Three_failed_unlocks_force_reupload()
{
@@ -202,6 +208,8 @@ public class TransportImportPageTests : BunitContext
// Test 4: Confirm step requires an exact match (case-sensitive) on the
// source environment name before Apply is enabled.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the confirm step requires an exact environment name match.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Confirm_step_requires_exact_environment_name_match()
{
@@ -250,6 +258,8 @@ public class TransportImportPageTests : BunitContext
// Test 5: ApplyAsync is invoked with the chosen resolutions and the
// authenticated user identity.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the apply step calls the bundle importer's <c>ApplyAsync</c> with the chosen resolutions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_step_invokes_BundleImporter_ApplyAsync_with_resolutions()
{
@@ -314,6 +324,8 @@ public class TransportImportPageTests : BunitContext
// doesn't model routing, so we verify the policy itself denies the
// principal.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the page is unauthorized for a user without the Admin role.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Page_returns_unauthorized_for_user_without_Admin_role()
{
@@ -335,6 +347,7 @@ public class TransportImportPageTests : BunitContext
// Test 7 (helper coverage): BuildDefaultResolutions maps each kind to the
// expected default ResolutionAction.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that default resolutions map each conflict kind to the expected action.</summary>
[Fact]
public void BuildDefaultResolutions_maps_kinds_to_actions()
{
@@ -355,10 +368,12 @@ public class TransportImportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 8 (M8 E2): the Map sub-section renders one row per required site /
// Test 8: the Map sub-section renders one row per required site /
// connection mapping with the auto-match defaults pre-selected, and the
// section is hidden entirely when the preview carries no required mappings.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the map section renders the required rows with auto-match defaults selected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Map_section_renders_required_rows_with_automatch_defaults()
{
@@ -398,6 +413,8 @@ public class TransportImportPageTests : BunitContext
Assert.Equal("opc-main", selectedConnOpt.GetAttribute("value"));
}
/// <summary>Verifies that the map section is hidden when no mappings are required.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Map_section_hidden_when_no_required_mappings()
{
@@ -418,10 +435,12 @@ public class TransportImportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 9 (M8 E2): the operator's Map choices fold into the BundleNameMap
// Test 9: the operator's Map choices fold into the BundleNameMap
// passed to ApplyAsync — a chosen target → MapToExisting, "Create new" →
// CreateNew. Captured via the substituted IBundleImporter.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that apply passes a <c>BundleNameMap</c> built from the operator's map choices.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_passes_BundleNameMap_built_from_map_choices()
{
@@ -479,9 +498,11 @@ public class TransportImportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 10 (M8 E2): a Modified row whose FieldDiffJson carries a code
// Test 10: a Modified row whose FieldDiffJson carries a code
// lineDiff renders +/- lines via LineDiffView.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a modified row with a code line diff renders added and removed lines.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Modified_row_with_code_lineDiff_renders_add_and_remove_lines()
{
@@ -533,8 +554,10 @@ public class TransportImportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 11 (M8 E2): truncation marker shows when the lineDiff is truncated.
// Test 11: truncation marker shows when the lineDiff is truncated.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a modified row with a truncated line diff shows the truncation marker.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Modified_row_with_truncated_lineDiff_shows_truncation_marker()
{
@@ -579,11 +602,13 @@ public class TransportImportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 12 (M8 E2 Fix 1): changing a source site's dropdown to a target
// Test 12: changing a source site's dropdown to a target
// that lacks the previously auto-matched connection name resets that
// connection choice to CreateNew (""), and BuildNameMap therefore emits
// a CreateNew entry — not a MapToExisting for an absent connection.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that changing a site choice resets connection choices the new target site lacks.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnSiteChoiceChanged_resets_stale_connection_choices_when_new_target_lacks_connection()
{
@@ -639,9 +664,11 @@ public class TransportImportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────
// Test 13 (M8 E2 Fix 2): BackToUpload clears session/preview/Map state
// Test 13: BackToUpload clears session/preview/Map state
// so a subsequent re-upload flow cannot inherit stale data.
// ─────────────────────────────────────────────────────────────────────
/// <summary>Verifies that going back to upload clears the session, preview and map state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BackToUpload_clears_session_and_preview_state()
{
@@ -715,12 +742,17 @@ public class TransportImportPageTests : BunitContext
/// </summary>
private sealed class StoreBackedImporter : IBundleImporter
{
/// <summary>Gets the backing bundle session store.</summary>
public BundleSessionStore Store { get; } = new(
Microsoft.Extensions.Options.Options.Create(new TransportOptions()),
TimeProvider.System);
/// <summary>Gets the number of times <c>Abandon</c> has been called.</summary>
public int AbandonCalls { get; private set; }
/// <summary>Seeds an encrypted session directly into the store.</summary>
/// <param name="sourceEnv">The source environment name recorded on the session manifest.</param>
/// <returns>The opened session.</returns>
public BundleSession OpenSession(string sourceEnv = "prod-cluster")
{
var seed = BuildEncryptedSession(sourceEnv);
@@ -735,13 +767,16 @@ public class TransportImportPageTests : BunitContext
return Store.Open(session);
}
/// <inheritdoc />
public Task<BundleSession> LoadAsync(
Stream bundleStream, string? passphrase, CancellationToken ct = default, string? clientKey = null)
=> throw new NotSupportedException("These tests seed sessions via OpenSession().");
/// <inheritdoc />
public Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default)
=> Task.FromResult(new ImportPreview(sessionId, new List<ImportPreviewItem>()));
/// <inheritdoc />
public Task<ImportResult> ApplyAsync(
Guid sessionId,
IReadOnlyList<ImportResolution> resolutions,
@@ -760,6 +795,7 @@ public class TransportImportPageTests : BunitContext
AuditEventCorrelation: Guid.NewGuid().ToString()));
}
/// <inheritdoc />
public void Abandon(Guid sessionId)
{
AbandonCalls++;
@@ -777,6 +813,8 @@ public class TransportImportPageTests : BunitContext
}
}
/// <summary>Verifies that disposing the wizard releases its bundle session.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Disposing_the_wizard_releases_its_bundle_session()
{
@@ -798,6 +836,8 @@ public class TransportImportPageTests : BunitContext
Assert.Null(GetField<object?>(cut.Instance, "_session"));
}
/// <summary>Verifies that going back to upload releases the bundle session.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BackToUpload_releases_the_bundle_session()
{
@@ -827,6 +867,8 @@ public class TransportImportPageTests : BunitContext
Assert.All(content, b => Assert.Equal(0, b));
}
/// <summary>Verifies that applying then disposing releases the session exactly once without throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Apply_then_dispose_releases_the_session_once_without_throwing()
{
@@ -862,6 +904,8 @@ public class TransportImportPageTests : BunitContext
Assert.Null(importer.Store.Get(session.SessionId));
}
/// <summary>Verifies that disposing one wizard leaves a concurrent circuit's session open.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Disposing_one_wizard_leaves_a_concurrent_circuits_session_open()
{
@@ -942,8 +986,8 @@ public class TransportImportPageTests : BunitContext
/// <summary>
/// Seeds the wizard at Step 2 (Passphrase) with a staged bundle file — the
/// shape after an encrypted-bundle upload completed Step 1's peek and
/// surfaced an ArgumentException ("passphrase required"). CentralUI-031:
/// the wizard now stages the upload to a temp file and only retains the
/// surfaced an ArgumentException ("passphrase required"). The
/// wizard now stages the upload to a temp file and only retains the
/// path on the component, so the test helper writes the bytes to a per-
/// test temp file and sets the path field instead of the byte[] field.
/// </summary>
@@ -22,7 +22,7 @@ using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Des
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
/// <summary>
/// Regression tests for register row 103 (the P6-6 lesser instance in
/// Regression tests for register row 103 (the lesser instance in
/// <c>TransportImport</c>). <c>TryLoadAsync</c> awaits
/// <see cref="IBundleImporter.LoadAsync"/>, then used to publish the result into
/// <c>_session</c> unconditionally. If the circuit was torn down inside that await
@@ -31,7 +31,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
/// <c>BundleSession</c> stayed registered, stranding one of
/// <c>TransportOptions.MaxConcurrentImportSessions</c> (register row 43) for the full
/// session TTL. The fix publishes the session and re-checks disposal under one lock
/// (<c>_sessionLock</c>), mirroring <c>DebugView</c>'s P6-6 fix, so exactly one side
/// (<c>_sessionLock</c>), mirroring <c>DebugView</c>'s fix, so exactly one side
/// (this method or <c>Dispose</c>) abandons it.
/// </summary>
public class TransportImportSessionDisposeRaceTests : BunitContext
@@ -48,6 +48,7 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<Guid> _abandoned = new();
/// <summary>Gets the session store used by the test.</summary>
public BundleSessionStore Store { get; } = new(
Options.Create(new TransportOptions()), TimeProvider.System);
@@ -60,8 +61,10 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
get { lock (_abandoned) { return _abandoned.ToList(); } }
}
/// <summary>Releases the load that is blocked waiting for the test to let it finish.</summary>
public void ReleaseLoad() => _gate.TrySetResult();
/// <inheritdoc />
public async Task<BundleSession> LoadAsync(
Stream bundleStream, string? passphrase, CancellationToken ct = default, string? clientKey = null)
{
@@ -89,9 +92,11 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
return Store.Open(session);
}
/// <inheritdoc />
public Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default)
=> throw new NotSupportedException("Not exercised by these tests.");
/// <inheritdoc />
public Task<ImportResult> ApplyAsync(
Guid sessionId,
IReadOnlyList<ImportResolution> resolutions,
@@ -100,6 +105,7 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
BundleNameMap? nameMap = null)
=> throw new NotSupportedException("Not exercised by these tests.");
/// <inheritdoc />
public void Abandon(Guid sessionId)
{
lock (_abandoned) { _abandoned.Add(sessionId); }
@@ -116,6 +122,7 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private const string TestClientIp = "203.0.113.7";
/// <summary>Initializes the test class and its fake session service.</summary>
public TransportImportSessionDisposeRaceTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -181,6 +188,8 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
return task!;
}
/// <summary>Verifies that a component disposed mid-load abandons the session that was just opened.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TryLoadAsync_DisposedMidLoad_AbandonsTheJustOpenedSession()
{
@@ -203,6 +212,8 @@ public class TransportImportSessionDisposeRaceTests : BunitContext
Assert.Null(_importer.Store.Get(_importer.Abandoned[0]));
}
/// <summary>Verifies that loading and then disposing abandons the session exactly once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TryLoadAsync_ThenDispose_AbandonsTheSessionExactlyOnce()
{
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit tests for <see cref="ExecutionTreePage"/> (Audit Log ParentExecutionId
/// feature, Task 10). The page is reached via the "View execution chain"
/// feature). The page is reached via the "View execution chain"
/// drill-in at <c>/audit/execution-tree?executionId={guid}</c>. It parses the
/// query-string id, calls <see cref="IAuditLogQueryService.GetExecutionTreeAsync"/>,
/// and hands the flat node list to the <c>ExecutionTree</c> component.
@@ -68,6 +68,7 @@ public class ExecutionTreePageTests : BunitContext
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
/// <summary>Verifies that navigating with an execution id calls the service and renders the tree.</summary>
[Fact]
public void NavigateWithExecutionId_CallsService_AndRendersTree()
{
@@ -91,6 +92,7 @@ public class ExecutionTreePageTests : BunitContext
});
}
/// <summary>Verifies that navigating without an execution id renders the guidance prompt and makes no service call.</summary>
[Fact]
public void NavigateWithoutExecutionId_RendersGuidancePrompt_NoServiceCall()
{
@@ -102,6 +104,7 @@ public class ExecutionTreePageTests : BunitContext
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that navigating with an unparseable execution id renders the guidance prompt and makes no service call.</summary>
[Fact]
public void NavigateWithUnparseableExecutionId_RendersGuidancePrompt_NoServiceCall()
{
@@ -113,6 +116,7 @@ public class ExecutionTreePageTests : BunitContext
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that double-clicking a tree node opens the execution detail modal.</summary>
[Fact]
public void DoubleClickTreeNode_OpensExecutionDetailModal()
{
@@ -147,6 +151,7 @@ public class ExecutionTreePageTests : BunitContext
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that closing the execution detail modal hides it.</summary>
[Fact]
public void ClosingExecutionDetailModal_HidesIt()
{
@@ -175,6 +180,7 @@ public class ExecutionTreePageTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]")));
}
/// <summary>Verifies that the page carries the operational-audit authorize attribute.</summary>
[Fact]
public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute()
{
@@ -26,7 +26,7 @@ using HealthPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Monitoring.H
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the Health Monitoring dashboard (Task 24).
/// bUnit rendering tests for the Health Monitoring dashboard.
///
/// Scope: the Notification Outbox KPI tile row added to the Health dashboard.
/// <see cref="ICentralHealthAggregator"/> is an interface (mockable), but
@@ -45,13 +45,14 @@ public class HealthPageTests : BunitContext
new("k", true, null, QueueDepth: 12, StuckCount: 4, ParkedCount: 3,
DeliveredLastInterval: 88, OldestPendingAge: TimeSpan.FromMinutes(6));
// Site Call Audit (#22) Task 7 — mutable scripted Site Call KPI reply. Tests
// Mutable scripted Site Call KPI reply. Tests
// that target the Site Call tiles override this before rendering.
private SiteCallKpiResponse _siteCallKpiReply =
new("k", true, null, BufferedCount: 9, ParkedCount: 2, FailedLastInterval: 1,
DeliveredLastInterval: 40, OldestPendingAge: TimeSpan.FromMinutes(3),
StuckCount: 5);
/// <summary>Initializes the test context and registers the services and actor stand-ins the Health page requires.</summary>
public HealthPageTests()
{
_comms = new CommunicationService(
@@ -65,7 +66,7 @@ public class HealthPageTests : BunitContext
_comms.SetSiteCallAudit(siteCallAudit);
Services.AddSingleton(_comms);
// arch-review WP2.4 — the page no longer queries CommunicationService /
// The page no longer queries CommunicationService /
// IAuditLogQueryService per circuit; it reads the process-level memoized
// KPI cache. The real cache is registered here (not a substitute) so the
// scripted-actor seam above is still what actually answers.
@@ -78,7 +79,7 @@ public class HealthPageTests : BunitContext
.Returns(new Dictionary<string, SiteHealthState>());
Services.AddSingleton(aggregator);
// M6 K16 — the Health page now injects IKpiHistoryQueryService to feed the
// The Health page now injects IKpiHistoryQueryService to feed the
// per-site Site Health Trends panel. Stub it with a known non-empty series
// so the page resolves the dependency and the trend charts have data; the
// dedicated trend tests below seed sites / override behaviour.
@@ -94,12 +95,12 @@ public class HealthPageTests : BunitContext
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
Services.AddSingleton(siteRepo);
// PLAN-01 Task 14 — the Health page now shows the metrics-stale badge with
// The Health page now shows the metrics-stale badge with
// a tooltip naming the configured MetricsStaleTimeout, so it injects the
// health-monitoring options.
Services.AddSingleton(Options.Create(new HealthMonitoringOptions()));
// Audit Log (#23) M7 Bundle E — the Health page now also fetches the
// The Health page now also fetches the
// Audit KPI snapshot. Stub it with an empty point-in-time reading so
// the existing assertions (Notification Outbox tiles, Online/Offline
// counts) keep passing; tests that target the Audit tiles set their
@@ -125,7 +126,7 @@ public class HealthPageTests : BunitContext
// policy is evaluated; force the real one (same note as NavMenuTests).
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
// The cluster cards embed CentralFailoverControl (Task 10), which injects these two.
// The cluster cards embed CentralFailoverControl, which injects these two.
// The page's DI graph genuinely requires them in production, so the test supplies
// them rather than the component making its dependencies optional. Behaviour of the
// control itself is covered by HealthFailoverButtonTests.
@@ -136,7 +137,7 @@ public class HealthPageTests : BunitContext
/// <summary>
/// Renders the dashboard the way the app does — inside a
/// <see cref="CascadingAuthenticationState"/>, which the real layout supplies. The
/// cluster cards contain an AuthorizeView (the Task 10 failover control), and
/// cluster cards contain an AuthorizeView (the failover control), and
/// AuthorizeView throws without that cascading value.
/// </summary>
private IRenderedComponent<HealthPage> RenderHealthPage()
@@ -151,6 +152,7 @@ public class HealthPageTests : BunitContext
return host.FindComponent<HealthPage>();
}
/// <summary>Verifies that the Notification Outbox KPI tiles render with the supplied values.</summary>
[Fact]
public void Renders_OutboxKpiTiles_WithValues()
{
@@ -170,6 +172,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that the dashboard renders a link to the notification KPIs page.</summary>
[Fact]
public void RendersLinkToTheNotificationKpisPage()
{
@@ -178,6 +181,7 @@ public class HealthPageTests : BunitContext
Assert.Contains("View details", link.TextContent);
}
/// <summary>Verifies that the audit KPI tiles render with the supplied values.</summary>
[Fact]
public void Renders_AuditKpiTiles_WithValues()
{
@@ -207,6 +211,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that the Site Call KPI tiles render with the supplied values.</summary>
[Fact]
public void Renders_SiteCallKpiTiles_WithValues()
{
@@ -227,6 +232,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that the dashboard renders a link to the Site Calls report page.</summary>
[Fact]
public void RendersLinkToTheSiteCallsReportPage()
{
@@ -235,6 +241,7 @@ public class HealthPageTests : BunitContext
Assert.Contains("View details", link.TextContent);
}
/// <summary>Verifies that a Site Call KPI failure shows a graceful fallback.</summary>
[Fact]
public void SiteCallKpiFailure_ShowsGracefulFallback()
{
@@ -254,6 +261,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that a Notification Outbox KPI failure shows a graceful fallback.</summary>
[Fact]
public void OutboxKpiFailure_ShowsGracefulFallback()
{
@@ -271,6 +279,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that the site health trends panel and chart render for the selected site.</summary>
[Fact]
public void Renders_SiteHealthTrends_PanelAndChart_ForSelectedSite()
{
@@ -297,6 +306,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that a site health trends failure does not break the dashboard.</summary>
[Fact]
public void SiteHealthTrendsFailure_DoesNotBreakDashboard()
{
@@ -324,10 +334,11 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that the metrics-stale badge renders for an online site whose metrics are stale.</summary>
[Fact]
public void Renders_MetricsStaleBadge_ForOnlineButStaleSite()
{
// PLAN-01 Task 14: a site that is online (heartbeating) but whose report
// A site that is online (heartbeating) but whose report
// pipeline died shows a distinct "Metrics stale" warning badge.
SeedStates(new SiteHealthState
{
@@ -352,6 +363,7 @@ public class HealthPageTests : BunitContext
// Sustained LocalDb replication backlog badge (register row 33)
// ------------------------------------------------------------------
/// <summary>Verifies that the replication backlog badge renders when the backlog is sustained past the dwell.</summary>
[Fact]
public void Renders_ReplicationBacklogBadge_WhenBacklogSustainedPastTheDwell()
{
@@ -373,6 +385,7 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that the replication backlog badge is not rendered during a purge burst.</summary>
[Fact]
public void DoesNotRender_ReplicationBacklogBadge_DuringAPurgeBurst()
{
@@ -394,6 +407,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"localdb-backlog-badge\"", cut.Markup);
}
/// <summary>Verifies that the replication backlog badge is not rendered for an unreplicated site.</summary>
[Fact]
public void DoesNotRender_ReplicationBacklogBadge_ForAnUnreplicatedSite()
{
@@ -413,6 +427,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"localdb-backlog-badge\"", cut.Markup);
}
/// <summary>Verifies that the replication backlog badge is not rendered for an offline site.</summary>
[Fact]
public void DoesNotRender_ReplicationBacklogBadge_ForAnOfflineSite()
{
@@ -432,6 +447,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"localdb-backlog-badge\"", cut.Markup);
}
/// <summary>Verifies that the LocalDb backlog trend chart renders.</summary>
[Fact]
public void Renders_LocalDbBacklogTrendChart()
{
@@ -448,9 +464,10 @@ public class HealthPageTests : BunitContext
// ----- Register row 73: the "No self-heal" badge -----------------------------------
//
// Tri-state, and only the explicit false is badged. Null means "not reported" — the
// central self-report row, and every site node on a pre-row-73 build — so badging null
// central self-report row, and every site node on an older build — so badging null
// would light up the whole fleet the moment central is upgraded ahead of the sites.
/// <summary>Verifies that the startup reconciler badge renders when the site reports it is missing.</summary>
[Fact]
public void Renders_StartupReconcilerBadge_WhenTheSiteReportsItIsMissing()
{
@@ -468,6 +485,7 @@ public class HealthPageTests : BunitContext
Assert.Contains("data-test=\"startup-reconciler-badge\"", cut.Markup));
}
/// <summary>Verifies that the startup reconciler badge is not rendered when the reconciler is running.</summary>
[Fact]
public void DoesNotRender_StartupReconcilerBadge_WhenTheReconcilerIsRunning()
{
@@ -485,6 +503,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"startup-reconciler-badge\"", cut.Markup);
}
/// <summary>Verifies that the startup reconciler badge is not rendered when the status is not reported.</summary>
[Fact]
public void DoesNotRender_StartupReconcilerBadge_WhenNotReported()
{
@@ -504,6 +523,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"startup-reconciler-badge\"", cut.Markup);
}
/// <summary>Verifies that the startup reconciler badge is not rendered when the site has no report yet.</summary>
[Fact]
public void DoesNotRender_StartupReconcilerBadge_WhenTheSiteHasNoReportYet()
{
@@ -521,6 +541,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"startup-reconciler-badge\"", cut.Markup);
}
/// <summary>Verifies that the startup reconciler badge is not rendered for an offline site.</summary>
[Fact]
public void DoesNotRender_StartupReconcilerBadge_ForAnOfflineSite()
{
@@ -559,11 +580,12 @@ public class HealthPageTests : BunitContext
StartupReconcilerActive = active,
};
// ----- Review finding M1: per-node deployed-secrets mirror failures ------------------
// ----- Per-node deployed-secrets mirror failures ------------------
//
// The standby's failures ride the active node's report; the card must name the node,
// the secret and the message. Null ("no data") and all-healthy nodes render nothing.
/// <summary>Verifies that secret mirror failures render labelled by node.</summary>
[Fact]
public void Renders_SecretMirrorFailures_LabelledByNode()
{
@@ -598,8 +620,10 @@ public class HealthPageTests : BunitContext
});
}
/// <summary>Verifies that no secret mirror warning renders when no node is failing.</summary>
/// <param name="reported">Whether the site reports secret mirror status at all.</param>
[Theory]
[InlineData(false)] // null = "no data" (central self-report, pre-M1 site)
[InlineData(false)] // null = "no data" (central self-report, older site)
[InlineData(true)] // every node healthy
public void DoesNotRender_SecretMirrorWarning_WhenNoNodeIsFailing(bool reported)
{
@@ -623,6 +647,7 @@ public class HealthPageTests : BunitContext
Assert.DoesNotContain("data-test=\"secret-mirror-failures\"", cut.Markup);
}
/// <summary>Verifies that the offline-since time renders for an offline site with a status change.</summary>
[Fact]
public void Renders_OfflineSince_ForOfflineSiteWithStatusChange()
{
@@ -688,6 +713,7 @@ public class HealthPageTests : BunitContext
};
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -703,6 +729,8 @@ public class HealthPageTests : BunitContext
/// </summary>
private sealed class ScriptedOutboxActor : ReceiveActor
{
/// <summary>Initializes the actor with the test whose scripted reply it returns.</summary>
/// <param name="test">The owning test that holds the scripted reply.</param>
public ScriptedOutboxActor(HealthPageTests test)
{
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
@@ -712,11 +740,13 @@ public class HealthPageTests : BunitContext
/// <summary>
/// Stand-in for the Site Call Audit actor. Replies to the KPI request with
/// the test's currently-scripted response. Also handles the per-node KPI
/// request (T6: M5.2) with an empty-nodes success reply so the Health page
/// request with an empty-nodes success reply so the Health page
/// can complete initialization without a 30-second Ask timeout.
/// </summary>
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
/// <summary>Initializes the actor with the test whose scripted replies it returns.</summary>
/// <param name="test">The owning test that holds the scripted replies.</param>
public ScriptedSiteCallAuditActor(HealthPageTests test)
{
Receive<SiteCallKpiRequest>(_ => Sender.Tell(test._siteCallKpiReply));
@@ -48,7 +48,7 @@ public class NotificationKpisPageTests : BunitContext
DeliveredLastInterval: 9, OldestPendingAge: TimeSpan.FromMinutes(7)),
});
// KPI-history trend service (K13 / T11). Default substitute returns a
// KPI-history trend service Default substitute returns a
// 3-point series for any (source, metric, scope, …) tuple so every chart
// renders a polyline; individual tests can re-stub it (e.g. to throw).
private readonly IKpiHistoryQueryService _kpiHistory = Substitute.For<IKpiHistoryQueryService>();
@@ -60,6 +60,7 @@ public class NotificationKpisPageTests : BunitContext
new(DateTime.UtcNow.AddHours(-1), 4),
};
/// <summary>Initializes a new instance of the <c>NotificationKpisPageTests</c> class.</summary>
public NotificationKpisPageTests()
{
_comms = new CommunicationService(
@@ -71,7 +72,7 @@ public class NotificationKpisPageTests : BunitContext
Services.AddSingleton(_comms);
// arch-review WP2.4 — the page reads the process-level memoized KPI cache
// The page reads the process-level memoized KPI cache
// rather than querying CommunicationService per circuit. The real cache is
// registered so the scripted-actor seam above still answers the queries.
Services.AddSingleton<IKpiSnapshotCache>(sp => new KpiSnapshotCache(
@@ -104,6 +105,8 @@ public class NotificationKpisPageTests : BunitContext
Services.AddTestConfigurationMutations();
}
/// <summary>Verifies that the page requires the deployment authorization policy.</summary>
/// <returns>The result.</returns>
[Fact]
public void Page_RequiresDeploymentPolicy()
{
@@ -116,6 +119,8 @@ public class NotificationKpisPageTests : BunitContext
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
/// <summary>Verifies that the page renders the global KPI tiles and the per-site rows.</summary>
/// <returns>The result.</returns>
[Fact]
public void RendersGlobalTilesAndPerSiteRows()
{
@@ -130,6 +135,8 @@ public class NotificationKpisPageTests : BunitContext
});
}
/// <summary>Verifies that the page shows an error when the global KPI query fails.</summary>
/// <returns>The result.</returns>
[Fact]
public void ShowsKpiError_WhenGlobalKpiQueryFails()
{
@@ -141,6 +148,8 @@ public class NotificationKpisPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Contains("kpi down", cut.Markup));
}
/// <summary>Verifies that the page shows a per-site error, without the global error, when the per-site KPI query fails.</summary>
/// <returns>The result.</returns>
[Fact]
public void ShowsPerSiteError_WhenPerSiteKpiQueryFails()
{
@@ -159,6 +168,8 @@ public class NotificationKpisPageTests : BunitContext
});
}
/// <summary>Verifies that the page shows the per-site empty state when there are no sites.</summary>
/// <returns>The result.</returns>
[Fact]
public void ShowsPerSiteEmptyState_WhenNoSites()
{
@@ -170,6 +181,8 @@ public class NotificationKpisPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Contains("No per-site activity", cut.Markup));
}
/// <summary>Verifies that the page renders trend charts when history series are available.</summary>
/// <returns>The result.</returns>
[Fact]
public void RendersTrendCharts_WhenSeriesAvailable()
{
@@ -186,6 +199,8 @@ public class NotificationKpisPageTests : BunitContext
});
}
/// <summary>Verifies that a trend query failure for one metric degrades only that metric's chart.</summary>
/// <returns>The result.</returns>
[Fact]
public void PartialTrendFailure_IsolatesToTheFailingMetric()
{
@@ -224,6 +239,8 @@ public class NotificationKpisPageTests : BunitContext
});
}
/// <summary>Verifies that a failing trend query does not break the page.</summary>
/// <returns>The result.</returns>
[Fact]
public void TrendQueryFailure_DoesNotBreakPage()
{
@@ -249,6 +266,7 @@ public class NotificationKpisPageTests : BunitContext
});
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -261,11 +279,13 @@ public class NotificationKpisPageTests : BunitContext
/// <summary>
/// Stand-in for the notification-outbox actor. Replies to each KPI message
/// type with the test's currently-scripted response. Also handles the per-node
/// KPI request (T6: M5.2) with an empty-nodes success reply so the page can
/// KPI request with an empty-nodes success reply so the page can
/// complete initialization without a 30-second Ask timeout.
/// </summary>
private sealed class ScriptedOutboxActor : ReceiveActor
{
/// <summary>Initializes a new instance of the <c>ScriptedOutboxActor</c> class.</summary>
/// <param name="test">The test.</param>
public ScriptedOutboxActor(NotificationKpisPageTests test)
{
Receive<NotificationKpiRequest>(_ => Sender.Tell(test._kpiReply));
@@ -20,6 +20,7 @@ public class NotificationListFormTests : BunitContext
{
private readonly INotificationRepository _repo = Substitute.For<INotificationRepository>();
/// <summary>Initializes the test context with a substitute repository, a channel catalog and test authentication.</summary>
public NotificationListFormTests()
{
Services.AddSingleton(_repo);
@@ -46,6 +47,7 @@ public class NotificationListFormTests : BunitContext
Services.AddTestConfigurationMutations();
}
/// <summary>Verifies that the type selector renders the registered channels.</summary>
[Fact]
public void TypeSelector_RendersRegisteredChannels()
{
@@ -61,6 +63,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that the type selector is enabled when creating a list.</summary>
[Fact]
public void TypeSelector_IsEnabled_OnCreate()
{
@@ -73,6 +76,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that selecting the email type shows the email input and not the phone input.</summary>
[Fact]
public void SelectingEmail_ShowsEmailInput_NotPhone()
{
@@ -93,6 +97,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that selecting the SMS type shows the phone input and not the email input.</summary>
[Fact]
public void SelectingSms_ShowsPhoneInput_NotEmail()
{
@@ -111,6 +116,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that the type selector is disabled when editing an existing list.</summary>
[Fact]
public void TypeSelector_IsDisabled_OnEdit()
{
@@ -128,6 +134,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that creating an SMS list persists the SMS type.</summary>
[Fact]
public void CreatingSmsList_PersistsTypeSms()
{
@@ -150,6 +157,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that adding an SMS recipient creates an SMS recipient with its phone number set.</summary>
[Fact]
public void AddingSmsRecipient_CreatesForSmsRecipient_WithPhoneSet()
{
@@ -179,6 +187,7 @@ public class NotificationListFormTests : BunitContext
});
}
/// <summary>Verifies that adding an SMS recipient with an invalid phone number is rejected.</summary>
[Fact]
public void AddingSmsRecipient_RejectsInvalidPhone()
{
@@ -13,7 +13,7 @@ using NotificationListsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.N
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the standalone Notification Lists page (Task 7).
/// bUnit rendering tests for the standalone Notification Lists page.
/// </summary>
public class NotificationListsPageTests : BunitContext
{
@@ -32,6 +32,7 @@ public class NotificationListsPageTests : BunitContext
Services.AddTestConfigurationMutations();
}
/// <summary>Verifies that the page renders a row for each notification list.</summary>
[Fact]
public void RendersNotificationListRows()
{
@@ -54,6 +55,7 @@ public class NotificationListsPageTests : BunitContext
});
}
/// <summary>Verifies that the page shows an empty state when there are no lists.</summary>
[Fact]
public void ShowsEmptyState_WhenNoLists()
{
@@ -70,6 +72,7 @@ public class NotificationListsPageTests : BunitContext
Assert.Contains("No notification lists", cut.Markup));
}
/// <summary>Verifies that deleting a list confirms, deletes it and reloads the list.</summary>
[Fact]
public void DeleteList_ConfirmsThenDeletesAndReloads()
{
@@ -100,6 +103,7 @@ public class NotificationListsPageTests : BunitContext
});
}
/// <summary>Verifies that the type column renders SMS for an SMS list.</summary>
[Fact]
public void RendersTypeColumn_Sms()
{
@@ -125,6 +129,7 @@ public class NotificationListsPageTests : BunitContext
});
}
/// <summary>Verifies that an SMS list recipient badge shows the phone number instead of empty angle brackets.</summary>
[Fact]
public void SmsListRecipientBadge_ShowsPhoneNumber_NotEmptyAngleBrackets()
{
@@ -154,6 +159,7 @@ public class NotificationListsPageTests : BunitContext
});
}
/// <summary>Verifies that the type column renders Email for an email list.</summary>
[Fact]
public void RendersTypeColumn_Email()
{
@@ -192,19 +198,28 @@ public class NotificationListsPageTests : BunitContext
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
// Register row 75 added a DialogSeverity overload. This stub auto-confirms
// regardless of severity — it exists to let action paths run end to end, not to
// assert anything about styling.
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
/// <inheritdoc />
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
/// <summary>Returns a completed task with the default result without showing a dialog.</summary>
/// <typeparam name="TResult">The dialog result type.</typeparam>
/// <param name="title">The dialog title.</param>
/// <param name="body">The dialog body fragment.</param>
/// <param name="size">The optional dialog size.</param>
/// <returns>A task that resolves to the default result.</returns>
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
@@ -65,6 +65,7 @@ public class NotificationReportDetailModalTests : BunitContext
DeliveredAt: DateTimeOffset.UtcNow.AddHours(-2), IsStuck: false),
}, TotalCount: 2);
/// <summary>Initializes the test context, registering the fake outbox actor and dialog service.</summary>
public NotificationReportDetailModalTests()
{
_comms = new CommunicationService(
@@ -98,6 +99,7 @@ public class NotificationReportDetailModalTests : BunitContext
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
/// <summary>Verifies that double click row, opens detail modal.</summary>
[Fact]
public void DoubleClickRow_OpensDetailModal()
{
@@ -119,6 +121,7 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
/// <summary>Verifies that modal, shows full notification id, not truncated.</summary>
[Fact]
public void Modal_ShowsFullNotificationId_NotTruncated()
{
@@ -138,6 +141,7 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
/// <summary>Verifies that close button, dismisses modal.</summary>
[Fact]
public void CloseButton_DismissesModal()
{
@@ -156,6 +160,7 @@ public class NotificationReportDetailModalTests : BunitContext
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".modal.show")));
}
/// <summary>Verifies that modal, shows last error, when present.</summary>
[Fact]
public void Modal_ShowsLastError_WhenPresent()
{
@@ -173,6 +178,7 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
/// <summary>Verifies that modal, fetches and shows body.</summary>
[Fact]
public void Modal_FetchesAndShowsBody()
{
@@ -192,6 +198,7 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
/// <summary>Verifies that modal, shows recipients, from resolved targets.</summary>
[Fact]
public void Modal_ShowsRecipients_FromResolvedTargets()
{
@@ -210,6 +217,7 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
/// <summary>Verifies that modal, shows list fallback, when resolved targets null.</summary>
[Fact]
public void Modal_ShowsListFallback_WhenResolvedTargetsNull()
{
@@ -234,6 +242,7 @@ public class NotificationReportDetailModalTests : BunitContext
});
}
/// <summary>Verifies that modal, shows error, when detail fetch fails.</summary>
[Fact]
public void Modal_ShowsError_WhenDetailFetchFails()
{
@@ -310,6 +319,7 @@ public class NotificationReportDetailModalTests : BunitContext
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".modal.show")));
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -321,6 +331,8 @@ public class NotificationReportDetailModalTests : BunitContext
private sealed class ScriptedOutboxActor : ReceiveActor
{
/// <summary>Creates the scripted outbox actor bound to the owning test class.</summary>
/// <param name="test">The owning test whose scripted responses the actor replays.</param>
public ScriptedOutboxActor(NotificationReportDetailModalTests test)
{
Receive<NotificationOutboxQueryRequest>(_ => Sender.Tell(test._queryReply));
@@ -337,19 +349,28 @@ public class NotificationReportDetailModalTests : BunitContext
private sealed class AlwaysConfirmDialogService : IDialogService
{
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
// Register row 75 added a DialogSeverity overload. This stub auto-confirms
// regardless of severity — it exists to let action paths run end to end, not to
// assert anything about styling.
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
/// <inheritdoc />
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
/// <summary>Returns the default result without showing a dialog.</summary>
/// <typeparam name="TResult">The type of the dialog result.</typeparam>
/// <param name="title">The dialog title (ignored).</param>
/// <param name="body">The dialog body (ignored).</param>
/// <param name="size">The dialog size (ignored).</param>
/// <returns>A task whose result is the default value.</returns>
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
@@ -54,6 +54,7 @@ public class NotificationReportPageTests : BunitContext
private readonly List<RetryNotificationRequest> _retryRequests = new();
private readonly List<DiscardNotificationRequest> _discardRequests = new();
/// <summary>Initializes the test class and registers the services the page needs.</summary>
public NotificationReportPageTests()
{
_comms = new CommunicationService(
@@ -84,12 +85,13 @@ public class NotificationReportPageTests : BunitContext
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
Services.AddTestConfigurationMutations();
// CentralUI-028: the page now injects SiteScopeService — the test user
// The page now injects SiteScopeService — the test user
// has no SiteId claims, so this resolves to system-wide and the
// pre-existing test expectations hold.
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
/// <summary>Verifies that the page requires the Deployment policy.</summary>
[Fact]
public void Page_RequiresDeploymentPolicy()
{
@@ -102,6 +104,7 @@ public class NotificationReportPageTests : BunitContext
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
/// <summary>Verifies that the page renders the notification rows.</summary>
[Fact]
public void Renders_NotificationRows()
{
@@ -115,6 +118,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that a stuck row is badged.</summary>
[Fact]
public void StuckRow_IsBadged()
{
@@ -131,6 +135,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that clicking Retry on a parked row calls the retry notification command.</summary>
[Fact]
public void ClickRetry_OnParkedRow_CallsRetryNotification()
{
@@ -152,6 +157,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that clicking Discard on a parked row calls the discard notification command.</summary>
[Fact]
public void ClickDiscard_OnParkedRow_CallsDiscardNotification()
{
@@ -173,6 +179,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that a query failure shows an error message.</summary>
[Fact]
public void QueryFailure_ShowsErrorMessage()
{
@@ -187,10 +194,11 @@ public class NotificationReportPageTests : BunitContext
}
// ─────────────────────────────────────────────────────────────────────────
// Bundle D drill-in (#23 M7-T10) — every notification row carries a
// Audit drill-in — every notification row carries a
// "View audit history" link to /audit/log?correlationId={NotificationId}.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the View audit history link on a notification row has the correct href.</summary>
[Fact]
public void NotificationRow_ViewAuditHistory_Link_HasCorrectHref()
{
@@ -220,6 +228,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that clicking the audit history link navigates to the Audit Log with the correlation id.</summary>
[Fact]
public void Click_NavigatesTo_AuditLog_WithCorrelationId()
{
@@ -249,6 +258,7 @@ public class NotificationReportPageTests : BunitContext
// with that id comes back.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a dashed GUID in the correlation id query is normalised to the stored N form.</summary>
[Fact]
public void NotificationReport_CorrelationIdQuery_DashedGuid_IsNormalisedToTheStoredNForm()
{
@@ -268,6 +278,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that the correlation id query seeds the notification filter and automatically opens the detail view.</summary>
[Fact]
public void NotificationReport_CorrelationIdQuery_SeedsTheNotificationFilterAndAutoOpensDetail()
{
@@ -303,6 +314,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that clearing the filters drops the notification filter.</summary>
[Fact]
public void ClearFilters_DropsTheNotificationFilter()
{
@@ -326,6 +338,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <summary>Verifies that clearing the notification filter pill drops the filter and re-queries.</summary>
[Fact]
public void NotificationFilterPill_Clear_DropsTheFilterAndRequeries()
{
@@ -346,6 +359,7 @@ public class NotificationReportPageTests : BunitContext
});
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -361,6 +375,8 @@ public class NotificationReportPageTests : BunitContext
/// </summary>
private sealed class ScriptedOutboxActor : ReceiveActor
{
/// <summary>Initializes the actor with the test that owns it.</summary>
/// <param name="test">The test.</param>
public ScriptedOutboxActor(NotificationReportPageTests test)
{
Receive<NotificationOutboxQueryRequest>(req =>
@@ -397,19 +413,28 @@ public class NotificationReportPageTests : BunitContext
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
// Register row 75 added a DialogSeverity overload. This stub auto-confirms
// regardless of severity — it exists to let action paths run end to end, not to
// assert anything about styling.
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
/// <inheritdoc />
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
/// <summary>Returns the default result without showing a dialog.</summary>
/// <typeparam name="TResult">The dialog result type.</typeparam>
/// <param name="title">The dialog title.</param>
/// <param name="body">The dialog body fragment.</param>
/// <param name="size">The optional dialog size.</param>
/// <returns>A task that resolves to the default result.</returns>
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
@@ -66,6 +66,9 @@ public class PageScopedDetailStateTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>Verifies that paging the Parked Messages list closes the open detail drawer.</summary>
/// <param name="pagingMethod">The name of the paging method to invoke.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("PrevPage")]
[InlineData("NextPage")]
@@ -94,6 +97,8 @@ public class PageScopedDetailStateTests : BunitContext
Assert.Null(GetPrivate(cut.Instance, "_drawerMessageId"));
}
/// <summary>Verifies that paging the configuration audit log closes the open state modal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConfigurationAuditLog_Paging_ClosesTheStateModal()
{
@@ -113,7 +118,10 @@ public class PageScopedDetailStateTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the stub with the authentication state it returns.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -31,7 +31,7 @@ using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Des
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// CentralUI-033: tests for the drill-in / query-string code paths on the two
/// Tests for the drill-in / query-string code paths on the two
/// newest pages (TransportImport + SiteCallsReport). The base happy-path cases
/// (Parked, stuck=true, no params) live next to the rest of the page's tests in
/// <c>SiteCallsReportPageTests</c> / <c>TransportImportPageTests</c>; this file
@@ -40,12 +40,12 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// </summary>
public sealed class QueryStringDrillInTests
{
// STM: CentralUI-033-QueryStringDrillIn marker — used by grep verification.
// -----------------------------------------------------------------
// SiteCallsReport — ?status=
// -----------------------------------------------------------------
/// <summary>Verifies that a case-insensitive status query parameter on the site calls report normalises to the canonical casing.</summary>
[Fact]
public void SiteCallsReport_StatusParam_CaseInsensitiveMatch_NormalisesToCanonicalCasing()
{
@@ -68,6 +68,7 @@ public sealed class QueryStringDrillInTests
});
}
/// <summary>Verifies that an unrecognised status query parameter on the site calls report is silently dropped.</summary>
[Fact]
public void SiteCallsReport_StatusParam_Unrecognised_IsSilentlyDropped()
{
@@ -88,6 +89,7 @@ public sealed class QueryStringDrillInTests
});
}
/// <summary>Verifies that a non-boolean stuck query parameter on the site calls report is silently dropped.</summary>
[Fact]
public void SiteCallsReport_StuckParam_NonBoolean_IsSilentlyDropped()
{
@@ -110,6 +112,7 @@ public sealed class QueryStringDrillInTests
// SiteCallsReport — ?correlationId= (Flow Debugger / Audit Log deep link, D2)
// -----------------------------------------------------------------
/// <summary>Verifies that the correlation id query parameter seeds the operation filter on the site calls report.</summary>
[Fact]
public void SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilter()
{
@@ -131,6 +134,7 @@ public sealed class QueryStringDrillInTests
});
}
/// <summary>Verifies that a malformed correlation id query parameter on the site calls report is ignored.</summary>
[Fact]
public void SiteCallsReport_CorrelationIdQuery_Malformed_IsIgnored()
{
@@ -155,6 +159,7 @@ public sealed class QueryStringDrillInTests
// NotificationReport — ?correlationId= (Flow Debugger follow-ups, D2)
// -----------------------------------------------------------------
/// <summary>Verifies that a blank correlation id query parameter on the notification report is ignored.</summary>
[Fact]
public void NotificationReport_CorrelationIdQuery_Blank_IsIgnored()
{
@@ -180,7 +185,7 @@ public sealed class QueryStringDrillInTests
// -----------------------------------------------------------------
/// <summary>
/// CentralUI-033: TransportImport.razor declares no <c>[Parameter]</c> /
/// TransportImport.razor declares no <c>[Parameter]</c> /
/// <c>SupplyParameterFromQuery</c> bindings — the wizard's initial state is
/// purely <c>ImportWizardStep.Upload</c> regardless of the query-string. This
/// test pins that contract: navigating with an unrecognised query-string
@@ -204,7 +209,7 @@ public sealed class QueryStringDrillInTests
.GetValue(cut.Instance)!;
Assert.Equal(TransportImportPage.ImportWizardStep.Upload, step);
// And the Step-1 InputFile control is rendered — the page came up clean.
// And the first-step InputFile control is rendered — the page came up clean.
Assert.NotNull(cut.Find("input[type='file']"));
}
@@ -222,6 +227,7 @@ public sealed class QueryStringDrillInTests
public readonly CommunicationService Comms;
public readonly List<SiteCallQueryRequest> QueryRequests = new();
/// <summary>Initializes a new instance of the <c>SiteCallsReportFixture</c> class.</summary>
public SiteCallsReportFixture()
{
Comms = new CommunicationService(
@@ -233,7 +239,7 @@ public sealed class QueryStringDrillInTests
Services.AddSingleton(Comms);
Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());
// M6 (K14): the SiteCallsReport page now injects IKpiHistoryQueryService
// The SiteCallsReport page now injects IKpiHistoryQueryService
// for the Trends section. Provide a stand-in so these drill-in tests render.
Services.AddSingleton(Substitute.For<IKpiHistoryQueryService>());
@@ -257,6 +263,7 @@ public sealed class QueryStringDrillInTests
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -269,6 +276,8 @@ public sealed class QueryStringDrillInTests
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
/// <summary>Initializes a new instance of the <c>ScriptedSiteCallAuditActor</c> class.</summary>
/// <param name="fixture">The fixture supplying the scripted replies.</param>
public ScriptedSiteCallAuditActor(SiteCallsReportFixture fixture)
{
Receive<SiteCallQueryRequest>(req =>
@@ -287,6 +296,7 @@ public sealed class QueryStringDrillInTests
public readonly CommunicationService Comms;
public readonly List<NotificationOutboxQueryRequest> QueryRequests = new();
/// <summary>Initializes a new instance of the <c>NotificationReportFixture</c> class.</summary>
public NotificationReportFixture()
{
Comms = new CommunicationService(
@@ -319,6 +329,7 @@ public sealed class QueryStringDrillInTests
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -331,6 +342,8 @@ public sealed class QueryStringDrillInTests
private sealed class ScriptedNotificationOutboxActor : ReceiveActor
{
/// <summary>Initializes a new instance of the <c>ScriptedNotificationOutboxActor</c> class.</summary>
/// <param name="fixture">The fixture supplying the scripted replies.</param>
public ScriptedNotificationOutboxActor(NotificationReportFixture fixture)
{
Receive<NotificationOutboxQueryRequest>(req =>
@@ -345,17 +358,26 @@ public sealed class QueryStringDrillInTests
private sealed class AlwaysConfirmDialogService : IDialogService
{
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
// Register row 75 added a DialogSeverity overload. This stub auto-confirms
// regardless of severity — it exists to let action paths run end to end, not to
// assert anything about styling.
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
/// <inheritdoc />
public Task<string?> PromptAsync(string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
/// <summary>Returns the default result without showing a dialog.</summary>
/// <typeparam name="TResult">The dialog result type.</typeparam>
/// <param name="title">The dialog title.</param>
/// <param name="body">The dialog body fragment.</param>
/// <param name="size">The optional dialog size.</param>
/// <returns>A task that resolves to the default result.</returns>
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
@@ -365,6 +387,7 @@ public sealed class QueryStringDrillInTests
private sealed class TransportImportFixture : BunitContext
{
/// <summary>Initializes a new instance of the <c>TransportImportFixture</c> class.</summary>
public TransportImportFixture()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -372,7 +395,7 @@ public sealed class QueryStringDrillInTests
var importer = Substitute.For<IBundleImporter>();
Services.AddSingleton(importer);
Services.AddSingleton(Substitute.For<IAuditService>());
// M8 E2: TransportImport's Map step injects ISiteRepository.
// TransportImport's Map step injects ISiteRepository.
Services.AddSingleton(Substitute.For<ISiteRepository>());
// Register row 44: the wizard reads the circuit's client IP for the
// per-IP unlock throttle. No HTTP context under bUnit — use the seam.
@@ -22,7 +22,7 @@ using SiteCallsReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Sit
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>
/// bUnit rendering tests for the Site Calls report page (Site Call Audit #22).
/// bUnit rendering tests for the Site Calls report page.
///
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
/// non-virtual methods, so NSubstitute cannot intercept it. The page's calls all
@@ -82,6 +82,7 @@ public class SiteCallsReportPageTests : BunitContext
private DiscardSiteCallResponse _discardReply =
new("q", SiteCallRelayOutcome.Applied, true, true, null);
/// <summary>Initializes the bUnit context with the fakes and services the Site Calls report page depends on.</summary>
public SiteCallsReportPageTests()
{
_comms = new CommunicationService(
@@ -129,6 +130,7 @@ public class SiteCallsReportPageTests : BunitContext
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
}
/// <summary>Verifies that the page requires the Deployment authorization policy.</summary>
[Fact]
public void Page_RequiresDeploymentPolicy()
{
@@ -141,6 +143,7 @@ public class SiteCallsReportPageTests : BunitContext
Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
}
/// <summary>Verifies that the page renders a row for each site call returned by the query.</summary>
[Fact]
public void Renders_SiteCallRows()
{
@@ -153,6 +156,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that a stuck row is shown with a stuck badge.</summary>
[Fact]
public void StuckRow_IsBadged()
{
@@ -167,6 +171,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that the Retry and Discard buttons are shown only on parked rows.</summary>
[Fact]
public void RetryDiscardButtons_ShownOnlyOnParkedRows()
{
@@ -192,6 +197,7 @@ public class SiteCallsReportPageTests : BunitContext
b => b.TextContent.Contains("Discard"));
}
/// <summary>Verifies that clicking Retry on a parked row relays the retry to the owning site.</summary>
[Fact]
public void ClickRetry_OnParkedRow_RelaysRetryToOwningSite()
{
@@ -215,6 +221,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that clicking Discard on a parked row relays the discard to the owning site.</summary>
[Fact]
public void ClickDiscard_OnParkedRow_RelaysDiscardToOwningSite()
{
@@ -237,6 +244,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that a retry relay to an unreachable site shows a distinct message.</summary>
[Fact]
public void RetryRelay_SiteUnreachable_ShowsDistinctMessage()
{
@@ -259,6 +267,7 @@ public class SiteCallsReportPageTests : BunitContext
Assert.Contains("offline", cut.Markup));
}
/// <summary>Verifies that a failed query shows an error message.</summary>
[Fact]
public void QueryFailure_ShowsErrorMessage()
{
@@ -277,6 +286,7 @@ public class SiteCallsReportPageTests : BunitContext
// /audit/log?correlationId={TrackedOperationId}.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the View Audit History link on a row has the correct href.</summary>
[Fact]
public void SiteCallRow_ViewAuditHistory_Link_HasCorrectHref()
{
@@ -310,6 +320,7 @@ public class SiteCallsReportPageTests : BunitContext
// page numbers; the request echoes the cursor back to the actor.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the Next button is hidden when there is no further page.</summary>
[Fact]
public void Paging_NextButton_HiddenWhenNoFurtherPage()
{
@@ -324,6 +335,7 @@ public class SiteCallsReportPageTests : BunitContext
Assert.True(prev.HasAttribute("disabled"));
}
/// <summary>Verifies that the Next button advances using the keyset cursor.</summary>
[Fact]
public void Paging_NextButton_AdvancesUsingKeysetCursor()
{
@@ -364,6 +376,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that the Previous button pops the back stack and refetches using the prior cursor.</summary>
[Fact]
public void Paging_PrevButton_PopsBackStackAndRefetchesPriorCursor()
{
@@ -407,7 +420,7 @@ public class SiteCallsReportPageTests : BunitContext
cut.WaitForAssertion(() =>
{
// Query 3 is the Previous refetch — the back-stack popped the page-1
// Query 3 is the Previous refetch — the back-stack popped the page 1
// cursor, which is the empty (null, null) first-page cursor.
Assert.Equal(3, _queryRequests.Count);
Assert.Null(_queryRequests[2].AfterCreatedAtUtc);
@@ -417,6 +430,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that retrying a row that is no longer parked shows an info message and exactly one toast.</summary>
[Fact]
public void RetryRelay_NotParked_ShowsInfoMessage_AndExactlyOneToast()
{
@@ -453,6 +467,7 @@ public class SiteCallsReportPageTests : BunitContext
// is already filtered, and the filter card controls must reflect the values.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that navigating with the parked status query parameter loads the grid pre-filtered to parked rows.</summary>
[Fact]
public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked()
{
@@ -476,6 +491,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that navigating with the stuck query parameter set to true loads the grid with the stuck filter applied.</summary>
[Fact]
public void NavigateWithStuckTrueParam_LoadsGridWithStuckFilterApplied()
{
@@ -497,6 +513,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that navigating without query parameters loads the grid unfiltered.</summary>
[Fact]
public void NavigateWithNoQueryParams_LoadsGridUnfiltered()
{
@@ -521,9 +538,10 @@ public class SiteCallsReportPageTests : BunitContext
// Flow Debugger / Audit Log deep link: ?correlationId=<TrackedOperationId>
// seeds a read-only "Operation" pill filter, rides on the query as
// TrackedOperationIdFilter, and auto-opens the detail modal when exactly
// one matching row comes back (follow-ups plan D2 / Task 4).
// one matching row comes back.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that a correlation ID query parameter seeds the operation filter and automatically opens the detail view.</summary>
[Fact]
public void SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilterAndAutoOpensDetail()
{
@@ -558,6 +576,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that a correlation ID query parameter with no matching row does not open the detail view.</summary>
[Fact]
public void SiteCallsReport_CorrelationIdQuery_NoMatchingRow_DoesNotOpenDetail()
{
@@ -580,6 +599,7 @@ public class SiteCallsReportPageTests : BunitContext
Assert.Empty(_detailRequests);
}
/// <summary>Verifies that clearing the filters drops the operation filter.</summary>
[Fact]
public void ClearFilters_DropsTheOperationFilter()
{
@@ -597,6 +617,7 @@ public class SiteCallsReportPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']")));
}
/// <summary>Verifies that the operation pill's clear button drops the filter and requeries.</summary>
[Fact]
public void OperationPill_ClearButton_DropsTheFilterAndRequeries()
{
@@ -616,10 +637,11 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that a Deployment user scoped to one site does not see rows from out-of-scope sites.</summary>
[Fact]
public void SiteScoping_ScopedDeploymentUser_HidesOutOfScopeRows()
{
// CentralUI-028: a Deployment user scoped to Plant A only must not see
// A Deployment user scoped to Plant A only must not see
// Plant B rows in the grid, even though the query response carried both.
// Last AuthenticationStateProvider registration wins on resolution.
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
@@ -647,6 +669,7 @@ public class SiteCallsReportPageTests : BunitContext
// section is collapsed on init; expanding it lazy-loads the series.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that the trends section container always renders.</summary>
[Fact]
public void TrendsSection_Container_AlwaysRenders()
{
@@ -658,6 +681,7 @@ public class SiteCallsReportPageTests : BunitContext
Assert.NotNull(cut.Find("[data-test='site-calls-trends']")));
}
/// <summary>Verifies that an expanded trends section renders charts from the known series.</summary>
[Fact]
public void TrendsSection_WhenExpanded_RendersChartsFromKnownSeries()
{
@@ -680,6 +704,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <summary>Verifies that toggling the trends window reloads the series.</summary>
[Fact]
public void TrendsSection_WindowToggle_ReloadsSeries()
{
@@ -700,10 +725,11 @@ public class SiteCallsReportPageTests : BunitContext
Assert.True(_kpiHistory.ReceivedCalls().Count() > afterExpand));
}
/// <summary>Verifies that the long-range trend windows render and reload the series.</summary>
[Fact]
public void TrendsSection_LongRangeWindows_RenderAndReloadSeries()
{
// The 30d/90d buttons (plan #22 T7) render alongside 24h/7d and clicking
// The 30d/90d buttons render alongside 24h/7d and clicking
// one re-loads the series — the query service then routes the long window
// to hourly rollups transparently.
var cut = Render<SiteCallsReportPage>();
@@ -725,6 +751,7 @@ public class SiteCallsReportPageTests : BunitContext
Assert.True(_kpiHistory.ReceivedCalls().Count() > afterExpand));
}
/// <summary>Verifies that the page still renders when the KPI history query throws.</summary>
[Fact]
public void TrendsSection_WhenKpiHistoryThrows_PageStillRenders()
{
@@ -751,6 +778,7 @@ public class SiteCallsReportPageTests : BunitContext
});
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -766,6 +794,8 @@ public class SiteCallsReportPageTests : BunitContext
/// </summary>
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
/// <summary>Creates the stand-in actor, wiring each request type to the owning test's recorded requests and scripted replies.</summary>
/// <param name="test">The owning test whose recorded requests and scripted replies the actor uses.</param>
public ScriptedSiteCallAuditActor(SiteCallsReportPageTests test)
{
Receive<SiteCallQueryRequest>(r =>
@@ -794,19 +824,28 @@ public class SiteCallsReportPageTests : BunitContext
/// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
private sealed class AlwaysConfirmDialogService : IDialogService
{
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, bool danger = false, string? confirmLabel = null)
=> Task.FromResult(true);
// Register row 75 added a DialogSeverity overload. This stub auto-confirms
// regardless of severity — it exists to let action paths run end to end, not to
// assert anything about styling.
/// <inheritdoc />
public Task<bool> ConfirmAsync(string title, string message, DialogSeverity severity, string? confirmLabel = null)
=> Task.FromResult(true);
/// <inheritdoc />
public Task<string?> PromptAsync(
string title, string label, string initialValue = "", string? placeholder = null)
=> Task.FromResult<string?>(null);
/// <summary>Returns a null result without rendering the dialog.</summary>
/// <typeparam name="TResult">The dialog result type.</typeparam>
/// <param name="title">The dialog title, ignored.</param>
/// <param name="body">The dialog body fragment, ignored.</param>
/// <param name="size">The dialog size, ignored.</param>
/// <returns>A task that completes with a null result.</returns>
public Task<TResult?> ShowAsync<TResult>(
string title,
Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
@@ -52,6 +52,7 @@ public class SmsConfigurationPageTests : BunitContext
return repo;
}
/// <summary>Verifies that the read-only view shows the config row but never renders the auth token value.</summary>
[Fact]
public void ReadOnlyView_ShowsConfigRow_ButNeverRendersAuthTokenValue()
{
@@ -74,6 +75,7 @@ public class SmsConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that the edit form does not prefill the auth token but does prefill the messaging service SID.</summary>
[Fact]
public void EditForm_DoesNotPrefillAuthToken_ButPrefillsMessagingServiceSid()
{
@@ -96,6 +98,7 @@ public class SmsConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that saving a new configuration calls Add and SaveChanges.</summary>
[Fact]
public void SavingNewConfig_CallsAddAndSaveChanges()
{
@@ -127,10 +130,11 @@ public class SmsConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that a new configuration with only a messaging service SID and no from number saves successfully.</summary>
[Fact]
public void SavingNewConfig_MessagingServiceSidOnly_NoFromNumber_Saves()
{
// UI-Med-2: a Twilio Messaging-Service-only config is valid with no From number.
// A Twilio Messaging-Service-only config is valid with no From number.
// The either-or validation must accept it and persist a null FromNumber.
var repo = RepoWith();
Services.AddSingleton(repo);
@@ -160,6 +164,7 @@ public class SmsConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that saving an edit with a blank auth token preserves the existing token.</summary>
[Fact]
public void SavingEdit_WithBlankAuthToken_PreservesExistingToken()
{
@@ -185,6 +190,7 @@ public class SmsConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that saving an edit with a new auth token overwrites the token.</summary>
[Fact]
public void SavingEdit_WithNewAuthToken_OverwritesToken()
{
@@ -89,6 +89,7 @@ public class SmtpConfigurationPageTests : BunitContext
=> cut.FindAll("select")
.Any(s => s.QuerySelectorAll("option").Any(o => o.TextContent == optionText));
/// <summary>Verifies that the edit form renders the TLS mode select with all three modes.</summary>
[Fact]
public void EditForm_RendersTlsModeSelectWithAllThreeModes()
{
@@ -114,6 +115,7 @@ public class SmtpConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that the read-only view shows the TLS mode.</summary>
[Fact]
public void ReadOnlyView_ShowsTlsMode()
{
@@ -133,6 +135,7 @@ public class SmtpConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that saving an edit persists the chosen TLS mode.</summary>
[Fact]
public void SavingEdit_PersistsChosenTlsMode()
{
@@ -168,6 +171,7 @@ public class SmtpConfigurationPageTests : BunitContext
// Transport selector (EWS) — form state machine
// ========================================================================
/// <summary>Verifies that the edit form with the EWS transport hides the port, TLS and auth type and forces basic auth.</summary>
[Fact]
public void EditForm_WithEwsTransport_HidesPortTlsAndAuthTypeAndForcesBasicAuth()
{
@@ -196,6 +200,7 @@ public class SmtpConfigurationPageTests : BunitContext
&& c.Host == "https://mail.example.com/ews/exchange.asmx")));
}
/// <summary>Verifies that switching the transport from EWS back to SMTP restores the auth type and shows the port and TLS fields again.</summary>
[Fact]
public void EditForm_SwitchingEwsBackToSmtp_RestoresAuthTypeAndReshowsPortAndTls()
{
@@ -222,6 +227,7 @@ public class SmtpConfigurationPageTests : BunitContext
&& c.TlsMode == "StartTLS")));
}
/// <summary>Verifies that saving with the EWS transport and a non-HTTPS host shows a form error and does not persist.</summary>
[Fact]
public void Save_WithEwsTransportAndNonHttpsHost_ShowsFormErrorAndDoesNotPersist()
{
@@ -238,6 +244,7 @@ public class SmtpConfigurationPageTests : BunitContext
repo.DidNotReceive().SaveChangesAsync();
}
/// <summary>Verifies that saving with the EWS transport and credentials without a colon shows a form error and does not persist.</summary>
[Fact]
public void Save_WithEwsTransportAndCredentialsWithoutColon_ShowsFormErrorAndDoesNotPersist()
{
@@ -257,6 +264,7 @@ public class SmtpConfigurationPageTests : BunitContext
repo.DidNotReceive().SaveChangesAsync();
}
/// <summary>Verifies that saving with the EWS transport and domain-qualified credentials persists.</summary>
[Fact]
public void Save_WithEwsTransportAndDomainQualifiedCredentials_Persists()
{
@@ -279,6 +287,7 @@ public class SmtpConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that the read-only view shows the transport badge and hides the port and TLS for EWS rows.</summary>
[Fact]
public void ReadOnlyView_ShowsTransportBadgeAndHidesPortAndTlsForEwsRows()
{
@@ -294,6 +303,7 @@ public class SmtpConfigurationPageTests : BunitContext
});
}
/// <summary>Verifies that a legacy row with a null transport shows the SMTP badge and keeps the port and TLS fields.</summary>
[Fact]
public void ReadOnlyView_LegacyRowWithNullTransport_ShowsSmtpBadgeAndKeepsPortAndTls()
{