Validate audit windows and isolate dashboard count reads
This commit is contained in:
+71
-14
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user