fix(centralui): single relay toast, paging/skip polish, extra Site Calls tests

This commit is contained in:
Joseph Doherty
2026-05-21 04:59:12 -04:00
parent 7e9d74697b
commit d73b459057
5 changed files with 190 additions and 28 deletions

View File

@@ -15,6 +15,13 @@
<PackageReference Include="Microsoft.Playwright" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<!--
SkippableFact lets the Site Calls E2E tests report as Skipped (not Failed)
when the dev cluster / MSSQL is not running. xunit 2.9.x does not ship
Assert.Skip / SkipUnless — those are v3-only — so we use the canonical
community wrapper, matching ScadaLink.ConfigurationDatabase.Tests.
-->
<PackageReference Include="Xunit.SkippableFact" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,4 +1,5 @@
using Microsoft.Playwright;
using Xunit;
namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls;
@@ -27,8 +28,16 @@ namespace ScadaLink.CentralUI.PlaywrightTests.SiteCalls;
/// Audit Log pre-filtered to the call's TrackedOperationId.</item>
/// <item><c>RetryDiscardVisibility</c> — Retry/Discard appear only on Parked
/// rows, never on Failed (or other) rows.</item>
/// <item><c>RetryClickThrough</c> — clicking Retry on a Parked row confirms
/// the dialog, relays to the owning site, and surfaces an outcome toast.</item>
/// </list>
/// </para>
///
/// <para>
/// The DB-seeding tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
/// matching the established <c>ScadaLink.ConfigurationDatabase.Tests</c> idiom.
/// </para>
/// </summary>
[Collection("Playwright")]
public class SiteCallsPageTests
@@ -55,15 +64,15 @@ public class SiteCallsPageTests
await Assertions.Expect(page.Locator("[data-test='site-calls-query']")).ToBeVisibleAsync();
}
[Fact]
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
private const string DbUnavailableSkipReason =
"SiteCallDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.";
[SkippableFact]
public async Task FilterNarrowing_ChannelFilterShrinksGrid()
{
if (!await SiteCallDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException(
"SiteCallDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.");
}
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/sc-filter/{runId}/";
@@ -112,13 +121,10 @@ public class SiteCallsPageTests
}
}
[Fact]
[SkippableFact]
public async Task DrillIn_ViewAuditHistory_NavigatesToPreFilteredAuditLog()
{
if (!await SiteCallDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
}
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/sc-drill-in/{runId}/";
@@ -162,13 +168,10 @@ public class SiteCallsPageTests
}
}
[Fact]
[SkippableFact]
public async Task RetryDiscard_VisibleOnlyOnParkedRows()
{
if (!await SiteCallDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
}
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/sc-actions/{runId}/";
@@ -221,4 +224,59 @@ public class SiteCallsPageTests
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[SkippableFact]
public async Task RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast()
{
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/sc-retry-click/{runId}/";
var parkedId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
// A single Parked row — the only status from which Retry/Discard can
// be relayed to the owning site.
await SiteCallDataSeeder.InsertSiteCallAsync(
trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
sourceSite: "plant-a", status: "Parked", retryCount: 3,
lastError: "HTTP 503 from ERP", httpStatus: 503,
createdAtUtc: now, updatedAtUtc: now);
var page = await _fixture.NewAuthenticatedPageAsync();
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
await page.Locator("#sc-search").FillAsync(targetPrefix + "parked");
await page.Locator("[data-test='site-calls-query']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
await Assertions.Expect(parkedRow).ToBeVisibleAsync();
// Click Retry — this opens the confirmation dialog (DialogHost modal).
await parkedRow.Locator("button:has-text('Retry')").ClickAsync();
// Confirm the relay in the dialog footer ("Confirm" — the non-danger
// label; Discard would render "Delete").
var confirmButton = page.Locator(".modal-footer button:has-text('Confirm')");
await Assertions.Expect(confirmButton).ToBeVisibleAsync();
await confirmButton.ClickAsync();
// The relay outcome surfaces on a toast — Applied, NotParked or, if
// the owning site is offline in this environment, SiteUnreachable.
// We only assert that an outcome toast appears (exactly one — the
// single-toast contract), not which one, since the live cluster
// state determines the outcome.
var toast = page.Locator(".toast");
await Assertions.Expect(toast).ToBeVisibleAsync();
Assert.Equal(1, await toast.CountAsync());
}
finally
{
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
}

View File

@@ -329,6 +329,88 @@ public class SiteCallsReportPageTests : BunitContext
});
}
[Fact]
public void Paging_PrevButton_PopsBackStackAndRefetchesPriorCursor()
{
// The keyset back-stack is the trickiest paging path: Next pushes the
// current cursor, Prev pops it and refetches that prior page. Page 1 is
// opened with the empty (null, null) cursor, so after Next→Previous the
// follow-up query must carry (null, null) again.
var firstPage = new List<SiteCallSummary>();
for (var i = 0; i < 50; i++)
{
firstPage.Add(new SiteCallSummary(
Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered",
RetryCount: 0, LastError: null, HttpStatus: 200,
CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i),
TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false));
}
var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999");
_queryReply = new SiteCallQueryResponse(
"q", true, null, firstPage,
NextAfterCreatedAtUtc: cursorCreated,
NextAfterId: cursorId);
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.Op0"));
// Step forward — query 2 carries the keyset cursor.
var next = cut.Find("[data-test='site-calls-next']");
next.Click();
cut.WaitForAssertion(() =>
{
Assert.Equal(2, _queryRequests.Count);
Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc);
});
// Previous is now live (the back-stack has one entry); click it.
var prev = cut.Find("[data-test='site-calls-prev']");
Assert.False(prev.HasAttribute("disabled"));
prev.Click();
cut.WaitForAssertion(() =>
{
// 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);
Assert.Null(_queryRequests[2].AfterId);
// Back on page 1, the back-stack is empty again so Previous re-disables.
Assert.True(cut.Find("[data-test='site-calls-prev']").HasAttribute("disabled"));
});
}
[Fact]
public void RetryRelay_NotParked_ShowsInfoMessage_AndExactlyOneToast()
{
// NotParked is a definitive answer from the site (nothing to do), not a
// failure — it surfaces as a single info toast, never an error. This
// also guards the single-toast contract: a non-Applied outcome must
// produce exactly one toast.
_retryReply = new RetrySiteCallResponse(
"q", SiteCallRelayOutcome.NotParked, Success: false, SiteReachable: true,
ErrorMessage: "The cached call is no longer parked.");
var cut = Render<SiteCallsReportPage>();
cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));
var parkedRow = cut.FindAll("tbody tr")
.First(r => r.TextContent.Contains("ERP.GetOrder"));
parkedRow.QuerySelectorAll("button")
.First(b => b.TextContent.Contains("Retry"))
.Click();
cut.WaitForAssertion(() =>
{
Assert.Contains("no longer parked", cut.Markup);
// Exactly one toast — the ShowRelayOutcome switch owns the single
// toast; no second (error) toast piggybacks on the same response.
Assert.Single(cut.FindAll(".toast"));
});
}
protected override void Dispose(bool disposing)
{
if (disposing)