Compare commits
19 Commits
1eece71c76
...
fdea9e0bde
| Author | SHA1 | Date | |
|---|---|---|---|
| fdea9e0bde | |||
| 70e84a7b79 | |||
| b1d7497463 | |||
| 99a69c1fba | |||
| 5774b30d0d | |||
| 42f38996a9 | |||
| e36adf8acd | |||
| 3b71ac220a | |||
| f5535ad5c1 | |||
| eea68b97f6 | |||
| 79778e12b7 | |||
| 0efbb66bc3 | |||
| 8419eb0d86 | |||
| 3e57c6b054 | |||
| 64222cf596 | |||
| 11ba61c39c | |||
| 5c190885da | |||
| 40f6d21392 | |||
| 7fda67be9e |
@@ -0,0 +1,577 @@
|
||||
# Playwright Coverage Fill — Wave 4 Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Close the cross-cutting edge-case gaps (duplicate-name, cancel, empty-state, filter-combination, pagination) on the six already-covered Central UI pages — Sites, Templates, LDAP mappings, Audit Log, Site Calls, Notification Report — by **extending** their existing test suites, ending green with zero residue.
|
||||
|
||||
**Architecture:** Each task adds 1–2 edge `[SkippableFact]`s to an existing test class (no new test files), driving real behavior against the live 8-node docker cluster, outcome- and selector-grounded against the **actual** page code-behind (the ⚠ validation-behavior protocol). Fixtures/seeders are reused; only three tiny *test-side* helpers are added (2 CLI verbs + 1 seeder generalization). **No app-code changes, no new `data-test` hooks, no `docker/deploy.sh` rebuild** — every edge surface already has a stable selector once the two un-wired "Escape closes" legs are dropped.
|
||||
|
||||
**Tech Stack:** xUnit + `Xunit.SkippableFact`, Microsoft.Playwright (remote Chromium `ws://localhost:3000`), Microsoft.Data.SqlClient (direct-SQL seeders), the `scadabridge` CLI. TFM `net10.0`, `Nullable=enable`, `TreatWarningsAsErrors=true`.
|
||||
|
||||
---
|
||||
|
||||
## Grounding facts (verified during planning — do NOT re-derive)
|
||||
|
||||
**Harness conventions (reuse verbatim):**
|
||||
- All classes are `[Collection("Playwright")]` (serial via `ICollectionFixture<PlaywrightFixture>`); ctor injects `PlaywrightFixture _fixture`.
|
||||
- Auth: `await _fixture.NewAuthenticatedPageAsync()` → logs in `multi-role`/`password` (has Administrator + Designer + Deployer + Viewer; satisfies every page below). Browser base URL `PlaywrightFixture.BaseUrl` = `http://scadabridge-traefik`; CLI from host `http://localhost:9000`.
|
||||
- Toast assertion: web-first `page.Locator(".toast")` with `ToHaveCountAsync(1)`.
|
||||
- Danger confirm: `.modal-footer .btn-danger` (text "Delete"); plain confirm `.modal-footer .btn-primary` (text "Confirm").
|
||||
- Ephemeral fixture naming: `CliRunner.UniqueName("kind")` → `zztest-<kind>-<8hex>`; direct-SQL seeders use a per-run marker (`playwright-test/<scenario>/{runId}/…` for audit/site-calls `Target`, `zztest-notif-…-{runId}` for the notification `ListName`).
|
||||
- **Two skip-gate idioms — match the one already in each file:** `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason)` (CLI probe — used by SiteCrudTests, TemplateCrudTests, LdapMappingCrudTests, AuditLogPageTests) **vs.** `Skip.IfNot(await <Seeder>.IsAvailableAsync(), DbUnavailableSkipReason)` (direct-SQL probe — used by SiteCallsPageTests, NotificationActionTests, AuditGridColumnTests). Do not mix.
|
||||
- Text inputs `@bind` commit on **change/blur, not Enter** — after `FillAsync` into a search box, call `DispatchEventAsync("change")` before clicking Query (the existing `SetSearchKeywordAsync`/`SetSubjectKeywordAsync`/`SetSearchKeywordAsync` helpers already do this). `SelectOptionAsync` fires `change` itself.
|
||||
- **Prerender-hydration race (Wave-3 gotcha, still live):** the UI uses InteractiveServer WITH prerendering, so an input can be "visible" before its `@bind` handler is wired. For any test that fills a field then acts WITHOUT an intervening server roundtrip, force a confirmed roundtrip first (e.g. assert an Apply/Query result, or a prior validation message) before relying on the committed value. Most Wave-4 tests click Query/Apply (a roundtrip) right after filling, so they are naturally safe; flag in code where they aren't.
|
||||
|
||||
**No-residue rule:** best-effort teardown in `finally`/`DisposeAsync`, keyed on `zztest-*` / the run marker. Tests that collide against the real seeded `site-a` persist nothing (the create is rejected). The mutating cancel/edit tests create their own `zztest-*` entity and delete it. Wave ends with the residue scan → zero.
|
||||
|
||||
**Cadence constraint:** one shared Playwright browser + one cluster + one build → every cluster-running implementer is serialized. Test tasks are therefore `Parallelizable with: none`; only the two non-cluster prep tasks (0, 1) may be dispatched concurrently with each other. Reviewers (read-only) may still overlap with the next implementer per subagent-driven-development.
|
||||
|
||||
### Per-area verified selectors & actual behavior
|
||||
|
||||
**Sites** (`/admin/sites`, form `/admin/sites/create` & `/admin/sites/{id}/edit`, single `SiteForm.razor`):
|
||||
- Identifier input: `label:has-text('Identifier') + input.form-control.form-control-sm` (**`disabled` in edit mode**). Name: `label:has-text('Name') + input.form-control.form-control-sm`. Description: `label:has-text('Description') + input.form-control.form-control-sm`.
|
||||
- Save: `button.btn.btn-success.btn-sm:has-text('Save')`. Cancel: `button:has-text('Cancel')` (== Back; both call `GoBack()` → `/admin/sites`, no dirty-check, no persist).
|
||||
- Error surface: `div.text-danger.small.mt-2` (single element, reused for all errors). `SaveSite()`: blank name → `"Name is required."`; create-mode blank identifier → `"Identifier is required."`; **duplicate identifier (or name) → caught `DbUpdateException` → `_formError = $"Save failed: {ex.Message}"`, stays on `/admin/sites/create`.** Assert `"Save failed"` substring, NOT the literal.
|
||||
- **No URL validation** (form accepts any string; only >500 chars would error). `Name` is ALSO unique-indexed → keep the dup test's Name distinct from the collision target.
|
||||
- CLI: `ResolveSiteIdAsync(identifier)` (throws if absent), `DeleteSiteAsync(id)` (best-effort). **No `CreateSiteAsync`** — create via UI.
|
||||
|
||||
**Templates** (`/design/templates`, create `/design/templates/create`, edit `/design/templates/{id}`):
|
||||
- Create Name: `div.mb-3:has(label:has-text('Name')) input.form-control`. Create button: `button.btn.btn-success:has-text('Create')`. Create error: `div.text-danger.small` (inline; stays on `/design/templates/create`). Create Cancel: `button.btn.btn-outline-secondary:has-text('Cancel')` → `/design/templates`.
|
||||
- **Duplicate base-template name** → DB unique index (`HasIndex(t=>t.Name).IsUnique().HasFilter("[IsDerived]=0")`), no friendly pre-check in `TemplateService.CreateTemplateAsync` → error lands inline in `div.text-danger.small`. Assert the div is visible/non-empty + URL still `/create`; do NOT assert a literal.
|
||||
- Edit page attribute modal: trigger `button.btn.btn-primary.btn-sm:has-text('Add Attribute')`; existing-attr edit via row dropdown `button[aria-label="More actions for {attr.Name}"]` → `button.dropdown-item:has-text('Edit…')`. Modal = `.modal.show.d-block:not(.fade)` (page-local, no `fade`); title `h6.modal-title` = "Add Attribute"/"Edit Attribute". Modal fields: Name `input.form-control @bind=_attrName` (**`readonly` when editing**), Data Type `select.form-select`, Value `input.form-control`. Footer save button text is **"Save" when editing, "Add" when adding** → `.modal.show.d-block:not(.fade) .modal-footer button.btn-success.btn-sm:has-text('Save')`. Attr rows: `table td:has-text('{name}')`.
|
||||
- **Delete-blocked-by-instances:** header Delete `button.btn.btn-outline-danger.btn-sm:has-text('Delete')` → DialogHost confirm `.modal-footer .btn-danger:has-text('Delete')` (this DialogHost modal HAS `fade`) → server guard fails → **`.toast`** containing `Cannot delete template '{name}': {n} instance(s) reference it (...)`. A **non-deployed** instance referencing the template is sufficient to trigger the block.
|
||||
- Reuse **`DeploymentFixture`** (`IClassFixture<DeploymentFixture>` + `[Collection("Playwright")]`, exactly as `DeploymentActionTests`): exposes `bool Available`, `int TemplateId`, `int SiteAId`, `int AreaId`, `Task<(int Id,string UniqueName)> CreateInstanceAsync()` (creates, does NOT deploy). CLI: `CreateTemplateAsync`, `DeleteTemplateAsync`, `ListTemplateIdsByNamePrefixAsync`, `AddAttributeAsync`, `DeleteInstanceAsync`.
|
||||
|
||||
**LDAP** (`/admin/ldap-mappings`, form `/admin/ldap-mappings/create` & `/{id}/edit`, single `LdapMappingForm.razor`):
|
||||
- Group input: `label:has-text('LDAP Group Name') + input.form-control.form-control-sm`. Role select: `div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm` (options `""`,`Administrator`,`Designer`,`Deployer`,`Viewer`). Save: `button.btn.btn-success.btn-sm:has-text('Save')`. Cancel: `button.btn.btn-outline-secondary.btn-sm:has-text('Cancel')`.
|
||||
- Error surface: `div.text-danger.small.mt-2` (single element). `SaveMapping()`: blank group → `"LDAP Group Name is required."` (checked first, returns); blank role → `"Role is required."`. **Duplicate group → DB unique index (`HasIndex(m=>m.LdapGroupName).IsUnique()`), no pre-check → caught → `"Save failed: {ex.Message}"`.** Assert `"Save failed"` substring.
|
||||
- Rows: `page.Locator("tr", new(){ HasText = group })`. Mapping delete = list kebab `button.dropdown-item.text-danger` (**no confirm dialog**).
|
||||
- CLI verbs exist (`security role-mapping create|list|delete`) but no typed helper today — **Task 0 adds them.**
|
||||
|
||||
**Audit Log** (`/audit/log` — `AuditLogPage`, filter bar + results grid + drill-down drawer):
|
||||
- Filter bar (`[data-test='audit-filter-bar']`): channel `[data-test='filter-channel-select']` (`SelectOptionAsync("ApiOutbound"|"ApiInbound"|"DbOutbound"|…)`, `""`=all), site multi `[data-test='filter-site-ms']`, time-range `#audit-time-range` (`Last5Minutes`/`LastHour`(default)/`Last24Hours`/`Custom`), custom `#audit-from`/`#audit-to` (only when `Custom`), execution-id text `#audit-execution-id` (exact GUID), target text `#audit-target` (contains), **Apply `[data-test='filter-apply']`**, Clear `[data-test='filter-clear']` (resets model only — does NOT re-query).
|
||||
- **Grid does NOT auto-load on bare visit** — click Apply, or arrive with a drill-in querystring. Grid `[data-test='audit-results-grid']`; rows `[data-test='grid-row-{EventId}']` (and `[data-test^='grid-row-']` for counting); empty-state cell literal **`No audit events match the current filter.`** (no `data-test`). Page size **100** (no test override). Page indicator text `Page N · M rows`.
|
||||
- Drawer: open by clicking a row; container `[data-test='audit-drilldown-drawer']`; backdrop `[data-test='drawer-backdrop']` (click closes); **X `[data-test='drawer-close']`**; footer `[data-test='drawer-close-footer']` (closes). **No Escape handler** (the keydown handler lives only on the sibling `ExecutionDetailModal`). cURL button `[data-test='copy-as-curl']` renders **iff `channel ∈ {ApiOutbound, ApiInbound}`** (`IsApiChannel`); a `DbOutbound`/`Notification` row's drawer omits it.
|
||||
- `AuditDataSeeder` (table **`AuditLog`**): `InsertAuditEventAsync(eventId, occurredAtUtc, channel, kind, status, sourceSiteId?, target?, actor?, correlationId?, executionId?, parentExecutionId?, httpStatus?, …)`, `DeleteByTargetPrefixAsync(targetPrefix)`, `IsAvailableAsync()`. `DbUnavailableSkipReason` const lives in `AuditGridColumnTests.cs` — but **AuditLogPageTests uses `ClusterAvailability.SkipReason`** (match the file). Default time-range `LastHour`, so seed rows at "now".
|
||||
|
||||
**Site Calls** (`/site-calls/report` — `SiteCallsReport`):
|
||||
- Filters: status `#sc-status` (`""`=All, `Submitted`/`Forwarded`/`Attempted`/`Delivered`/`Parked`/`Failed`/`Discarded`), channel `#sc-channel` (`ApiOutbound`/`DbOutbound`), site `#sc-site`, node `#sc-node` (exact), from/to `#sc-from`/`#sc-to`, target keyword `#sc-search` (**exact match**; use `SetSearchKeywordAsync` to commit), stuck-only `#sc-stuck-only`. **Query button `[data-test='site-calls-query']`** (resets to page 1). **Auto-loads on visit** (`OnInitializedAsync → RefreshAll`), so narrow by the run marker to isolate seeded rows.
|
||||
- Grid: rows `tbody tr`; status badge `span.badge:has-text('{Status}')`. **Empty state literals** `No site calls` / `No cached calls match the current filters.` (no `data-test`). Page size **50**.
|
||||
- **Pagination (confirmed wired):** prev `[data-test='site-calls-prev']` (text "Previous", disabled on page 1), next `[data-test='site-calls-next']` (text "Next", **enabled only when the page is exactly full = 50 rows**). Keyset order `CreatedAtUtc DESC, TrackedOperationId DESC`. Page indicator `Page {N} · {rows} rows`. Pager footer renders only when rows exist.
|
||||
- `SiteCallDataSeeder` (table **`SiteCalls`**): `InsertSiteCallAsync(trackedOperationId, channel, target, sourceSite, status, retryCount, createdAtUtc, updatedAtUtc, lastError?, httpStatus?, terminalAtUtc?)`, `DeleteByTargetPrefixAsync(prefix)`, `IsAvailableAsync()`, `ConnectionString`. **`DbUnavailableSkipReason`** const is in `SiteCallsPageTests.cs`. **`FilterPermittedAsync` drops rows whose `SourceSite` is outside the user's permitted set → seed with `sourceSite:"site-a"`.** `Target` is exact-match, so **51 rows sharing ONE identical target value** are all selected by one `#sc-search` keyword (the deterministic pagination isolator). Stagger `createdAtUtc` for strict keyset order.
|
||||
|
||||
**Notification Report** (`/notifications/report` — `NotificationReport`, single file, `RequireDeployment`):
|
||||
- Filters: status `#no-status` (`""`=All,`Pending`/`Retrying`/`Delivered`/`Parked`/`Discarded`), type `#no-type` (`Email`), site `#no-site`, list `#no-list` (exact `ListName`), node `#no-node` / `[data-test='notif-filter-node']`, from/to `#no-from`/`#no-to`, subject keyword `#no-search` (substring; use `SetSubjectKeywordAsync`), **stuck-only `#no-stuck-only`** (label "Stuck only"). **Query `button.btn-primary:has-text('Query')`** (no `data-test`; resets to page 1). Clear `button.btn-outline-secondary.btn-sm:has-text('Clear')`. **Auto-loads on visit.** Page size **50**.
|
||||
- Grid rows `tbody tr`. Stuck badge `span.badge.bg-warning.text-dark:has-text('Stuck')`; stuck row highlight `tr.table-warning`. Empty-state literals `No notifications` / `No notifications match the current filters.`
|
||||
- **Detail modal: open by DOUBLE-CLICK on a non-Actions row cell** (`@ondblclick`; Actions cell stops propagation). Container `.modal.show.d-block`; title `.modal-title:has-text('Notification Detail')`; **X `button.btn-close[aria-label="Close"]`**; footer `.modal-footer button.btn-outline-secondary:has-text('Close')`; **backdrop click closes** (outer `.modal` `@onclick`, inner dialog stops propagation). **No Escape handler.**
|
||||
- Pager renders **only when `_totalCount > 50`**: prev `button.btn-outline-secondary.btn-sm:has-text('Previous')` (disabled page 1), next `…:has-text('Next')` (disabled when current page < 50 rows). Indicator `Page {N} of {pages} · {total} total`.
|
||||
- `NotificationDataSeeder` (table **`Notifications`**): today only `InsertParkedNotificationAsync(...)` (hard-codes `Status="Parked"`, `CreatedAt=now`). **`IsStuck` is derived: `Status ∈ {Pending,Retrying} && CreatedAt < now − 10min`** → a Parked@now row is NEVER stuck. **Task 1 generalizes the seeder** so a back-dated `Retrying` row can be seeded for the positive stuck test. `DeleteByMarkerAsync(listName)`, `IsAvailableAsync()`, `DbUnavailableSkipReason` const in `NotificationActionTests.cs`.
|
||||
|
||||
### Scope changes vs. the design (and why)
|
||||
- **DROP** Sites "invalid Akka/gRPC URL" — no URL validation exists in `SiteForm`.
|
||||
- **DROP** Audit Log pagination — page size is 100 with no test-facing override; a 101-row seed is disproportionate, and the pagination *pattern* is covered by Site Calls (keyset) and Notification (page-number). Documented, not silent.
|
||||
- **DROP** the "Escape closes" leg on both the Audit drawer and the Notification detail modal — neither is wired in the app. Replace with the X / backdrop / footer-Close paths that ARE wired (no app change this wave).
|
||||
- Everything else in the design's Wave-4 list is kept and grounded above.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: CLI role-mapping helpers + round-trip test
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** Task 1
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunner.Helpers.cs`
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Cluster/CliRunnerHelpersTests.cs`
|
||||
|
||||
**Why:** the LDAP duplicate-group test (Task 5) needs to seed a pre-existing mapping and clean it up deterministically by id. The CLI verbs exist (`security role-mapping create|list|delete`); add typed wrappers mirroring the existing `Create…/Delete…` split (provision throws, teardown best-effort).
|
||||
|
||||
**Step 1 — add helpers to `CliRunner.Helpers.cs`** (mirror `CreateExternalSystemAsync`/`DeleteExternalSystemAsync` style; reuse the existing private `RequireId(JsonDocument)` and `BestEffortAsync(group, verb, id)`):
|
||||
|
||||
```csharp
|
||||
/// <summary>Creates an LDAP→role mapping; returns its id. Throws on failure.</summary>
|
||||
public static async Task<int> CreateRoleMappingAsync(string ldapGroup, string role = "Designer")
|
||||
{
|
||||
using var doc = await RunJsonAsync("security", "role-mapping", "create",
|
||||
"--ldap-group", ldapGroup, "--role", role);
|
||||
return RequireId(doc);
|
||||
}
|
||||
|
||||
/// <summary>Best-effort delete of an LDAP→role mapping (teardown).</summary>
|
||||
public static Task DeleteRoleMappingAsync(int id)
|
||||
=> BestEffortAsync("security role-mapping", "delete", id, group2: "role-mapping");
|
||||
```
|
||||
|
||||
If `BestEffortAsync`'s signature doesn't accommodate the two-token group (`security role-mapping`), follow whatever the existing role-mapping teardown in `LdapMappingCrudTests` already does (`CliRunner.RunAsync("security","role-mapping","delete","--id",id)`) and wrap it in a try/catch that swallows. Match the file's existing idiom exactly — read the surrounding helpers before writing.
|
||||
|
||||
**Step 2 — add a round-trip test to `CliRunnerHelpersTests.cs`** (mirror `CreateThenDeleteApiMethod_RoundTrips`):
|
||||
|
||||
```csharp
|
||||
[SkippableFact]
|
||||
public async Task CreateThenDeleteRoleMapping_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
var group = CliRunner.UniqueName("grp");
|
||||
var id = await CliRunner.CreateRoleMappingAsync(group, "Designer");
|
||||
try
|
||||
{
|
||||
Assert.True(id > 0);
|
||||
using var list = await CliRunner.RunJsonAsync("security", "role-mapping", "list");
|
||||
Assert.Contains(list.RootElement.EnumerateArray(),
|
||||
e => e.TryGetProperty("ldapGroupName", out var n) && n.GetString() == group);
|
||||
}
|
||||
finally { await CliRunner.DeleteRoleMappingAsync(id); }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3 — run:** `dotnet test --filter CreateThenDeleteRoleMapping_RoundTrips` (from the test project dir). Expected: PASS (or SKIPPED if cluster down — then it cannot be validated; note that and proceed, the downstream Task 5 will exercise it).
|
||||
|
||||
**Step 4 — commit:** `test(playwright): add CLI role-mapping create/delete helpers (Wave 4 prep)`.
|
||||
|
||||
**Acceptance:** helpers compile under `TreatWarningsAsErrors`; round-trip passes against the live cluster; `CreateRoleMappingAsync` returns a positive id; teardown leaves no `zztest-grp-*` mapping.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Generalize NotificationDataSeeder for non-Parked / back-dated rows
|
||||
|
||||
**Classification:** small
|
||||
**Estimated implement time:** ~3 min
|
||||
**Parallelizable with:** Task 0
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationDataSeeder.cs`
|
||||
|
||||
**Why:** the positive "Stuck only" test (Task 11) needs a row that is actually stuck — `Status ∈ {Pending,Retrying}` AND `CreatedAt < now − 10min`. The current `InsertParkedNotificationAsync` hard-codes `Status="Parked"`, `CreatedAt=now` (never stuck). Add a general insert and refactor the existing method to delegate (DRY; no behavior change for existing callers).
|
||||
|
||||
**Step 1 — read** `NotificationDataSeeder.cs` fully to learn the exact INSERT column list and the existing parameter names.
|
||||
|
||||
**Step 2 — add a general method** and make `InsertParkedNotificationAsync` call it. Add `status` and `createdAt` parameters (default to the current Parked@now behavior):
|
||||
|
||||
```csharp
|
||||
/// <summary>Inserts one notification row with explicit status/created-at (for stuck/age edge cases).</summary>
|
||||
public static async Task InsertNotificationAsync(
|
||||
Guid notificationId, string listNameMarker, string subject,
|
||||
string status, DateTimeOffset createdAt,
|
||||
int retryCount = 0, string? lastError = "SMTP 451 transient failure (seeded)",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// identical body to the existing InsertParkedNotificationAsync INSERT, but with
|
||||
// @status = status and @createdAt = createdAt (and @siteEnqueuedAt = createdAt).
|
||||
// Keep every other NOT-NULL column exactly as the existing method sets it.
|
||||
}
|
||||
|
||||
public static Task InsertParkedNotificationAsync(
|
||||
Guid notificationId, string listNameMarker, string subject,
|
||||
int retryCount = 0, string? lastError = "SMTP 451 transient failure (seeded)",
|
||||
CancellationToken ct = default)
|
||||
=> InsertNotificationAsync(notificationId, listNameMarker, subject,
|
||||
status: "Parked", createdAt: DateTimeOffset.UtcNow, retryCount, lastError, ct);
|
||||
```
|
||||
|
||||
Preserve the existing method's public signature so Tasks 9/10's existing callers and the current tests are untouched. Confirm the `Notifications` INSERT does NOT require a terminal/`TerminalAtUtc` column for a non-terminal status (the existing INSERT column list is the source of truth — do not add columns).
|
||||
|
||||
**Step 3 — build:** `dotnet build` (must be clean under `TreatWarningsAsErrors`). No standalone test — this is a test-helper refactor exercised by Tasks 10/11.
|
||||
|
||||
**Step 4 — commit:** `test(playwright): generalize NotificationDataSeeder for status/created-at (Wave 4 prep)`.
|
||||
|
||||
**Acceptance:** builds clean; `InsertParkedNotificationAsync` behavior is byte-identical to before (delegates with Parked/now); the new method allows `status:"Retrying", createdAt: now-15min`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Sites edge — duplicate identifier + cancel-from-edit
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none (cluster/browser)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCrudTests.cs`
|
||||
|
||||
**Existing coverage to NOT duplicate:** create-page Cancel→list, Save-without-name→`.text-danger`, Back→list, full create/edit/delete round-trip. The new value is **duplicate-identifier** and **cancel-from-EDIT-with-a-changed-field**.
|
||||
|
||||
**Step 1 — `Create_DuplicateIdentifier_ShowsSaveFailedError`** (`[SkippableFact]`, `ClusterAvailability` gate). Collide against the always-present seeded `site-a` identifier (the failed create persists nothing → zero residue):
|
||||
|
||||
- `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);`
|
||||
- Confirm the collision target exists: `await CliRunner.ResolveSiteIdAsync("site-a")` (it throws if absent — let that surface; site-a is a core cluster seed).
|
||||
- `var page = await _fixture.NewAuthenticatedPageAsync();` → `GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create")`.
|
||||
- Fill Identifier = `"site-a"` (the duplicate), Name = `$"zztest-dup-{Guid.NewGuid():N}"[..16]` (**distinct** so the unique-Name index can't be the thing that trips). Leave node addresses blank.
|
||||
- Click Save (`button.btn.btn-success.btn-sm:has-text('Save')`).
|
||||
- Assert (web-first): `Expect(page.Locator("div.text-danger.small.mt-2")).ToContainTextAsync("Save failed")` AND `Expect(page).ToHaveURLAsync(new Regex("/admin/sites/create"))`. (Do NOT assert the raw DB message.)
|
||||
- No teardown needed (nothing persisted). Optionally a `finally` safety sweep: best-effort `DeleteSiteAsync(ResolveSiteIdAsync(name))` swallowing the "not found" throw.
|
||||
|
||||
**Step 2 — `EditCancel_DiscardsChanges`** (`[SkippableFact]`). Create a `zztest-site` via UI, edit a field, Cancel, verify the original persists:
|
||||
|
||||
- Gate; `var hex = Guid.NewGuid().ToString("N")[..8]; var ident = $"zztest-{hex}"; var name = $"zztest-site-{hex}";`
|
||||
- Create via UI (mirror the round-trip test's create steps: fill Identifier+Name, Save, wait for `/admin/sites`).
|
||||
- Open edit: locate the site card (`div.card` HasText=name) → its Edit button (`button.btn.btn-outline-primary.btn-sm:has-text('Edit')`); wait for `/{id}/edit` (URL contains `/edit`).
|
||||
- Change the Description input to `"zztest-CANCELLED-EDIT"`; click `button:has-text('Cancel')`; wait for `/admin/sites` (excludePath `/edit`).
|
||||
- Re-open the same card's Edit; assert the Description input value is the ORIGINAL (empty): `Expect(descInput).ToHaveValueAsync("")`. (Proves Cancel discarded.)
|
||||
- `finally`: `try { await CliRunner.DeleteSiteAsync(await CliRunner.ResolveSiteIdAsync(ident)); } catch { }`.
|
||||
|
||||
**Step 3 — run:** `dotnet test --filter "Create_DuplicateIdentifier_ShowsSaveFailedError|EditCancel_DiscardsChanges"`. Expected: 2 PASS.
|
||||
|
||||
**Step 4 — commit:** `test(playwright): Sites duplicate-identifier + cancel-from-edit edge cases (Wave 4)`.
|
||||
|
||||
**Acceptance:** both pass; site-a untouched; no `zztest-site-*` residue (`site list` clean).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Templates edge p1 — duplicate name + create-cancel
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs`
|
||||
|
||||
**Existing coverage to NOT duplicate:** create→add-attribute→delete round-trip (happy path).
|
||||
|
||||
**Step 1 — `CreateTemplate_DuplicateName_ShowsInlineError`** (`[SkippableFact]`, `ClusterAvailability` gate). Seed an existing base template by name via CLI, then UI-attempt the duplicate:
|
||||
|
||||
- `var name = CliRunner.UniqueName("tmpl");`
|
||||
- `var seededId = await CliRunner.CreateTemplateAsync(name);` (this is the existing-name source).
|
||||
- try: navigate `/design/templates/create`; fill Name = `name` (the duplicate) via `div.mb-3:has(label:has-text('Name')) input.form-control`; click `button.btn.btn-success:has-text('Create')`.
|
||||
- Assert: `Expect(page.Locator("div.text-danger.small")).ToBeVisibleAsync()` AND not empty (`ToContainTextAsync` of a non-empty match, or assert `.Count >= 1` + text length) AND `Expect(page).ToHaveURLAsync(new Regex("/design/templates/create"))`. (Behavior confirmed: DB unique index, no friendly pre-check → caught into `_formError`. Do NOT assert a literal — capture whatever surfaces.)
|
||||
- `finally`: `await CliRunner.DeleteTemplateAsync(seededId);` and sweep any `ListTemplateIdsByNamePrefixAsync(name)` leftovers.
|
||||
|
||||
> **⚠ validation-behavior protocol:** before finalizing, the implementer confirms the duplicate create does NOT silently succeed-and-navigate. If (contrary to the EF unique index) the cluster allows it and navigates to `/design/templates/{id}`, re-scope this to the **attribute** duplicate instead (which IS friendly-guarded: add attribute `Val`, add `Val` again → modal `div.text-danger.small` reading `Attribute 'Val' already exists on template '{name}'.`). The EF index strongly implies the template-name path works; this is the documented fallback.
|
||||
|
||||
**Step 2 — `CreateTemplate_Cancel_ReturnsToListWithoutCreating`** (`[SkippableFact]`):
|
||||
|
||||
- `var name = CliRunner.UniqueName("tmpl");`
|
||||
- Navigate `/design/templates/create`; fill Name = `name`; click `button.btn.btn-outline-secondary:has-text('Cancel')`; wait for `/design/templates` (excludePath `/create`).
|
||||
- Assert no template with that name exists: `Assert.Empty(await CliRunner.ListTemplateIdsByNamePrefixAsync(name))`.
|
||||
- No teardown needed (nothing created); defensive sweep in `finally` anyway.
|
||||
|
||||
**Step 3 — run:** `dotnet test --filter "CreateTemplate_DuplicateName_ShowsInlineError|CreateTemplate_Cancel_ReturnsToListWithoutCreating"`. Expected: 2 PASS.
|
||||
|
||||
**Step 4 — commit:** `test(playwright): Templates duplicate-name + create-cancel edge cases (Wave 4)`.
|
||||
|
||||
**Acceptance:** both pass; zero `zztest-tmpl-*` residue (`template list` clean).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Templates edge p2 — edit attribute + delete-blocked-by-instance
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Design/TemplateCrudTests.cs`
|
||||
|
||||
**Blocked by:** Task 3 (same file — sequential edits).
|
||||
|
||||
**Setup:** the delete-blocked test reuses **`DeploymentFixture`**. Add `IClassFixture<DeploymentFixture>` to the class and accept it in the ctor (`public class TemplateCrudTests(PlaywrightFixture pw, DeploymentFixture cluster)` with `[Collection("Playwright")]` + `IClassFixture<DeploymentFixture>` — mirror `DeploymentActionTests`). If the class is currently a non-primary-ctor form, adapt to keep `_fixture` working and add `_cluster`.
|
||||
|
||||
**Step 1 — `EditAttribute_PersistsChange`** (`[SkippableFact]`, `ClusterAvailability` gate). Create a template + attribute via CLI, edit the attribute's Value in the UI modal, verify persisted:
|
||||
|
||||
- `var name = CliRunner.UniqueName("tmpl"); var id = await CliRunner.CreateTemplateAsync(name); await CliRunner.AddAttributeAsync(id, "Val", "Double");`
|
||||
- try: navigate `/design/templates/{id}`; open the `Val` row dropdown `button[aria-label="More actions for Val"]` → `button.dropdown-item:has-text('Edit…')`; assert modal `.modal.show.d-block:not(.fade)` with title "Edit Attribute"; confirm Name input is `readonly`; set the Value input to `"42.5"`; click `.modal.show.d-block:not(.fade) .modal-footer button.btn-success.btn-sm:has-text('Save')`.
|
||||
- Assert the modal closes and the change persisted: web-first wait for the modal to detach, then assert the attribute row reflects the new value (`table tr:has(td:has-text('Val'))` contains `42.5`), OR re-read via `LoadAsync` round-trip — the simplest deterministic check is to assert the row's value cell text. If the table doesn't render the value column, fall back to a toast/no-error assertion + reload-and-reopen the modal asserting the Value input now reads `42.5`.
|
||||
- `finally`: `await CliRunner.DeleteTemplateAsync(id);`.
|
||||
|
||||
**Step 2 — `DeleteTemplate_WithInstance_IsBlocked`** (`[SkippableFact]`). Use `DeploymentFixture` (a template with an instance referencing it):
|
||||
|
||||
- `Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);`
|
||||
- `var (instId, _) = await _cluster.CreateInstanceAsync();` (creates a non-deployed instance referencing `_cluster.TemplateId` — sufficient to block).
|
||||
- try: navigate `/design/templates/{_cluster.TemplateId}`; click header Delete `button.btn.btn-outline-danger.btn-sm:has-text('Delete')`; confirm `.modal-footer .btn-danger:has-text('Delete')`.
|
||||
- Assert: `Expect(page.Locator(".toast")).ToContainTextAsync("instance(s) reference it")` (web-first) — the block message `Cannot delete template '{name}': {n} instance(s) reference it (...)`. Also assert the template still exists: still on the detail page (URL still `/design/templates/{id}`) or `Assert.NotEmpty(await CliRunner.ListTemplateIdsByNamePrefixAsync(_cluster's template name))` if its name is exposed; the URL check is simplest.
|
||||
- `finally`: `await CliRunner.DeleteInstanceAsync(instId);` (the fixture's `DisposeAsync` sweeps the template + any leftover instances).
|
||||
|
||||
**Step 3 — run:** `dotnet test --filter "EditAttribute_PersistsChange|DeleteTemplate_WithInstance_IsBlocked"`. Expected: 2 PASS.
|
||||
|
||||
**Step 4 — commit:** `test(playwright): Templates edit-attribute + delete-blocked-by-instance edge cases (Wave 4)`.
|
||||
|
||||
**Acceptance:** both pass; the `DeploymentFixture` template restored to instance-free; zero `zztest-*` residue.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: LDAP edge — missing-field + duplicate group
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs`
|
||||
|
||||
**Blocked by:** Task 0 (CLI role-mapping helpers).
|
||||
|
||||
**Existing coverage to NOT duplicate:** create→edit(role)→delete round-trip.
|
||||
|
||||
**Step 1 — `Save_MissingGroupName_ShowsRequiredError`** (`[SkippableFact]`, `ClusterAvailability` gate). The `SaveMapping` handler checks group-blank FIRST:
|
||||
|
||||
- Navigate `/admin/ldap-mappings/create`; leave the group input blank; select a role (`SelectOptionAsync` on `div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm` → `"Designer"`); click Save (`button.btn.btn-success.btn-sm:has-text('Save')`).
|
||||
- Assert: `Expect(page.Locator("div.text-danger.small.mt-2")).ToHaveTextAsync("LDAP Group Name is required.")` AND URL still `/admin/ldap-mappings/create`.
|
||||
- (Optional second leg in the same test — `Role is required.`: fill the group with a `zztest-grp` value, clear the role back to `""` via `SelectOptionAsync("")`, Save → assert the div now reads `"Role is required."`. Keep the prerender-race in mind: the prior Save already proved the circuit is live, so the second fill+Save is safe.)
|
||||
- No persistence (both validations return early) → no teardown.
|
||||
|
||||
**Step 2 — `Create_DuplicateGroup_ShowsSaveFailedError`** (`[SkippableFact]`). CLI-seed a mapping, UI-attempt the duplicate:
|
||||
|
||||
- `var group = CliRunner.UniqueName("grp"); var seededId = await CliRunner.CreateRoleMappingAsync(group, "Designer");`
|
||||
- try: navigate `/admin/ldap-mappings/create`; fill group input = `group` (exact same string); select role `"Viewer"`; click Save.
|
||||
- Assert: `Expect(page.Locator("div.text-danger.small.mt-2")).ToContainTextAsync("Save failed")` AND URL still `/admin/ldap-mappings/create`. (DB unique index on `LdapGroupName`, no pre-check.)
|
||||
- `finally`: `await CliRunner.DeleteRoleMappingAsync(seededId);`.
|
||||
|
||||
**Step 3 — run:** `dotnet test --filter "Save_MissingGroupName_ShowsRequiredError|Create_DuplicateGroup_ShowsSaveFailedError"`. Expected: 2 PASS.
|
||||
|
||||
**Step 4 — commit:** `test(playwright): LDAP missing-field + duplicate-group edge cases (Wave 4)`.
|
||||
|
||||
**Acceptance:** both pass; no `zztest-grp-*` mapping residue (`security role-mapping list` clean).
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Audit Log edge p1 — filter combination + empty-after-apply
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs`
|
||||
|
||||
**Gate:** match the file — `Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason)`. Seeding uses `AuditDataSeeder` with a per-run `targetPrefix = $"playwright-test/wave4-audit/{runId}/"`.
|
||||
|
||||
**Existing coverage to NOT duplicate:** channel-narrowing (single channel), drawer JSON pretty-print, copy-as-curl visible for ApiInbound (positive), correlation/execution drill-ins. The new value is **multi-filter combination** and **empty-after-apply**.
|
||||
|
||||
**Step 1 — `FilterCombination_ChannelPlusTarget_NarrowsToMatch`** (`[SkippableFact]`). Seed two rows differing in channel; apply channel+target together:
|
||||
|
||||
- Seed row 1: `InsertAuditEventAsync(eventId: Guid.NewGuid(), occurredAtUtc: now, channel: "ApiOutbound", kind: "ApiCall", status: "Success", target: targetPrefix + "match", …)`.
|
||||
- Seed row 2: `…channel:"DbOutbound", kind:"DbWrite", target: targetPrefix + "other", …`.
|
||||
- Navigate `/audit/log`; set channel `[data-test='filter-channel-select']` → `"ApiOutbound"`; type the exact `targetPrefix + "match"` into `#audit-target`; click `[data-test='filter-apply']`.
|
||||
- Assert: the ApiOutbound/match row is visible (`[data-test='grid-row-{row1 eventId}']`) and the DbOutbound row count is 0 (`page.Locator($"[data-test='grid-row-{row2 eventId}']")` → `ToHaveCountAsync(0)`). This proves the two filters AND together.
|
||||
- `finally`: `await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);`.
|
||||
|
||||
**Step 2 — `EmptyResults_AfterApply_ShowsEmptyState`** (`[SkippableFact]`). A fresh random executionId GUID matches nothing:
|
||||
|
||||
- Navigate `/audit/log`; type a fresh `Guid.NewGuid().ToString()` into `#audit-execution-id` (exact-match); click `[data-test='filter-apply']`.
|
||||
- Assert: zero rows (`page.Locator("[data-test^='grid-row-']")` → `ToHaveCountAsync(0)`) AND the empty-state literal is visible: `Expect(page.Locator("[data-test='audit-results-grid']")).ToContainTextAsync("No audit events match the current filter.")`.
|
||||
- No seeding/teardown needed (guaranteed-empty by construction).
|
||||
|
||||
**Step 3 — run:** `dotnet test --filter "FilterCombination_ChannelPlusTarget_NarrowsToMatch|EmptyResults_AfterApply_ShowsEmptyState"`. Expected: 2 PASS.
|
||||
|
||||
**Step 4 — commit:** `test(playwright): Audit Log filter-combination + empty-state edge cases (Wave 4)`.
|
||||
|
||||
**Acceptance:** both pass; no `playwright-test/wave4-audit/*` rows remain (`DeleteByTargetPrefixAsync` ran).
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Audit Log edge p2 — non-API row omits cURL + drawer close (X/backdrop/footer)
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs`
|
||||
|
||||
**Blocked by:** Task 6 (same file).
|
||||
|
||||
**Step 1 — `NonApiRow_Drawer_OmitsCopyAsCurl`** (`[SkippableFact]`). The cURL button renders iff channel ∈ {ApiOutbound, ApiInbound}:
|
||||
|
||||
- Seed one `DbOutbound`/`DbWrite` row with `target: targetPrefix + "dbrow"`.
|
||||
- Navigate `/audit/log`; type `targetPrefix + "dbrow"` into `#audit-target`; Apply; click the row `[data-test='grid-row-{eventId}']`.
|
||||
- Assert drawer open (`Expect(page.Locator("[data-test='audit-drilldown-drawer']")).ToBeVisibleAsync()`) AND cURL absent: `page.Locator("[data-test='copy-as-curl']")` → `ToHaveCountAsync(0)`. (Existing test already proves the POSITIVE for ApiInbound; this is the negative.)
|
||||
- `finally`: delete by prefix.
|
||||
|
||||
**Step 2 — `Drawer_CloseControls_DismissTheDrawer`** (`[SkippableFact]`). Verify the X (and footer) close paths (Escape is NOT wired — intentionally omitted; see plan header):
|
||||
|
||||
- Seed one `ApiOutbound` row; navigate; filter by its target; Apply; click the row to open the drawer.
|
||||
- Assert drawer visible; click the X `[data-test='drawer-close']`; assert drawer gone (`ToHaveCountAsync(0)` on `[data-test='audit-drilldown-drawer']`).
|
||||
- Re-open the row; click the footer Close `[data-test='drawer-close-footer']`; assert drawer gone again. (Two close affordances in one test; optionally add the backdrop `[data-test='drawer-backdrop']` path as a third.)
|
||||
- Add a code comment: `// Escape-to-close is not wired on AuditDrilldownDrawer (only ExecutionDetailModal has a keydown handler); covering X/footer/backdrop, the paths that ARE wired.`
|
||||
- `finally`: delete by prefix.
|
||||
|
||||
**Step 3 — run:** `dotnet test --filter "NonApiRow_Drawer_OmitsCopyAsCurl|Drawer_CloseControls_DismissTheDrawer"`. Expected: 2 PASS.
|
||||
|
||||
**Step 4 — commit:** `test(playwright): Audit Log non-API-no-cURL + drawer-close edge cases (Wave 4)`.
|
||||
|
||||
**Acceptance:** both pass; no audit residue.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Site Calls edge — status filter + empty state
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~4 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs`
|
||||
|
||||
**Gate:** `Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason)` (the const is local to this file). Marker `targetPrefix = $"playwright-test/wave4-sc/{runId}/"`. Seed with `sourceSite:"site-a"` (permitted) so rows survive `FilterPermittedAsync`.
|
||||
|
||||
**Existing coverage to NOT duplicate:** channel-narrowing, audit drill-in link, Retry/Discard visibility + click-throughs. The new value is **status filter** and **empty state**.
|
||||
|
||||
**Step 1 — `StatusFilter_NarrowsToSelectedStatus`** (`[SkippableFact]`). Seed a Parked row and a Delivered row sharing the prefix; filter status=Parked:
|
||||
|
||||
- Seed row P: `InsertSiteCallAsync(Guid.NewGuid(), channel:"ApiOutbound", target: targetPrefix + "parked", sourceSite:"site-a", status:"Parked", retryCount:1, createdAtUtc: now, updatedAtUtc: now)`.
|
||||
- Seed row D: `…target: targetPrefix + "delivered", status:"Delivered", …`.
|
||||
- Navigate `/site-calls/report`; select `#sc-status` → `"Parked"`; set `#sc-search` to the EXACT `targetPrefix + "parked"` via `SetSearchKeywordAsync`; click `[data-test='site-calls-query']`.
|
||||
- Assert: the Parked marker row is visible (`tbody tr` HasText = `targetPrefix + "parked"`), its status badge reads `Parked`; then change `#sc-search` to `targetPrefix + "delivered"` + status still Parked + Query → 0 rows (the Delivered row is excluded by the status filter). This proves status narrows.
|
||||
- `finally`: `await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);`.
|
||||
|
||||
**Step 2 — `EmptyState_NoMatch_ShowsEmptyCard`** (`[SkippableFact]`). A per-run GUID target matches nothing:
|
||||
|
||||
- Navigate `/site-calls/report`; set `#sc-search` to `$"playwright-test/wave4-sc-empty/{Guid.NewGuid():N}/none"` via `SetSearchKeywordAsync`; click Query.
|
||||
- Assert: zero data rows AND empty-state literal visible: `Expect(page.Locator(".card-body")).ToContainTextAsync("No cached calls match the current filters.")` (or `text=No site calls`). No seeding/teardown.
|
||||
|
||||
**Step 3 — run:** `dotnet test --filter "StatusFilter_NarrowsToSelectedStatus|EmptyState_NoMatch_ShowsEmptyCard"`. Expected: 2 PASS.
|
||||
|
||||
**Step 4 — commit:** `test(playwright): Site Calls status-filter + empty-state edge cases (Wave 4)`.
|
||||
|
||||
**Acceptance:** both pass; no `playwright-test/wave4-sc/*` rows remain.
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Site Calls keyset pagination
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCalls/SiteCallsPageTests.cs`
|
||||
|
||||
**Blocked by:** Task 8 (same file).
|
||||
|
||||
**Key mechanic:** page size 50; Next enabled only when the current page is EXACTLY full (50). `Target` is exact-match → seed **51 rows sharing ONE identical target string** so one `#sc-search` keyword selects all 51; stagger `createdAtUtc` for strict keyset order; `sourceSite:"site-a"`.
|
||||
|
||||
**Step 1 — `Pagination_KeysetNextAndPrev_TraversesPages`** (`[SkippableFact]`, `SiteCallDataSeeder` gate):
|
||||
|
||||
- `var sharedTarget = $"playwright-test/wave4-scpage/{runId}/row";`
|
||||
- Seed 51 rows in a loop (one connection-per-call is acceptable for a live-cluster fact): `for (int i = 0; i < 51; i++) await SiteCallDataSeeder.InsertSiteCallAsync(Guid.NewGuid(), "ApiOutbound", sharedTarget, "site-a", "Delivered", 0, now.AddSeconds(-i), now.AddSeconds(-i));` — identical target, descending createdAt so the keyset order is deterministic.
|
||||
- Navigate `/site-calls/report`; set `#sc-search` = `sharedTarget` via `SetSearchKeywordAsync`; click `[data-test='site-calls-query']`.
|
||||
- Assert page 1: `Expect(page.Locator("tbody tr")).ToHaveCountAsync(50)`; page indicator contains `Page 1`; prev `[data-test='site-calls-prev']` is `disabled`; next `[data-test='site-calls-next']` is enabled.
|
||||
- Click Next; assert page 2: 1 row (`ToHaveCountAsync(1)`), indicator `Page 2`, prev now enabled, next now disabled (short page).
|
||||
- Click Prev; assert back to 50 rows / `Page 1` / prev disabled. (Confirms the cursor stack round-trips.)
|
||||
- `finally`: `await SiteCallDataSeeder.DeleteByTargetPrefixAsync($"playwright-test/wave4-scpage/{runId}/");`.
|
||||
|
||||
> Use web-first `Expect(...).ToBeDisabledAsync()`/`ToBeEnabledAsync()` for the button states and `ToHaveCountAsync` for rows — never `WaitForTimeout` + read. The `_loading` flag also disables the buttons mid-fetch, so assert the row count first (which waits for the new page) then the button states.
|
||||
|
||||
**Step 2 — run:** `dotnet test --filter Pagination_KeysetNextAndPrev_TraversesPages`. Expected: PASS (allow generous timeouts; 51 seeds + two page fetches).
|
||||
|
||||
**Step 3 — commit:** `test(playwright): Site Calls keyset pagination edge case (Wave 4)`.
|
||||
|
||||
**Acceptance:** passes; all 51 seeded rows removed (`DeleteByTargetPrefixAsync`).
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Notification Report edge p1 — filter combination + detail modal
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs`
|
||||
|
||||
**Blocked by:** Task 1 (seeder generalization — though these two cases use the existing Parked insert, keep the dependency so the seeder change lands first and the file builds once).
|
||||
|
||||
**Gate:** `Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason)` (const local to file). Marker `var marker = $"zztest-notif-wave4-{runId}";` (the `ListName`).
|
||||
|
||||
**Existing coverage to NOT duplicate:** Retry/Discard click-throughs. New value: **filter combo** and **detail-modal open/close**.
|
||||
|
||||
**Step 1 — `FilterCombination_StatusPlusList_NarrowsToMatch`** (`[SkippableFact]`). Seed two Parked rows under the marker with distinct subjects; combine list+subject (or list+status) filters:
|
||||
|
||||
- Seed: `InsertParkedNotificationAsync(Guid.NewGuid(), marker, subject: "wave4-alpha")` and `…(…, marker, subject: "wave4-beta")`.
|
||||
- Navigate `/notifications/report`; set `#no-list` = `marker` (exact); set `#no-status` → `"Parked"`; set `#no-search` = `"wave4-alpha"` via `SetSubjectKeywordAsync`; click Query (`button.btn-primary:has-text('Query')`).
|
||||
- Assert: the `wave4-alpha` row visible, `wave4-beta` row count 0 (combined filters AND). Optionally flip `#no-search` to `wave4-beta` + Query → beta visible, alpha gone.
|
||||
- `finally`: `await NotificationDataSeeder.DeleteByMarkerAsync(marker);`.
|
||||
|
||||
**Step 2 — `DetailModal_OpenOnDblClick_CloseViaXAndFooter`** (`[SkippableFact]`). Modal opens on double-click of a non-Actions cell:
|
||||
|
||||
- Seed one Parked row (`subject:"wave4-detail"`, marker); navigate; narrow by `#no-list`=marker + Query; locate the row (`tbody tr` HasText `wave4-detail`).
|
||||
- Double-click the row's subject cell (`row.Locator("td", new(){ HasText = "wave4-detail" })` → `DblClickAsync()`); assert modal `.modal.show.d-block` visible with `.modal-title:has-text('Notification Detail')`.
|
||||
- Close via X `button.btn-close[aria-label="Close"]`; assert modal gone (`ToHaveCountAsync(0)` on `.modal.show.d-block`).
|
||||
- Re-open (double-click); close via footer `.modal-footer button.btn-outline-secondary:has-text('Close')`; assert gone. Comment that Escape-close is not wired (only X / footer / backdrop are).
|
||||
- `finally`: `DeleteByMarkerAsync(marker)`.
|
||||
|
||||
**Step 3 — run:** `dotnet test --filter "FilterCombination_StatusPlusList_NarrowsToMatch|DetailModal_OpenOnDblClick_CloseViaXAndFooter"`. Expected: 2 PASS.
|
||||
|
||||
**Step 4 — commit:** `test(playwright): Notification Report filter-combo + detail-modal edge cases (Wave 4)`.
|
||||
|
||||
**Acceptance:** both pass; no `zztest-notif-wave4-*` rows remain.
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Notification Report edge p2 — stuck-only + pagination
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Notifications/NotificationActionTests.cs`
|
||||
|
||||
**Blocked by:** Task 1 (uses the generalized seeder) and Task 10 (same file).
|
||||
|
||||
**Step 1 — `StuckOnlyFilter_NarrowsToStuckRows`** (`[SkippableFact]`). Seed one genuinely-stuck row (Retrying + back-dated) and one Parked@now row under one marker; toggle Stuck-only:
|
||||
|
||||
- `var stuckCreatedAt = DateTimeOffset.UtcNow.AddMinutes(-15);` (older than the 10-min threshold).
|
||||
- Seed stuck: `InsertNotificationAsync(Guid.NewGuid(), marker, "wave4-stuck", status:"Retrying", createdAt: stuckCreatedAt);`
|
||||
- Seed non-stuck: `InsertParkedNotificationAsync(Guid.NewGuid(), marker, "wave4-fresh");`
|
||||
- Navigate; set `#no-list` = marker; Query → assert both rows visible (2).
|
||||
- Check `#no-stuck-only` (`CheckAsync`); click Query.
|
||||
- Assert: only `wave4-stuck` visible (and it carries the stuck badge `span.badge.bg-warning.text-dark:has-text('Stuck')` / `tr.table-warning`); `wave4-fresh` row count 0.
|
||||
- `finally`: `DeleteByMarkerAsync(marker)`.
|
||||
|
||||
> **⚠ validation-behavior protocol:** confirm the seeded Retrying+back-dated row is classified stuck by the running app (the threshold default is 10 min; 15 min gives margin). If the cluster's `StuckAgeThreshold` is configured higher, increase the back-date. If — contrary to the code read — `Notifications` requires a terminal/`TerminalAtUtc`-style column for stuck classification, fall back to the **negative** form: toggling Stuck-only ON drops the seeded Parked row to zero (empty state). Prefer the positive assertion.
|
||||
|
||||
**Step 2 — `Pagination_PageNumberNextAndPrev_TraversesPages`** (`[SkippableFact]`). Page size 50; pager renders only when `_totalCount > 50`. Seed 51 rows under one marker + shared subject token:
|
||||
|
||||
- `for (int i = 0; i < 51; i++) await NotificationDataSeeder.InsertParkedNotificationAsync(Guid.NewGuid(), marker, subject: $"wave4-page-{i:D2}");` (same marker → one cleanup; distinct subjects fine — filter by `#no-list`=marker selects all 51).
|
||||
- Navigate; set `#no-list` = marker; Query.
|
||||
- Assert page 1: 50 rows (`tbody tr` `ToHaveCountAsync(50)`), indicator contains `Page 1`, prev `button.btn-outline-secondary.btn-sm:has-text('Previous')` disabled, next `…:has-text('Next')` enabled.
|
||||
- Click Next; assert page 2: 1 row, `Page 2`, prev enabled, next disabled.
|
||||
- Click Prev; back to 50 / `Page 1`.
|
||||
- `finally`: `DeleteByMarkerAsync(marker)`.
|
||||
|
||||
**Step 3 — run:** `dotnet test --filter "StuckOnlyFilter_NarrowsToStuckRows|Pagination_PageNumberNextAndPrev_TraversesPages"`. Expected: 2 PASS.
|
||||
|
||||
**Step 4 — commit:** `test(playwright): Notification Report stuck-only + pagination edge cases (Wave 4)`.
|
||||
|
||||
**Acceptance:** both pass; no `zztest-notif-wave4-*` rows remain.
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Wave 4 verification + residue check
|
||||
|
||||
**Classification:** standard
|
||||
**Estimated implement time:** ~5 min
|
||||
**Parallelizable with:** none
|
||||
|
||||
**Blocked by:** Tasks 0–11.
|
||||
|
||||
**Files:** none (verification only; may touch the plan's `.tasks.json` to mark complete).
|
||||
|
||||
**Step 1 — full build:** `dotnet build tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests` → clean under `TreatWarningsAsErrors=true`.
|
||||
|
||||
**Step 2 — full suite:** run the entire Playwright test project against the live cluster. Expected: **0 failed**; new Wave-4 facts pass; skips logged (only the genuinely-environmental ones — e.g. any pre-existing SMTP no-op skip). Capture the pass/fail/skip tally.
|
||||
|
||||
**Step 3 — residue scan (must be zero):**
|
||||
- `scadabridge … site list` → no `zztest-*` site.
|
||||
- `scadabridge … template list` → no `zztest-tmpl-*`.
|
||||
- `scadabridge … instance list` → no `zztest-*` instance (the `DeploymentFixture` template restored to instance-free).
|
||||
- `scadabridge … security role-mapping list` → no `zztest-grp-*`.
|
||||
- Direct-SQL marker scan: no `playwright-test/wave4-%` rows in `AuditLog` or `SiteCalls`; no `zztest-notif-wave4-%` `ListName` in `Notifications`.
|
||||
- `site-a` left exactly as found (the duplicate-identifier test persisted nothing; no test mutated site-a).
|
||||
|
||||
**Step 4 — app-diff guard:** `git diff --stat` shows **only** files under `tests/…PlaywrightTests/` and `docs/plans/…wave4*` — **zero `src/` changes** (no `data-test` hooks were needed this wave). If any `src/` file changed, that's a defect — investigate before completing.
|
||||
|
||||
**Step 5 — mark complete:** update `…-wave4.md.tasks.json` statuses → `completed`; commit `docs(plans): mark Wave 4 tasks complete`.
|
||||
|
||||
**Acceptance:** full suite 0-failed; zero residue; clean build; no `src/` changes; `site-a` unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Scope guard (YAGNI)
|
||||
|
||||
No new fixtures, no new test files, no page-object framework, no CI/runner/parallelization changes, no visual-regression. The only non-test additions are two CLI helpers (Task 0) and one seeder generalization (Task 1). **No app-code changes and no `docker/deploy.sh` rebuild** — confirmed every edge surface has an existing stable selector after dropping the two un-wired Escape legs and the disproportionate Audit-pagination case.
|
||||
|
||||
## Success criteria
|
||||
|
||||
All 6 already-covered pages gain their duplicate/cancel/empty/filter-combo/pagination edge assertions (~18 new `[SkippableFact]`s across 10 test tasks); the full suite stays green with logged skips; zero residue; clean build. Wave 4 closes the coverage-fill effort.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-06-playwright-coverage-fill-wave4.md",
|
||||
"lastUpdated": "2026-06-07T00:00:00Z",
|
||||
"nativeTaskIdBase": 112,
|
||||
"status": "completed",
|
||||
"tasks": [
|
||||
{"id": 0, "nativeId": 112, "subject": "Task 0: CLI role-mapping helpers + round-trip test", "status": "completed"},
|
||||
{"id": 1, "nativeId": 113, "subject": "Task 1: Generalize NotificationDataSeeder for status/created-at", "status": "completed"},
|
||||
{"id": 2, "nativeId": 114, "subject": "Task 2: Sites edge - duplicate identifier + cancel-from-edit", "status": "completed"},
|
||||
{"id": 3, "nativeId": 115, "subject": "Task 3: Templates edge p1 - duplicate name + create-cancel", "status": "completed"},
|
||||
{"id": 4, "nativeId": 116, "subject": "Task 4: Templates edge p2 - edit attribute + delete-blocked-by-instance", "status": "completed", "blockedBy": [3]},
|
||||
{"id": 5, "nativeId": 117, "subject": "Task 5: LDAP edge - missing-field + duplicate group", "status": "completed", "blockedBy": [0]},
|
||||
{"id": 6, "nativeId": 118, "subject": "Task 6: Audit Log edge p1 - filter combination + empty-after-apply", "status": "completed"},
|
||||
{"id": 7, "nativeId": 119, "subject": "Task 7: Audit Log edge p2 - non-API no cURL + drawer close", "status": "completed", "blockedBy": [6]},
|
||||
{"id": 8, "nativeId": 120, "subject": "Task 8: Site Calls edge - status filter + empty state", "status": "completed"},
|
||||
{"id": 9, "nativeId": 121, "subject": "Task 9: Site Calls keyset pagination", "status": "completed", "blockedBy": [8]},
|
||||
{"id": 10, "nativeId": 122, "subject": "Task 10: Notification Report edge p1 - filter combination + detail modal", "status": "completed", "blockedBy": [1]},
|
||||
{"id": 11, "nativeId": 123, "subject": "Task 11: Notification Report edge p2 - stuck-only + pagination", "status": "completed", "blockedBy": [1, 10]},
|
||||
{"id": 12, "nativeId": 124, "subject": "Task 12: Wave 4 verification + residue check", "status": "completed", "blockedBy": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]}
|
||||
]
|
||||
}
|
||||
@@ -123,4 +123,97 @@ public class LdapMappingCrudTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The create form's <c>SaveMapping()</c> validates manually (no EditForm /
|
||||
/// DataAnnotations): a blank LDAP Group Name renders "LDAP Group Name is
|
||||
/// required." in <c>div.text-danger.small.mt-2</c> and returns early; a present
|
||||
/// group but blank role renders "Role is required." Both branches return before
|
||||
/// any persistence, so nothing is created and no teardown is needed. The first
|
||||
/// Save is a full circuit roundtrip — proving the Blazor circuit is live — which
|
||||
/// makes the second-leg fill (group → blank role) safe from the prerender race.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Save_MissingGroupName_ShowsRequiredError()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/ldap-mappings/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// ── Leg 1: blank group, role selected → "LDAP Group Name is required." ─────
|
||||
// Leave the group input blank; pick a role so the group-blank branch (checked
|
||||
// FIRST in SaveMapping) is unambiguously the one that fires.
|
||||
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm")
|
||||
.SelectOptionAsync("Designer");
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
|
||||
await Assertions.Expect(page.Locator("div.text-danger.small.mt-2"))
|
||||
.ToHaveTextAsync("LDAP Group Name is required.");
|
||||
await Assertions.Expect(page)
|
||||
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/admin/ldap-mappings/create"));
|
||||
|
||||
// ── Leg 2: group filled, role blank → "Role is required." ──────────────────
|
||||
// The first Save roundtrip proved the circuit live, so filling now is safe.
|
||||
var group = $"zztest-grp-{Guid.NewGuid():N}"[..18];
|
||||
await page.Locator("label:has-text('LDAP Group Name') + input.form-control.form-control-sm")
|
||||
.FillAsync(group);
|
||||
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm")
|
||||
.SelectOptionAsync(new[] { "" });
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
|
||||
await Assertions.Expect(page.Locator("div.text-danger.small.mt-2"))
|
||||
.ToHaveTextAsync("Role is required.");
|
||||
await Assertions.Expect(page)
|
||||
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/admin/ldap-mappings/create"));
|
||||
|
||||
// Both validations returned early → nothing persisted → no teardown.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempting to create a mapping whose LDAP group already exists violates the DB
|
||||
/// unique index on <c>LdapGroupName</c>. The form has no friendly pre-check, so
|
||||
/// the persistence exception is caught and surfaced as "Save failed: {message}"
|
||||
/// in <c>div.text-danger.small.mt-2</c>, keeping the user on the create page.
|
||||
/// The duplicate is CLI-seeded (and torn down) so this asserts the save-failure
|
||||
/// branch without depending on the raw DB error text.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Create_DuplicateGroup_ShowsSaveFailedError()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var group = CliRunner.UniqueName("grp");
|
||||
var seededId = await CliRunner.CreateRoleMappingAsync(group, "Designer");
|
||||
|
||||
try
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/ldap-mappings/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Fill the SAME group name the CLI just seeded → unique-index violation.
|
||||
await page.Locator("label:has-text('LDAP Group Name') + input.form-control.form-control-sm")
|
||||
.FillAsync(group);
|
||||
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm")
|
||||
.SelectOptionAsync("Viewer");
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
|
||||
// The catch block prefixes the raw DB message with "Save failed:" — assert
|
||||
// only on that stable prefix, never the provider-specific tail.
|
||||
await Assertions.Expect(page.Locator("div.text-danger.small.mt-2"))
|
||||
.ToContainTextAsync("Save failed");
|
||||
await Assertions.Expect(page)
|
||||
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/admin/ldap-mappings/create"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Best-effort: remove the CLI-seeded mapping; the UI never persisted a
|
||||
// duplicate (the save failed), so only the seed needs cleanup.
|
||||
await CliRunner.DeleteRoleMappingAsync(seededId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,89 @@ public class AuditLogPageTests
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task FilterCombination_ChannelPlusTarget_NarrowsToMatch()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// Two rows differing in channel AND target. Applying the channel filter
|
||||
// (ApiOutbound) AND a contains-match target filter together should narrow
|
||||
// the grid to the single matching row — proving the filters AND rather
|
||||
// than OR. The match row is ApiOutbound + target ".../match"; the other
|
||||
// row is DbOutbound + target ".../other" and is excluded on both axes.
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/wave4-audit/{runId}/";
|
||||
var matchId = Guid.NewGuid();
|
||||
var otherId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// Row 1 — the match: ApiOutbound channel, target ends in "match".
|
||||
await AuditDataSeeder.InsertAuditEventAsync(
|
||||
eventId: matchId,
|
||||
occurredAtUtc: now,
|
||||
channel: "ApiOutbound",
|
||||
kind: "ApiCall",
|
||||
status: "Delivered",
|
||||
target: targetPrefix + "match",
|
||||
httpStatus: 200,
|
||||
durationMs: 42);
|
||||
|
||||
// Row 2 — the non-match channel: DbOutbound, target ends in "other".
|
||||
await AuditDataSeeder.InsertAuditEventAsync(
|
||||
eventId: otherId,
|
||||
occurredAtUtc: now,
|
||||
channel: "DbOutbound",
|
||||
kind: "DbWrite",
|
||||
status: "Delivered",
|
||||
target: targetPrefix + "other",
|
||||
durationMs: 17);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Apply channel AND target together.
|
||||
await page.Locator("[data-test='filter-channel-select']").SelectOptionAsync("ApiOutbound");
|
||||
await page.FillAsync("#audit-target", targetPrefix + "match");
|
||||
await page.ClickAsync("[data-test='filter-apply']");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The match row is visible; the other row is absent — the two filters
|
||||
// AND, so a row must satisfy both to survive.
|
||||
await Assertions.Expect(page.Locator($"[data-test='grid-row-{matchId}']")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator($"[data-test='grid-row-{otherId}']")).ToHaveCountAsync(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task EmptyResults_AfterApply_ShowsEmptyState()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// A fresh random executionId GUID matches nothing — the ExecutionId
|
||||
// filter is an exact match, so a never-seeded GUID is guaranteed-empty by
|
||||
// construction. After Apply the grid renders zero rows and the empty-state
|
||||
// message. No seeding, no teardown.
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await page.FillAsync("#audit-execution-id", Guid.NewGuid().ToString());
|
||||
await page.ClickAsync("[data-test='filter-apply']");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Zero grid rows AND the empty-state literal inside the grid container.
|
||||
await Assertions.Expect(page.Locator("[data-test^='grid-row-']")).ToHaveCountAsync(0);
|
||||
await Assertions.Expect(page.Locator("[data-test='audit-results-grid']"))
|
||||
.ToContainTextAsync("No audit events match the current filter.");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task DrilldownDrawer_JsonPrettyPrintsRequestBody()
|
||||
{
|
||||
@@ -682,4 +765,119 @@ public class AuditLogPageTests
|
||||
// The audit results grid never rendered for the unauthorized user.
|
||||
Assert.Equal(0, await page.Locator("[data-test='audit-results-grid']").CountAsync());
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task NonApiRow_Drawer_OmitsCopyAsCurl()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// The Copy-as-cURL action is gated on AuditEventDetail.IsApiChannel, which
|
||||
// returns true only for ApiOutbound/ApiInbound. A DbOutbound row's drawer
|
||||
// must therefore render WITHOUT the cURL button — the negative of
|
||||
// CopyAsCurlButton_IsVisibleAndClickableForApiInbound. We seed one
|
||||
// DbOutbound row, open its drawer, and assert the drawer is open AND the
|
||||
// cURL button is absent.
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/wave4-audit2/{runId}/";
|
||||
var dbId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await AuditDataSeeder.InsertAuditEventAsync(
|
||||
eventId: dbId,
|
||||
occurredAtUtc: now,
|
||||
channel: "DbOutbound",
|
||||
kind: "DbWrite",
|
||||
status: "Delivered",
|
||||
target: targetPrefix + "dbrow",
|
||||
durationMs: 17);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Filter to the seeded row (contains-match), then Apply to populate the
|
||||
// grid — it stays empty until the user filters.
|
||||
await page.FillAsync("#audit-target", targetPrefix + "dbrow");
|
||||
await page.ClickAsync("[data-test='filter-apply']");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var row = page.Locator($"[data-test='grid-row-{dbId}']");
|
||||
await Assertions.Expect(row).ToBeVisibleAsync();
|
||||
await row.ClickAsync();
|
||||
|
||||
// The drawer opens for the DbOutbound row, but the cURL action — gated
|
||||
// on IsApiChannel — is absent. Wait for the AuditEventDetail body
|
||||
// ([data-test='drawer-fields']) to render BEFORE the negative count
|
||||
// assertion, so a not-yet-rendered child can't false-pass count 0.
|
||||
await Assertions.Expect(page.Locator("[data-test='audit-drilldown-drawer']")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("[data-test='drawer-fields']")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page.Locator("[data-test='copy-as-curl']")).ToHaveCountAsync(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task Drawer_CloseControls_DismissTheDrawer()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// The drawer offers two wired close paths — the header X
|
||||
// ([data-test='drawer-close']) and the footer Close button
|
||||
// ([data-test='drawer-close-footer']), both bound to HandleClose. We open
|
||||
// the drawer, dismiss via the X, re-open, then dismiss via the footer,
|
||||
// asserting the drawer is gone (count 0) after each.
|
||||
// Escape-to-close is NOT wired on AuditDrilldownDrawer (only
|
||||
// ExecutionDetailModal has a keydown handler). The backdrop is also wired
|
||||
// to HandleClose, but we cover the X and footer-Close paths here for brevity.
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/wave4-audit2c/{runId}/";
|
||||
var apiId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await AuditDataSeeder.InsertAuditEventAsync(
|
||||
eventId: apiId,
|
||||
occurredAtUtc: now,
|
||||
channel: "ApiOutbound",
|
||||
kind: "ApiCall",
|
||||
status: "Delivered",
|
||||
target: targetPrefix + "row",
|
||||
httpStatus: 200,
|
||||
durationMs: 42);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await page.FillAsync("#audit-target", targetPrefix + "row");
|
||||
await page.ClickAsync("[data-test='filter-apply']");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var row = page.Locator($"[data-test='grid-row-{apiId}']");
|
||||
var drawer = page.Locator("[data-test='audit-drilldown-drawer']");
|
||||
|
||||
// Open, then dismiss via the header X.
|
||||
await Assertions.Expect(row).ToBeVisibleAsync();
|
||||
await row.ClickAsync();
|
||||
await Assertions.Expect(drawer).ToBeVisibleAsync();
|
||||
await page.ClickAsync("[data-test='drawer-close']");
|
||||
await Assertions.Expect(drawer).ToHaveCountAsync(0);
|
||||
|
||||
// Re-open, then dismiss via the footer Close button.
|
||||
await row.ClickAsync();
|
||||
await Assertions.Expect(drawer).ToBeVisibleAsync();
|
||||
await page.ClickAsync("[data-test='drawer-close-footer']");
|
||||
await Assertions.Expect(drawer).ToHaveCountAsync(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,6 +481,22 @@ public static partial class CliRunner
|
||||
return RequireId(doc, "shared-script create");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an LDAP→role mapping via <c>security role-mapping create</c> and returns its new <c>id</c>.
|
||||
/// </summary>
|
||||
/// <param name="ldapGroup">LDAP group name to map (typically from <see cref="UniqueName"/>).</param>
|
||||
/// <param name="role">Role to grant members of the group; defaults to <c>Designer</c>.</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// The CLI failed, or the response did not carry an integer <c>id</c>.
|
||||
/// </exception>
|
||||
public static async Task<int> CreateRoleMappingAsync(string ldapGroup, string role = "Designer")
|
||||
{
|
||||
using var doc = await RunJsonAsync(
|
||||
"security", "role-mapping", "create",
|
||||
"--ldap-group", ldapGroup, "--role", role);
|
||||
return RequireId(doc, "security role-mapping create");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ids of all external systems whose <c>name</c> starts with
|
||||
/// <paramref name="prefix"/>, via <c>external-system list</c>. Used to delete an
|
||||
@@ -514,6 +530,34 @@ public static partial class CliRunner
|
||||
/// <summary>Best-effort delete of a shared script via <c>shared-script delete</c> for teardown.</summary>
|
||||
public static Task DeleteSharedScriptAsync(int id) => BestEffortAsync("shared-script", "delete", id);
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort delete of an LDAP→role mapping via <c>security role-mapping delete</c> for teardown;
|
||||
/// swallows any failure (the entity may already be gone).
|
||||
/// </summary>
|
||||
/// <param name="id">Role-mapping id.</param>
|
||||
/// <remarks>
|
||||
/// This method intentionally does NOT delegate to <see cref="BestEffortAsync"/>
|
||||
/// even though the behaviour is identical. <see cref="BestEffortAsync"/> models
|
||||
/// two-word commands (<c><group> <verb></c>), whereas
|
||||
/// <c>security role-mapping delete</c> is a three-word command; extracting it would
|
||||
/// require changing <see cref="BestEffortAsync"/>'s signature or adding an overload.
|
||||
/// The inline try/catch is kept here deliberately — same pattern as
|
||||
/// <see cref="DeleteAreaAsync"/>.
|
||||
/// </remarks>
|
||||
public static async Task DeleteRoleMappingAsync(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunAsync(
|
||||
"security", "role-mapping", "delete",
|
||||
"--id", id.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort teardown — never mask the test's own failure.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports a Transport bundle scoped to a single template via
|
||||
/// <c>bundle export</c>.
|
||||
|
||||
+23
@@ -165,4 +165,27 @@ public class CliRunnerHelpersTests
|
||||
try { Assert.True(id > 0); }
|
||||
finally { await CliRunner.DeleteSharedScriptAsync(id); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A freshly created LDAP→role mapping returns a positive id, is discoverable by its
|
||||
/// <c>ldapGroupName</c> in <c>security role-mapping list</c>, and is cleanly deleted in
|
||||
/// teardown, exercising <see cref="CliRunner.CreateRoleMappingAsync"/> and
|
||||
/// <see cref="CliRunner.DeleteRoleMappingAsync"/> as a round-trip.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task CreateThenDeleteRoleMapping_RoundTrips()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
var group = CliRunner.UniqueName("grp");
|
||||
var id = await CliRunner.CreateRoleMappingAsync(group, "Designer");
|
||||
try
|
||||
{
|
||||
Assert.True(id > 0);
|
||||
using var list = await CliRunner.RunJsonAsync("security", "role-mapping", "list");
|
||||
Assert.Contains(
|
||||
list.RootElement.EnumerateArray(),
|
||||
e => e.TryGetProperty("ldapGroupName", out var n) && n.GetString() == group);
|
||||
}
|
||||
finally { await CliRunner.DeleteRoleMappingAsync(id); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.Playwright;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Deployment;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
|
||||
|
||||
@@ -9,13 +11,15 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Design;
|
||||
/// running dev cluster.
|
||||
/// </summary>
|
||||
[Collection("Playwright")]
|
||||
public class TemplateCrudTests
|
||||
public class TemplateCrudTests : IClassFixture<DeploymentFixture>
|
||||
{
|
||||
private readonly PlaywrightFixture _fixture;
|
||||
private readonly DeploymentFixture _cluster;
|
||||
|
||||
public TemplateCrudTests(PlaywrightFixture fixture)
|
||||
public TemplateCrudTests(PlaywrightFixture fixture, DeploymentFixture cluster)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_cluster = cluster;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
@@ -120,4 +124,214 @@ public class TemplateCrudTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CreateTemplate_DuplicateName_ShowsInlineError()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// CLI-seed an existing base template, then UI-attempt to create a duplicate.
|
||||
// Base (non-derived) Template.Name has a unique index
|
||||
// (HasIndex(t => t.Name).IsUnique().HasFilter("[IsDerived]=0")) and
|
||||
// TemplateService.CreateTemplateAsync has no friendly duplicate pre-check,
|
||||
// so the DB-constraint exception is caught into _formError and rendered inline
|
||||
// in div.text-danger.small with no navigation (stays on /create).
|
||||
// (Empirically confirmed: duplicate create surfaces inline; duplicate-name path used.)
|
||||
var name = CliRunner.UniqueName("tmpl");
|
||||
var seededId = await CliRunner.CreateTemplateAsync(name);
|
||||
|
||||
try
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Fill the Name input with the duplicate name and click Create.
|
||||
await page.Locator("div.mb-3:has(label:has-text('Name')) input.form-control").FillAsync(name);
|
||||
await page.ClickAsync("button.btn.btn-success:has-text('Create')");
|
||||
|
||||
// Web-first assertions: the inline error becomes visible and we stay on /create.
|
||||
// Do NOT assert a literal message — it is the DB-constraint exception text.
|
||||
await Assertions.Expect(page.Locator("div.text-danger.small")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(page)
|
||||
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/design/templates/create"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Delete the seeded source template, then sweep any leftover by name.
|
||||
await CliRunner.DeleteTemplateAsync(seededId);
|
||||
try
|
||||
{
|
||||
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(name))
|
||||
{
|
||||
await CliRunner.DeleteTemplateAsync(id);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — swallow to avoid masking the original test failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task CreateTemplate_Cancel_ReturnsToListWithoutCreating()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var name = CliRunner.UniqueName("tmpl");
|
||||
|
||||
try
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Fill the Name input, then click Cancel — Blazor navigates back to the list.
|
||||
await page.Locator("div.mb-3:has(label:has-text('Name')) input.form-control").FillAsync(name);
|
||||
await page.ClickAsync("button.btn.btn-outline-secondary:has-text('Cancel')");
|
||||
|
||||
// excludePath: "/create" rejects the /design/templates/create URL we came from.
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/design/templates", excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Nothing was created: no template exists with our unique name.
|
||||
Assert.Empty(await CliRunner.ListTemplateIdsByNamePrefixAsync(name));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Defensive sweep by name in case of an unexpected create.
|
||||
try
|
||||
{
|
||||
foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(name))
|
||||
{
|
||||
await CliRunner.DeleteTemplateAsync(id);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — swallow to avoid masking the original test failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task EditAttribute_PersistsChange()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// CLI-seed a template with a single Double "Val" attribute, then edit its
|
||||
// Value through the page-local Edit-Attribute modal and confirm it persists.
|
||||
var name = CliRunner.UniqueName("tmpl");
|
||||
var id = await CliRunner.CreateTemplateAsync(name);
|
||||
await CliRunner.AddAttributeAsync(id, "Val", "Double");
|
||||
|
||||
try
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/{id}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Open the Val row's actions dropdown, then click the "Edit…" item
|
||||
// (note the ellipsis char "…" — it is in the markup verbatim).
|
||||
await page.ClickAsync("button[aria-label=\"More actions for Val\"]");
|
||||
await page.ClickAsync("button.dropdown-item:has-text('Edit…')");
|
||||
|
||||
// The page-local attribute modal is .modal.show.d-block WITHOUT `fade`
|
||||
// (the DialogHost confirm modal HAS `fade`). :not(.fade) pins the
|
||||
// page-local one.
|
||||
var modal = page.Locator(".modal.show.d-block:not(.fade)");
|
||||
await Assertions.Expect(modal).ToBeVisibleAsync();
|
||||
await Assertions.Expect(modal.Locator("h6.modal-title")).ToHaveTextAsync("Edit Attribute");
|
||||
|
||||
// When editing, the Name input is rendered readonly (readonly="@editing").
|
||||
await Assertions.Expect(
|
||||
modal.Locator("div.col-12:has(label:has-text('Name')) input.form-control"))
|
||||
.ToHaveAttributeAsync("readonly", "");
|
||||
|
||||
// The Value input is label-anchored: its containing div carries the
|
||||
// "Value" label. ("Data Source Ref" does not contain "Value", so the
|
||||
// substring match resolves uniquely to the Value field.)
|
||||
var valueInput = modal.Locator("div.col-12:has(label:has-text('Value')) input.form-control");
|
||||
await valueInput.FillAsync("42.5");
|
||||
|
||||
// Footer button text is "Save" when editing (vs "Add" when adding).
|
||||
await modal.Locator(".modal-footer button.btn-success.btn-sm:has-text('Save')").ClickAsync();
|
||||
|
||||
// SaveAttribute persists then reloads, dismissing the modal.
|
||||
await Assertions.Expect(modal).ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
|
||||
// The attribute table renders the value in a <td class="small">@effectiveValue</td>
|
||||
// cell (for a non-derived template effectiveValue == attr.Value), so the
|
||||
// persisted "42.5" must appear in the Val row's value cell. Web-first wait
|
||||
// covers the post-save reload.
|
||||
await Assertions.Expect(page.Locator("table td.small:has-text('42.5')"))
|
||||
.ToBeVisibleAsync(new() { Timeout = 10_000 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
await CliRunner.DeleteTemplateAsync(id);
|
||||
// Defensive sweep by name in case the delete above was a no-op.
|
||||
try
|
||||
{
|
||||
foreach (var leftover in await CliRunner.ListTemplateIdsByNamePrefixAsync(name))
|
||||
{
|
||||
await CliRunner.DeleteTemplateAsync(leftover);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — swallow to avoid masking the original test failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task DeleteTemplate_WithInstance_IsBlocked()
|
||||
{
|
||||
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
|
||||
|
||||
// Mint a non-deployed instance referencing the fixture's template — that
|
||||
// reference is sufficient for TemplateDeletionService to block the delete
|
||||
// with the "instance(s) reference it" error.
|
||||
var (instId, _) = await _cluster.CreateInstanceAsync();
|
||||
try
|
||||
{
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates/{_cluster.TemplateId}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Header Delete button (btn-outline-danger btn-sm).
|
||||
await page.ClickAsync("button.btn.btn-outline-danger.btn-sm:has-text('Delete')");
|
||||
|
||||
// Confirm via the global DialogHost modal's danger button (labelled
|
||||
// "Delete"). This modal HAS the `fade` class — distinct from the
|
||||
// page-local attribute modal.
|
||||
var confirmBtn = page.Locator(".modal-footer .btn-danger:has-text('Delete')");
|
||||
await Assertions.Expect(confirmBtn).ToBeVisibleAsync(new() { Timeout = 5_000 });
|
||||
await confirmBtn.ClickAsync();
|
||||
|
||||
// The delete fails: DeleteTemplate surfaces result.Error on a toast and
|
||||
// does NOT navigate. The error reads
|
||||
// "Cannot delete template '{name}': {n} instance(s) reference it (...)".
|
||||
await Assertions.Expect(page.Locator(".toast"))
|
||||
.ToContainTextAsync("instance(s) reference it", new() { Timeout = 10_000 });
|
||||
|
||||
// The template still exists — the page stayed on the detail URL because
|
||||
// the failed delete did not navigate away.
|
||||
await Assertions.Expect(page)
|
||||
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex(
|
||||
$"/design/templates/{_cluster.TemplateId}$"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Remove this test's instance so the fixture template is instance-free
|
||||
// again; the fixture's DisposeAsync sweeps the template + any leftovers.
|
||||
await CliRunner.DeleteInstanceAsync(instId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+275
@@ -166,4 +166,279 @@ public class NotificationActionTests
|
||||
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combined-filter narrowing: two Parked rows share one <c>ListName</c> marker but carry
|
||||
/// distinct subjects. Applying the exact-match list filter (the marker) plus
|
||||
/// status=Parked plus the subject keyword for only ONE of the two rows must surface that
|
||||
/// row and exclude its sibling. This proves the filters compose (AND-narrowing) rather
|
||||
/// than widening — the marker isolates the pair from ambient cluster rows, and the
|
||||
/// subject keyword discriminates between them.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task FilterCombination_StatusPlusList_NarrowsToMatch()
|
||||
{
|
||||
Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var marker = $"zztest-notif-wave4-{runId}";
|
||||
|
||||
try
|
||||
{
|
||||
// Two Parked rows under the SAME ListName marker, distinct subjects.
|
||||
// Seeded inside the try so a mid-seed failure is still cleaned up by the finally.
|
||||
await NotificationDataSeeder.InsertParkedNotificationAsync(
|
||||
Guid.NewGuid(), marker, "wave4-alpha", "site-a");
|
||||
await NotificationDataSeeder.InsertParkedNotificationAsync(
|
||||
Guid.NewGuid(), marker, "wave4-beta", "site-a");
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{NotificationReportUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();
|
||||
|
||||
// #no-list is an exact-match text input bound @bind (commit-on-change), so
|
||||
// FillAsync (which only fires `input`) must be followed by an explicit
|
||||
// `change` dispatch to commit the bound value before the Query roundtrip —
|
||||
// same rationale as SetSubjectKeywordAsync for the subject box.
|
||||
await page.Locator("#no-list").FillAsync(marker);
|
||||
await page.Locator("#no-list").DispatchEventAsync("change");
|
||||
|
||||
// Status select commits on its own SelectOptionAsync change event.
|
||||
await page.Locator("#no-status").SelectOptionAsync("Parked");
|
||||
|
||||
// Subject keyword for ONLY the alpha row.
|
||||
await SetSubjectKeywordAsync(page, "wave4-alpha");
|
||||
|
||||
await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The combined filters narrow to alpha: alpha visible, beta absent.
|
||||
var alphaRow = page.Locator("tbody tr", new() { HasText = "wave4-alpha" });
|
||||
await Assertions.Expect(alphaRow).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(
|
||||
page.Locator("tbody tr").Filter(new() { HasText = "wave4-beta" }))
|
||||
.ToHaveCountAsync(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The row detail modal opens on a DOUBLE-CLICK of a non-Actions row cell (there is no
|
||||
/// dedicated open button) and closes via the header X and the footer Close button. Both
|
||||
/// close affordances are wired to the same <c>CloseDetail</c> handler; this exercises
|
||||
/// each path independently (open → X-close → reopen → footer-close).
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task DetailModal_OpenOnDblClick_CloseViaXAndFooter()
|
||||
{
|
||||
Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var marker = $"zztest-notif-wave4-{runId}";
|
||||
|
||||
try
|
||||
{
|
||||
// Seeded inside the try so a mid-seed failure is still cleaned up by the finally.
|
||||
await NotificationDataSeeder.InsertParkedNotificationAsync(
|
||||
Guid.NewGuid(), marker, "wave4-detail", "site-a");
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{NotificationReportUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();
|
||||
|
||||
// Narrow to the seeded row by its exact ListName marker (+ Parked status).
|
||||
await page.Locator("#no-list").FillAsync(marker);
|
||||
await page.Locator("#no-list").DispatchEventAsync("change");
|
||||
await page.Locator("#no-status").SelectOptionAsync("Parked");
|
||||
await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var row = page.Locator("tbody tr").Filter(new() { HasText = "wave4-detail" });
|
||||
await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
|
||||
|
||||
// Open the modal: double-click the SUBJECT cell (a non-Actions cell). The
|
||||
// Actions cell carries @ondblclick:stopPropagation, so double-clicking it would
|
||||
// not open the modal — the subject cell is the correct target.
|
||||
await row.Locator("td", new() { HasText = "wave4-detail" }).DblClickAsync();
|
||||
await Assertions.Expect(page.Locator(".modal.show.d-block")).ToBeVisibleAsync();
|
||||
// Scope the title to the open modal — the page also renders Retry/Discard
|
||||
// confirm dialogs that carry their own .modal-title.
|
||||
await Assertions.Expect(page.Locator(".modal.show.d-block .modal-title")).ToContainTextAsync("Notification Detail");
|
||||
|
||||
// Close via the header X.
|
||||
await page.ClickAsync("button.btn-close[aria-label='Close']");
|
||||
await Assertions.Expect(page.Locator(".modal.show.d-block")).ToHaveCountAsync(0);
|
||||
|
||||
// Re-open (double-click the subject cell again).
|
||||
await row.Locator("td", new() { HasText = "wave4-detail" }).DblClickAsync();
|
||||
await Assertions.Expect(page.Locator(".modal.show.d-block")).ToBeVisibleAsync();
|
||||
|
||||
// Close via the footer Close button.
|
||||
await page.ClickAsync(".modal-footer button.btn-outline-secondary:has-text('Close')");
|
||||
await Assertions.Expect(page.Locator(".modal.show.d-block")).ToHaveCountAsync(0);
|
||||
|
||||
// Escape-to-close is NOT wired on the Notification detail modal (no keydown handler); covering the X and footer-Close paths that ARE wired (backdrop also closes but is omitted for brevity).
|
||||
}
|
||||
finally
|
||||
{
|
||||
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The "Stuck only" filter narrows the result set to genuinely stuck rows. A
|
||||
/// notification IsStuck iff its <c>Status ∈ {Pending, Retrying}</c> AND its
|
||||
/// <c>CreatedAt < now − StuckAgeThreshold</c> (default 10 min). Two rows share one
|
||||
/// <c>ListName</c> marker: a genuinely-stuck row (<c>Retrying</c>, back-dated 15 min for
|
||||
/// margin) and a non-stuck <c>Parked@now</c> row. Querying by the marker alone surfaces
|
||||
/// BOTH; toggling Stuck-only ON must drop the fresh row and keep only the stuck one.
|
||||
/// This uses the POSITIVE form (stuck row present, fresh row gone): the default
|
||||
/// <c>StuckAgeThreshold</c> is 10 min — verified in NotificationOutboxOptions and the
|
||||
/// docker cluster carries no override — so a Retrying row back-dated 15 min passes both
|
||||
/// the repository's StuckOnly SQL predicate and the page's <c>IsStuck</c> derivation.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task StuckOnlyFilter_NarrowsToStuckRows()
|
||||
{
|
||||
Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var marker = $"zztest-notif-wave4-{runId}";
|
||||
|
||||
try
|
||||
{
|
||||
// Genuinely-stuck row: Retrying + back-dated 15 min (> the 10-min StuckAgeThreshold).
|
||||
// Seeded inside the try so a mid-seed failure is still cleaned up by the finally.
|
||||
await NotificationDataSeeder.InsertNotificationAsync(
|
||||
Guid.NewGuid(), marker, "wave4-stuck",
|
||||
status: "Retrying", createdAt: DateTimeOffset.UtcNow.AddMinutes(-15),
|
||||
sourceSite: "site-a");
|
||||
// Non-stuck row: Parked @ now (Parked is a terminal status → never stuck, regardless of age).
|
||||
await NotificationDataSeeder.InsertParkedNotificationAsync(
|
||||
Guid.NewGuid(), marker, "wave4-fresh", "site-a");
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{NotificationReportUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();
|
||||
|
||||
// Narrow to the pair by the exact ListName marker (commit the @bind value with a
|
||||
// change dispatch — FillAsync only fires `input`), then Query.
|
||||
await page.Locator("#no-list").FillAsync(marker);
|
||||
await page.Locator("#no-list").DispatchEventAsync("change");
|
||||
await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// The marker alone selects BOTH rows (proves the stuck filter is what narrows,
|
||||
// not the marker).
|
||||
await Assertions.Expect(
|
||||
page.Locator("tbody tr").Filter(new() { HasText = "wave4-stuck" }))
|
||||
.ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(
|
||||
page.Locator("tbody tr").Filter(new() { HasText = "wave4-fresh" }))
|
||||
.ToHaveCountAsync(1);
|
||||
|
||||
// Toggle Stuck-only ON and re-Query.
|
||||
await page.Locator("#no-stuck-only").CheckAsync();
|
||||
await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Only the stuck row survives: fresh gone, stuck present.
|
||||
await Assertions.Expect(
|
||||
page.Locator("tbody tr").Filter(new() { HasText = "wave4-fresh" }))
|
||||
.ToHaveCountAsync(0, new() { Timeout = 15_000 });
|
||||
var stuckRow = page.Locator("tbody tr").Filter(new() { HasText = "wave4-stuck" });
|
||||
await Assertions.Expect(stuckRow).ToHaveCountAsync(1);
|
||||
|
||||
// And the surviving row carries the stuck badge (positive corroboration that the
|
||||
// page classifies it as stuck, not merely that the SQL filter kept it).
|
||||
await Assertions.Expect(
|
||||
stuckRow.Locator("span.badge.bg-warning.text-dark:has-text('Stuck')"))
|
||||
.ToBeVisibleAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Page-number pagination: the pager renders only when <c>_totalCount > 50</c>
|
||||
/// (page size 50). Seeding 51 rows under one <c>ListName</c> marker yields exactly two
|
||||
/// pages (50 + 1). The test drives the page-NUMBER pager forward (Next) and back
|
||||
/// (Previous), asserting the row count, the page indicator, and the Previous/Next
|
||||
/// enabled/disabled states at each step. Per the harness contract the row COUNT is
|
||||
/// asserted FIRST at each step — it waits out the fetch, during which <c>_loading</c>
|
||||
/// disables both pager buttons — so the button-state assertions never race the in-flight
|
||||
/// query.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Pagination_PageNumberNextAndPrev_TraversesPages()
|
||||
{
|
||||
Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var marker = $"zztest-notif-wave4-{runId}";
|
||||
|
||||
try
|
||||
{
|
||||
// 51 Parked rows under one marker → 2 pages (50 + 1); the pager renders (> 50).
|
||||
// Seeded inside the try so a mid-seed failure is still cleaned up by the finally.
|
||||
for (int i = 0; i < 51; i++)
|
||||
{
|
||||
await NotificationDataSeeder.InsertParkedNotificationAsync(
|
||||
Guid.NewGuid(), marker, $"wave4-page-{i:D2}", "site-a");
|
||||
}
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{NotificationReportUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();
|
||||
|
||||
await page.Locator("#no-list").FillAsync(marker);
|
||||
await page.Locator("#no-list").DispatchEventAsync("change");
|
||||
await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Pager locators. Previous/Next are scoped by their text (the page header's
|
||||
// Refresh button is also a .btn-outline-secondary.btn-sm). The indicator span is
|
||||
// the pager's only `span.text-muted.small` once rows render — the "Loading…"
|
||||
// placeholder that shares that class renders only while `_notifications == null`.
|
||||
var prev = page.Locator("button.btn-outline-secondary.btn-sm:has-text('Previous')");
|
||||
var next = page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')");
|
||||
var indicator = page.Locator("span.text-muted.small");
|
||||
|
||||
// ── Page 1 ── (count first — it waits out the fetch — then indicator + buttons).
|
||||
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50, new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(indicator).ToContainTextAsync("Page 1");
|
||||
await Assertions.Expect(prev).ToBeDisabledAsync();
|
||||
await Assertions.Expect(next).ToBeEnabledAsync();
|
||||
|
||||
// ── Forward to page 2 ──
|
||||
await next.ClickAsync();
|
||||
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(indicator).ToContainTextAsync("Page 2");
|
||||
await Assertions.Expect(prev).ToBeEnabledAsync();
|
||||
await Assertions.Expect(next).ToBeDisabledAsync();
|
||||
|
||||
// ── Back to page 1 ──
|
||||
await prev.ClickAsync();
|
||||
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50, new() { Timeout = 15_000 });
|
||||
await Assertions.Expect(indicator).ToContainTextAsync("Page 1");
|
||||
await Assertions.Expect(prev).ToBeDisabledAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await NotificationDataSeeder.DeleteByMarkerAsync(marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-14
@@ -38,27 +38,50 @@ internal static class NotificationDataSeeder
|
||||
public static string ConnectionString => PlaywrightDbConnection.ConnectionString;
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a single <c>Parked</c> row into the central <c>Notifications</c> table.
|
||||
/// Inserts one notification row with explicit <paramref name="status"/> and
|
||||
/// <paramref name="createdAt"/> (for stuck/age edge-case tests).
|
||||
///
|
||||
/// <para>
|
||||
/// Populates every NOT NULL column (NotificationId, Type, ListName, Subject, Body,
|
||||
/// Status, RetryCount, SourceSiteId, SiteEnqueuedAt, CreatedAt) and stamps the row with
|
||||
/// the unique <paramref name="listNameMarker"/> so the test can filter to it and the
|
||||
/// teardown can delete by it. <c>Status</c> is fixed to <c>Parked</c> — the only status
|
||||
/// from which the page exposes Retry/Discard. Timestamps are pinned to "now" so the
|
||||
/// page's default unconstrained query window sees the row.
|
||||
/// Status, RetryCount, SourceSiteId, SiteEnqueuedAt, CreatedAt) and stamps the row
|
||||
/// with the unique <paramref name="listNameMarker"/> so the test can filter to it and
|
||||
/// the teardown can delete by it. <c>SiteEnqueuedAt</c> is set equal to
|
||||
/// <paramref name="createdAt"/>. All nullable provenance columns (SourceNode,
|
||||
/// OriginExecutionId, …) are left to default to NULL, which the page renders as an
|
||||
/// em-dash.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// To seed a genuinely stuck row set <paramref name="status"/> to <c>Retrying</c> (or
|
||||
/// <c>Pending</c>) and <paramref name="createdAt"/> to more than 10 minutes in the past —
|
||||
/// <c>IsStuck</c> is derived as <c>Status ∈ {Pending, Retrying} && CreatedAt <
|
||||
/// now − 10 min</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="notificationId">GUID primary key (stored as its 36-char string form).</param>
|
||||
/// <param name="listNameMarker">Unique per-run marker stored in <c>ListName</c>.</param>
|
||||
/// <param name="subject">Subject text (searchable via the page's subject keyword box).</param>
|
||||
/// <param name="status">
|
||||
/// Status value stored as varchar (HasConversion<string>()): e.g. <c>Pending</c>,
|
||||
/// <c>Retrying</c>, <c>Parked</c>, <c>Delivered</c>, <c>Discarded</c>.
|
||||
/// </param>
|
||||
/// <param name="createdAt">
|
||||
/// Timestamp written to both <c>CreatedAt</c> and <c>SiteEnqueuedAt</c>. Pass a
|
||||
/// back-dated value (e.g. <c>DateTimeOffset.UtcNow.AddMinutes(-15)</c>) to produce a
|
||||
/// stuck row.
|
||||
/// </param>
|
||||
/// <param name="sourceSite">Originating site identifier (e.g. <c>site-a</c>).</param>
|
||||
/// <param name="retryCount">Retry count to display on the row.</param>
|
||||
/// <param name="lastError">Optional last-error text shown beneath the subject.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public static async Task InsertParkedNotificationAsync(
|
||||
public static async Task InsertNotificationAsync(
|
||||
Guid notificationId,
|
||||
string listNameMarker,
|
||||
string subject,
|
||||
string sourceSite,
|
||||
int retryCount = 3,
|
||||
string status,
|
||||
DateTimeOffset createdAt,
|
||||
string sourceSite = "site-a",
|
||||
int retryCount = 0,
|
||||
string? lastError = "SMTP 451 transient failure (seeded)",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
@@ -74,8 +97,6 @@ VALUES
|
||||
(@id, @type, @listName, @subject, @body, @status, @retryCount,
|
||||
@lastError, @sourceSite, @siteEnqueuedAt, @createdAt);";
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
await using var connection = new SqlConnection(ConnectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
await using var cmd = connection.CreateCommand();
|
||||
@@ -85,16 +106,45 @@ VALUES
|
||||
cmd.Parameters.AddWithValue("@listName", listNameMarker);
|
||||
cmd.Parameters.AddWithValue("@subject", subject);
|
||||
cmd.Parameters.AddWithValue("@body", "Seeded notification body for Playwright E2E.");
|
||||
cmd.Parameters.AddWithValue("@status", "Parked");
|
||||
cmd.Parameters.AddWithValue("@status", status);
|
||||
cmd.Parameters.AddWithValue("@retryCount", retryCount);
|
||||
cmd.Parameters.AddWithValue("@lastError", (object?)lastError ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@sourceSite", sourceSite);
|
||||
cmd.Parameters.AddWithValue("@siteEnqueuedAt", now);
|
||||
cmd.Parameters.AddWithValue("@createdAt", now);
|
||||
cmd.Parameters.AddWithValue("@siteEnqueuedAt", createdAt);
|
||||
cmd.Parameters.AddWithValue("@createdAt", createdAt);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a single <c>Parked</c> row into the central <c>Notifications</c> table.
|
||||
/// Populates every NOT NULL column (NotificationId, Type, ListName, Subject, Body,
|
||||
/// Status, RetryCount, SourceSiteId, SiteEnqueuedAt, CreatedAt) and stamps the row with
|
||||
/// the unique <paramref name="listNameMarker"/> so the test can filter to it and the
|
||||
/// teardown can delete by it. <c>Status</c> is fixed to <c>Parked</c> — the only status
|
||||
/// from which the page exposes Retry/Discard. Timestamps are pinned to "now" so the
|
||||
/// page's default unconstrained query window sees the row.
|
||||
/// </summary>
|
||||
/// <param name="notificationId">GUID primary key (stored as its 36-char string form).</param>
|
||||
/// <param name="listNameMarker">Unique per-run marker stored in <c>ListName</c>.</param>
|
||||
/// <param name="subject">Subject text (searchable via the page's subject keyword box).</param>
|
||||
/// <param name="sourceSite">Originating site identifier (e.g. <c>site-a</c>).</param>
|
||||
/// <param name="retryCount">Retry count to display on the row.</param>
|
||||
/// <param name="lastError">Optional last-error text shown beneath the subject.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
public static Task InsertParkedNotificationAsync(
|
||||
Guid notificationId,
|
||||
string listNameMarker,
|
||||
string subject,
|
||||
string sourceSite,
|
||||
int retryCount = 3,
|
||||
string? lastError = "SMTP 451 transient failure (seeded)",
|
||||
CancellationToken ct = default)
|
||||
=> InsertNotificationAsync(
|
||||
notificationId, listNameMarker, subject,
|
||||
status: "Parked", createdAt: DateTimeOffset.UtcNow,
|
||||
sourceSite: sourceSite, retryCount: retryCount, lastError: lastError, ct: ct);
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort cleanup. Deletes every <c>Notifications</c> row whose <c>ListName</c>
|
||||
/// equals <paramref name="listNameMarker"/>. Swallows all errors — the marker carries a
|
||||
|
||||
+189
@@ -387,4 +387,193 @@ public class SiteCallsPageTests
|
||||
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Status dropdown filters the grid to the selected lifecycle status.
|
||||
/// Seeds two rows sharing a per-run Target prefix — one <c>Parked</c>, one
|
||||
/// <c>Delivered</c> — then filters status=Parked: the Parked marker row
|
||||
/// surfaces (with a <c>Parked</c> status badge) while the Delivered marker
|
||||
/// row is excluded even though both share the prefix. This mirrors
|
||||
/// <see cref="FilterNarrowing_ChannelFilterShrinksGrid"/> but exercises the
|
||||
/// STATUS axis rather than the channel axis.
|
||||
/// <para>
|
||||
/// <c>SourceSite</c> is <c>site-a</c> (a permitted site) on both rows —
|
||||
/// <c>FilterPermittedAsync</c> drops rows whose source site is not permitted.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task StatusFilter_NarrowsToSelectedStatus()
|
||||
{
|
||||
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var targetPrefix = $"playwright-test/wave4-sc/{runId}/";
|
||||
var parkedId = Guid.NewGuid();
|
||||
var deliveredId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
// Two rows sharing the prefix: one Parked, one Delivered. site-a is a
|
||||
// permitted source site (FilterPermittedAsync keeps it).
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
|
||||
sourceSite: "site-a", status: "Parked", retryCount: 3,
|
||||
lastError: "HTTP 503 from ERP", httpStatus: 503,
|
||||
createdAtUtc: now, updatedAtUtc: now);
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: deliveredId, channel: "ApiOutbound", target: targetPrefix + "delivered",
|
||||
sourceSite: "site-a", status: "Delivered", retryCount: 0,
|
||||
createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Filter status=Parked and scope to the exact Parked marker — the row
|
||||
// surfaces and its status badge reads Parked.
|
||||
await page.Locator("#sc-status").SelectOptionAsync("Parked");
|
||||
await SetSearchKeywordAsync(page, targetPrefix + "parked");
|
||||
await page.ClickAsync("[data-test='site-calls-query']");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
|
||||
await Assertions.Expect(parkedRow).ToBeVisibleAsync();
|
||||
await Assertions.Expect(parkedRow.Locator("span.badge:has-text('Parked')")).ToBeVisibleAsync();
|
||||
|
||||
// Same status=Parked filter, now searching the Delivered marker: the
|
||||
// Delivered row is excluded by the status filter, so no row carries
|
||||
// its marker. The retrying ToHaveCount waits out the re-render.
|
||||
await SetSearchKeywordAsync(page, targetPrefix + "delivered");
|
||||
await page.ClickAsync("[data-test='site-calls-query']");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await Assertions.Expect(
|
||||
page.Locator("tbody tr").Filter(new() { HasText = targetPrefix + "delivered" }))
|
||||
.ToHaveCountAsync(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the filters match no rows the grid renders the empty-state card
|
||||
/// rather than a table. A per-run GUID Target is searched (exact match), so
|
||||
/// nothing can match — guaranteed empty without seeding. Asserts both the
|
||||
/// absence of data rows and the empty-state literal.
|
||||
/// <para>
|
||||
/// The empty-state literal lives in <c>div.card > div.card-body…</c>, but
|
||||
/// the filter card also uses <c>.card-body</c>, so a bare <c>.card-body</c>
|
||||
/// locator is ambiguous under strict mode. We assert the literal via
|
||||
/// <see cref="IPage.GetByText(string,PageGetByTextOptions)"/> instead.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task EmptyState_NoMatch_ShowsEmptyCard()
|
||||
{
|
||||
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// A per-run GUID target matches nothing (exact match → guaranteed empty).
|
||||
await SetSearchKeywordAsync(page, $"playwright-test/wave4-sc-empty/{Guid.NewGuid():N}/none");
|
||||
await page.ClickAsync("[data-test='site-calls-query']");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// No data rows, and the empty-state literal renders. GetByText avoids the
|
||||
// strict-mode ambiguity of the shared .card-body class.
|
||||
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(0);
|
||||
await Assertions.Expect(
|
||||
page.GetByText("No cached calls match the current filters."))
|
||||
.ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keyset pagination traverses forward (Next) and back (Previous) across a
|
||||
/// full page boundary. The grid pages at 50 rows ordered
|
||||
/// <c>CreatedAtUtc DESC, TrackedOperationId DESC</c>; the Next button is
|
||||
/// enabled only when the current page came back exactly full (50 rows), so a
|
||||
/// short page (1 row) is the last page.
|
||||
/// <para>
|
||||
/// <c>Target</c> is an exact match, so seeding 51 rows that all share ONE
|
||||
/// identical target string lets a single <c>#sc-search</c> keyword select all
|
||||
/// 51 → page 1 = 50 rows, page 2 = 1 row. Staggering <c>createdAtUtc</c> by
|
||||
/// the loop index makes the keyset order strict and deterministic. Every row
|
||||
/// uses the permitted source site <c>site-a</c> so
|
||||
/// <c>FilterPermittedAsync</c> keeps them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Web-first only: each page-transition assertion checks the row COUNT first
|
||||
/// (which waits for the keyset fetch to render) BEFORE the pager button
|
||||
/// states, because the <c>_loading</c> flag also disables both buttons
|
||||
/// mid-fetch — reading button state before the fetch settles would race.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Pagination_KeysetNextAndPrev_TraversesPages()
|
||||
{
|
||||
Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var prefix = $"playwright-test/wave4-scpage/{runId}/";
|
||||
// All 51 rows share ONE identical exact target — a single keyword selects
|
||||
// the whole set, which then spans the 50-row page boundary.
|
||||
var sharedTarget = prefix + "row";
|
||||
|
||||
try
|
||||
{
|
||||
// 51 rows, identical target, distinct TrackedOperationId, all site-a /
|
||||
// Delivered, timestamps staggered by index so the keyset order
|
||||
// (CreatedAtUtc DESC, TrackedOperationId DESC) is strict.
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 51; i++)
|
||||
{
|
||||
var ts = now.AddSeconds(-i);
|
||||
await SiteCallDataSeeder.InsertSiteCallAsync(
|
||||
trackedOperationId: Guid.NewGuid(), channel: "ApiOutbound", target: sharedTarget,
|
||||
sourceSite: "site-a", status: "Delivered", retryCount: 0,
|
||||
createdAtUtc: ts, updatedAtUtc: ts);
|
||||
}
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await SetSearchKeywordAsync(page, sharedTarget);
|
||||
await page.ClickAsync("[data-test='site-calls-query']");
|
||||
|
||||
// The pager indicator span (`Page {N} · {rows} rows`). It is the only
|
||||
// text-muted small span in the table footer, so a scoped GetByText
|
||||
// regex is unambiguous.
|
||||
var pageIndicator = page.Locator("span.text-muted.small");
|
||||
|
||||
// ── Page 1: full page (50 rows). Assert COUNT first (waits for the
|
||||
// fetch), then the indicator and the button states. ──
|
||||
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50);
|
||||
await Assertions.Expect(pageIndicator).ToContainTextAsync("Page 1");
|
||||
await Assertions.Expect(page.Locator("[data-test='site-calls-prev']")).ToBeDisabledAsync();
|
||||
await Assertions.Expect(page.Locator("[data-test='site-calls-next']")).ToBeEnabledAsync();
|
||||
|
||||
// ── Next → Page 2: short page (1 row). Last page, so Next disables. ──
|
||||
await page.ClickAsync("[data-test='site-calls-next']");
|
||||
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(1);
|
||||
await Assertions.Expect(pageIndicator).ToContainTextAsync("Page 2");
|
||||
await Assertions.Expect(page.Locator("[data-test='site-calls-prev']")).ToBeEnabledAsync();
|
||||
await Assertions.Expect(page.Locator("[data-test='site-calls-next']")).ToBeDisabledAsync();
|
||||
|
||||
// ── Previous → back on Page 1: full page again, Prev disables. ──
|
||||
await page.ClickAsync("[data-test='site-calls-prev']");
|
||||
await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50);
|
||||
await Assertions.Expect(pageIndicator).ToContainTextAsync("Page 1");
|
||||
await Assertions.Expect(page.Locator("[data-test='site-calls-prev']")).ToBeDisabledAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SiteCallDataSeeder.DeleteByTargetPrefixAsync(prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,115 @@ public class SiteCrudTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creating a site whose Identifier collides with the always-present seeded
|
||||
/// <c>site-a</c> must fail at save and persist nothing. SiteForm.SaveSite()
|
||||
/// catches the duplicate DbUpdateException → <c>_formError = "Save failed: …"</c>
|
||||
/// (a raw EF message) and stays on /create. The Name is deliberately distinct so
|
||||
/// only the SiteIdentifier unique index can trip (not the Name unique index).
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Create_DuplicateIdentifier_ShowsSaveFailedError()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
// Confirm the collision target exists (site-a is a core cluster seed; throws if absent).
|
||||
await CliRunner.ResolveSiteIdAsync("site-a");
|
||||
|
||||
// Distinct name so the failure can only be the SiteIdentifier unique index.
|
||||
var distinctName = $"zztest-dup-{Guid.NewGuid():N}"[..16];
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Identifier collides with the seeded site-a; Name is distinct. Node addresses blank.
|
||||
await page.Locator("label:has-text('Identifier') + input.form-control.form-control-sm").FillAsync("site-a");
|
||||
await page.Locator("label:has-text('Name') + input.form-control.form-control-sm").FillAsync(distinctName);
|
||||
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
|
||||
// The inline error surface must report the failed save and we must stay on /create.
|
||||
// The full message is a raw DbUpdateException — assert only the "Save failed" prefix.
|
||||
await Expect(page.Locator("div.text-danger.small.mt-2")).ToContainTextAsync("Save failed");
|
||||
await Assertions.Expect(page)
|
||||
.ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/admin/sites/create"));
|
||||
|
||||
// No teardown: the SiteIdentifier unique index guarantees the create persisted nothing.
|
||||
// A spuriously-persisted row would carry identifier "site-a" (not distinctName), and that
|
||||
// cannot be auto-deleted without destroying the real seed — so there is nothing safe to sweep.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Editing a site and clicking Cancel must discard the change. SiteForm's Cancel
|
||||
/// (like Back) calls GoBack() → /admin/sites with NO dirty-check and NO persistence,
|
||||
/// so re-opening the edit must show the ORIGINAL (empty) Description.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task EditCancel_DiscardsChanges()
|
||||
{
|
||||
Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
|
||||
|
||||
var hex = Guid.NewGuid().ToString("N")[..8];
|
||||
var ident = $"zztest-{hex}";
|
||||
var name = $"zztest-site-{hex}";
|
||||
|
||||
var page = await _fixture.NewAuthenticatedPageAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// ── CREATE (mirror the round-trip test's create steps) ──────────────────────
|
||||
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/sites/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await page.Locator("label:has-text('Identifier') + input.form-control.form-control-sm").FillAsync(ident);
|
||||
await page.Locator("label:has-text('Name') + input.form-control.form-control-sm").FillAsync(name);
|
||||
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// ── EDIT → change Description → CANCEL ──────────────────────────────────────
|
||||
var card = page.Locator("div.card", new() { HasText = name });
|
||||
await Assertions.Expect(card).ToBeVisibleAsync();
|
||||
await card.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/edit", excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
await page.Locator("label:has-text('Description') + input.form-control.form-control-sm")
|
||||
.FillAsync("zztest-CANCELLED-EDIT");
|
||||
|
||||
await page.ClickAsync("button:has-text('Cancel')");
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/edit");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// ── RE-OPEN EDIT → Description must be the ORIGINAL (empty) ──────────────────
|
||||
var cardAfterCancel = page.Locator("div.card", new() { HasText = name });
|
||||
await Assertions.Expect(cardAfterCancel).ToBeVisibleAsync();
|
||||
await cardAfterCancel.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/edit", excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
var descInput = page.Locator("label:has-text('Description') + input.form-control.form-control-sm");
|
||||
await Expect(descInput).ToHaveValueAsync("");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Best-effort teardown: delete the site via CLI.
|
||||
try
|
||||
{
|
||||
await CliRunner.DeleteSiteAsync(await CliRunner.ResolveSiteIdAsync(ident));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Already gone or cluster unreachable — ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ILocatorAssertions Expect(ILocator locator) =>
|
||||
Assertions.Expect(locator);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user