Validate audit windows and isolate dashboard count reads

This commit is contained in:
Joseph Doherty
2026-11-13 18:23:12 -05:00
parent 102cbb2e90
commit 2d30323ae8
55 changed files with 11250 additions and 79 deletions
@@ -57,6 +57,63 @@ public class AuditFilterBarTests : BunitContext
Services.AddSingleton(_auditLogQueryService);
}
/// <summary>UTC-labelled custom picks are identical in every process timezone.</summary>
[Theory]
[InlineData("2026-11-12T10:00", "2026-11-12T11:00")]
[InlineData("2026-03-08T01:30", "2026-03-08T02:30")]
[InlineData("2026-11-01T01:30", "2026-11-01T02:30")]
public void CustomUtcWindow_PreservesLiteralInstantsAndInputText(string from, string to)
{
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p.Add(c => c.OnFilterChanged, f => captured = f));
cut.Find("#audit-time-range").Change(AuditTimeRangePreset.Custom.ToString());
cut.Find("#audit-from").Change(from); cut.Find("#audit-to").Change(to);
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.NotNull(captured);
Assert.Equal(DateTime.SpecifyKind(DateTime.Parse(from, System.Globalization.CultureInfo.InvariantCulture), DateTimeKind.Utc), captured.FromUtc);
Assert.Equal(DateTime.SpecifyKind(DateTime.Parse(to, System.Globalization.CultureInfo.InvariantCulture), DateTimeKind.Utc), captured.ToUtc);
Assert.Equal(from, cut.Find("#audit-from").GetAttribute("value"));
Assert.Equal(to, cut.Find("#audit-to").GetAttribute("value"));
}
/// <summary>Intentional nullable bounds retain the existing open-ended query contract.</summary>
[Theory]
[InlineData("", "2026-11-12T11:00")]
[InlineData("2026-11-12T10:00", "")]
[InlineData("", "")]
public void CustomUtcWindow_EmptyBoundsRemainIntentional(string from, string to)
{
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p.Add(c => c.OnFilterChanged, f => captured = f));
cut.Find("#audit-time-range").Change(AuditTimeRangePreset.Custom.ToString());
cut.Find("#audit-from").Change(from); cut.Find("#audit-to").Change(to);
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.NotNull(captured);
Assert.Equal(string.IsNullOrEmpty(from), captured.FromUtc is null);
Assert.Equal(string.IsNullOrEmpty(to), captured.ToUtc is null);
}
/// <summary>Bad ranges retain correction text without emitting a broader query.</summary>
[Theory]
[InlineData("not-a-date", "2026-11-12T11:00")]
[InlineData("2026-11-12T12:00", "2026-11-12T11:00")]
public void InvalidCustomWindow_DoesNotQueryUntilCorrected(string from, string to)
{
var queries = 0;
var cut = Render<AuditFilterBar>(p => p.Add(c => c.OnFilterChanged, _ => queries++));
cut.Find("#audit-time-range").Change(AuditTimeRangePreset.Custom.ToString());
cut.Find("#audit-from").Change(from); cut.Find("#audit-to").Change(to);
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.Equal(0, queries);
Assert.Equal(from, cut.Find("#audit-from").GetAttribute("value"));
Assert.Equal("true", cut.Find("#audit-from").GetAttribute("aria-invalid"));
Assert.NotEmpty(cut.Find("#" + cut.Find("#audit-from").GetAttribute("aria-describedby")).TextContent);
cut.Find("#audit-from").Change("2026-11-12T10:00");
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.Equal(1, queries);
Assert.Null(cut.Find("#audit-from").GetAttribute("aria-invalid"));
}
/// <summary>Verifies that the filter bar renders all ten filter elements plus the errors-only toggle.</summary>
[Fact]
public void Render_AllTenElements_Plus_ErrorsOnlyToggle_Present()
@@ -99,7 +156,7 @@ public class AuditFilterBarTests : BunitContext
// Drive UI: pick a Channel, type in the Target search box, click Apply.
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.NotNull(captured);
Assert.Equal(new[] { AuditChannel.ApiOutbound }, captured!.Channels);
@@ -118,14 +175,14 @@ public class AuditFilterBarTests : BunitContext
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"filter-channel-select\"]").Change("Notification");
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.NotNull(captured);
Assert.Equal(new[] { AuditChannel.Notification }, captured!.Channels);
// Selecting "All channels" clears the channel filter entirely.
cut.Find("[data-test=\"filter-channel-select\"]").Change(string.Empty);
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.Null(captured!.Channels);
}
@@ -164,7 +221,7 @@ public class AuditFilterBarTests : BunitContext
// Toggle Errors-only ON, leaving Status chips empty.
cut.Find("[data-test=\"filter-errors-only\"] input").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.NotNull(captured);
// Errors-only targets the full non-success set {Failed, Parked, Discarded}.
@@ -176,7 +233,7 @@ public class AuditFilterBarTests : BunitContext
// Now pin an explicit Status option — Errors-only must yield (explicit wins).
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
}
@@ -199,7 +256,7 @@ public class AuditFilterBarTests : BunitContext
Assert.Contains("data-test=\"filter-node-ms-opt-central-b\"", cut.Markup);
cut.Find("[data-test=\"filter-node-ms-opt-central-a\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.NotNull(captured);
Assert.NotNull(captured!.SourceNodes);
@@ -218,7 +275,7 @@ public class AuditFilterBarTests : BunitContext
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"filter-status-ms-opt-Failed\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.NotNull(captured);
Assert.NotNull(captured!.Statuses);
@@ -239,7 +296,7 @@ public class AuditFilterBarTests : BunitContext
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-execution-id\"] input").Change(executionId.ToString());
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.NotNull(captured);
Assert.Equal(executionId, captured!.ExecutionId);
@@ -255,13 +312,13 @@ public class AuditFilterBarTests : BunitContext
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Blank — never typed into.
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.NotNull(captured);
Assert.Null(captured!.ExecutionId);
// Unparseable paste — still dropped, no error.
cut.Find("[data-test=\"filter-execution-id\"] input").Change("not-a-guid");
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.Null(captured!.ExecutionId);
}
@@ -277,7 +334,7 @@ public class AuditFilterBarTests : BunitContext
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change(parentExecutionId.ToString());
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.NotNull(captured);
Assert.Equal(parentExecutionId, captured!.ParentExecutionId);
@@ -293,13 +350,13 @@ public class AuditFilterBarTests : BunitContext
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Blank — never typed into.
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.NotNull(captured);
Assert.Null(captured!.ParentExecutionId);
// Unparseable paste — still dropped, no error.
cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change("not-a-guid");
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
Assert.Null(captured!.ParentExecutionId);
}
@@ -313,7 +370,7 @@ public class AuditFilterBarTests : BunitContext
// LastHour is the default preset; clicking Apply must collapse it to FromUtc.
var before = DateTime.UtcNow;
cut.Find("[data-test=\"filter-apply\"]").Click();
cut.Find("form[data-test=audit-filter-bar]").Submit();
var after = DateTime.UtcNow;
Assert.NotNull(captured);
@@ -0,0 +1,108 @@
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using DashboardPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Dashboard;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;
/// <summary>Dashboard facts distinguish independent failures, loading and known zero.</summary>
public class DashboardStateTests : BunitContext
{
private readonly ISiteRepository _sites = Substitute.For<ISiteRepository>();
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
private readonly IInboundApiKeyAdmin _keys = Substitute.For<IInboundApiKeyAdmin>();
/// <summary>Registers deterministic read-only dependencies and a signed-in fixture.</summary>
public DashboardStateTests()
{
this.AddAuthorization().SetAuthorized("fixture");
_sites.GetAllSitesAsync().Returns(Task.FromResult<IReadOnlyList<Site>>([new("A", "a"), new("B", "b")]));
_sites.GetAllDataConnectionsAsync().Returns(Task.FromResult<IReadOnlyList<DataConnection>>([new("Link", "OpcUa", 1)]));
_templates.GetTemplateStubsAsync().Returns(Task.FromResult<IReadOnlyList<TemplateStub>>([
new(1,"A",null,null,false,null), new(2,"B",null,null,false,null), new(3,"C",null,null,false,null)]));
_keys.ListAsync().Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>([]));
Services.AddSingleton(_sites); Services.AddSingleton(_templates); Services.AddSingleton(_keys);
}
/// <summary>Each read failure leaves the other facts intact and is never a false zero.</summary>
[Theory]
[InlineData("Sites configured")]
[InlineData("Data connections configured")]
[InlineData("Templates")]
[InlineData("API keys")]
public void CountFailure_DoesNotEraseOtherFactsOrRenderZero(string label)
{
if(label=="Sites configured")_sites.GetAllSitesAsync().Returns(Task.FromException<IReadOnlyList<Site>>(new Exception("local failure")));
if(label=="Data connections configured")_sites.GetAllDataConnectionsAsync().Returns(Task.FromException<IReadOnlyList<DataConnection>>(new Exception("local failure")));
if(label=="Templates")_templates.GetTemplateStubsAsync().Returns(Task.FromException<IReadOnlyList<TemplateStub>>(new Exception("local failure")));
if(label=="API keys")_keys.ListAsync().Returns(Task.FromException<IReadOnlyList<InboundApiKeyInfo>>(new Exception("local failure")));
var cut=Render<DashboardPage>();
var facts=new Dictionary<string,string>{{"Sites configured","2"},{"Data connections configured","1"},{"Templates","3"},{"API keys","0"}};
foreach(var (name,count) in facts)
{
var fact=cut.Find("[role=region][aria-label='"+name+"']");
Assert.Contains(name==label?"Unavailable":count,fact.TextContent);
if(name==label)
{
Assert.Contains("Retry "+name,fact.TextContent);
Assert.DoesNotContain("0",fact.TextContent);
}
}
}
/// <summary>Only a failed API count is retried, and a real zero is displayed after success.</summary>
[Fact]
public void RetryFailure_PreservesSuccessfulFactsAndCanRecoverToZero()
{
_keys.ListAsync().Returns(Task.FromException<IReadOnlyList<InboundApiKeyInfo>>(new Exception("local failure")));
var cut=Render<DashboardPage>();
Assert.Contains("Unavailable",cut.Find("[aria-label='API keys']").TextContent);
_keys.ListAsync().Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>([]));
cut.Find("[aria-label='API keys'] button").Click();
Assert.Contains("0",cut.Find("[aria-label='API keys']").TextContent);
Assert.DoesNotContain("Unavailable",cut.Markup);
_sites.Received(1).GetAllSitesAsync(); _sites.Received(1).GetAllDataConnectionsAsync(); _templates.Received(1).GetTemplateStubsAsync();
}
/// <summary>Early retries share the initial-read gate and duplicate retries remain single-flight.</summary>
[Fact]
public async Task RetryDuringNextRead_WaitsWithoutOverlapAndCoalescesDuplicateTarget()
{
var connectionRead = new TaskCompletionSource<IReadOnlyList<DataConnection>>(TaskCreationOptions.RunContinuationsAsynchronously);
_sites.GetAllSitesAsync().Returns(Task.FromException<IReadOnlyList<Site>>(new Exception("local failure")),
Task.FromResult<IReadOnlyList<Site>>([new("Recovered", "recovered")]));
_sites.GetAllDataConnectionsAsync().Returns(connectionRead.Task);
var cut = Render<DashboardPage>();
Assert.Contains("Unavailable", cut.Find("[aria-label='Sites configured']").TextContent);
Assert.Contains("Loading", cut.Find("[aria-label='Data connections configured']").TextContent);
// Model two already-dispatched retries without relying on a stale DOM node.
var retry = typeof(DashboardPage).GetMethod("LoadSites", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
Task first = null!, second = null!;
await cut.InvokeAsync(() =>
{
first = (Task)retry.Invoke(cut.Instance, null)!;
second = (Task)retry.Invoke(cut.Instance, null)!;
});
Assert.Equal(1, _sites.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(ISiteRepository.GetAllSitesAsync)));
Assert.False(first.IsCompleted);
await cut.InvokeAsync(() => connectionRead.SetResult([new("Link", "OpcUa", 1)]));
await Task.WhenAll(first, second);
cut.WaitForAssertion(() => Assert.Contains("1", cut.Find("[aria-label='Sites configured']").TextContent));
Assert.Equal(2, _sites.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(ISiteRepository.GetAllSitesAsync)));
Assert.Equal(1, _sites.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(ISiteRepository.GetAllDataConnectionsAsync)));
}
/// <summary>An outstanding read remains loading while already loaded facts are available.</summary>
[Fact]
public async Task DelayedRead_RemainsLoadingUntilKnownZeroArrives()
{
var pending=new TaskCompletionSource<IReadOnlyList<InboundApiKeyInfo>>(TaskCreationOptions.RunContinuationsAsynchronously);
_keys.ListAsync().Returns(pending.Task);
var cut=Render<DashboardPage>();
Assert.Contains("Loading",cut.Find("[aria-label='API keys']").TextContent);
Assert.Contains("2",cut.Find("[aria-label='Sites configured']").TextContent);
await cut.InvokeAsync(()=>pending.SetResult([]));
cut.WaitForAssertion(()=>Assert.Contains("0",cut.Find("[aria-label='API keys']").TextContent));
Assert.DoesNotContain("Loading",cut.Find("[aria-label='API keys']").TextContent);
}
}
@@ -14,6 +14,20 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// </summary>
public class ToastNotificationTests : BunitContext
{
/// <summary>Dismissal names its toast and removes only that notification.</summary>
[Fact]
public async Task ToastDismiss_HasContextualNameAndPreservesOtherNotification()
{
var cut = Render<ToastNotification>();
await cut.InvokeAsync(() => cut.Instance.ShowError("Save failed", "Configuration", 60000));
await cut.InvokeAsync(() => cut.Instance.ShowInfo("Read completed", "Audit query", 60000));
var first = cut.FindAll(".toast").First(t => t.TextContent.Contains("Configuration"));
Assert.Equal("Dismiss Configuration", first.QuerySelector("button")!.GetAttribute("aria-label"));
await cut.InvokeAsync(() => first.QuerySelector("button")!.Click());
Assert.Single(cut.FindAll(".toast"));
Assert.Contains("Audit query", cut.Markup);
}
/// <summary>Verifies that show toast, when after disposal, is no op and schedules nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]