using Microsoft.Playwright; using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster; using Xunit; namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit; /// /// End-to-end coverage for the Configuration Audit Log page /// (/audit/configuration, OperationalAudit policy — the /// multi-role test user carries Admin + Viewer so it can reach the page). /// The page reads the central AuditLogEntries table; each fact seeds its /// own rows via under a unique per-test /// marker (stamped as EntityType), filters the grid by that marker /// so the (N total) pagination footer is deterministic regardless of the /// other rows the live cluster produces, then best-effort deletes the marker's /// rows in finally. /// /// /// This part (Wave 3, Task 10) covers render / search-narrows / pagination. The /// class is intentionally structured so Task 11 can APPEND modal/copy/chip facts /// to it without restructuring: shared constants (the DB-unavailable skip reason, /// the page route) and the seeded-test gate idiom live here once. /// /// /// /// The seeded tests are + Skip.IfNot: /// when the cluster / MSSQL is unreachable they report as Skipped (not Failed), /// matching the established idiom. /// /// [Collection("Playwright")] public class AuditConfigurationTests { /// /// Route to the Configuration Audit Log page. The results grid only renders /// after a Search (a bare visit, without a ?bundleImportId= query param, /// does not auto-fetch), so each fact issues a marker-scoped Search. /// private const string ConfigAuditUrl = "/audit/configuration"; /// Skip reason shared by the DB-seeding tests when MSSQL is down. private const string DbUnavailableSkipReason = "AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " + "or set SCADABRIDGE_PLAYWRIGHT_DB to a reachable connection string."; private readonly PlaywrightFixture _fixture; public AuditConfigurationTests(PlaywrightFixture fixture) { _fixture = fixture; } /// /// Generates a fresh per-test marker: a zzCfgAudit- prefix plus a short /// GUID slice. Stamped as EntityType on every seeded row so a UI filter /// on Entity Type isolates exactly this run's rows (deterministic totals) and /// the finally cleanup never touches cluster-produced rows. /// private static string NewMarker() => "zzCfgAudit-" + Guid.NewGuid().ToString("N")[..8]; [SkippableFact] public async Task Grid_RendersAndSearchNarrows() { Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); var marker = NewMarker(); try { // 3 rows, all EntityType = marker, no bundle rows → exactly 3 match. await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 3, bundleRows: 0); var page = await _fixture.NewAuthenticatedPageAsync(); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); // The page renders its heading and filter controls. The results table // (the whole @if (_entries != null) block) does NOT render until a // Search is issued — the page only auto-fetches when arriving with a // ?bundleImportId= query param, which a bare visit lacks — so the // Entity Type filter is present and ready to drive the first query. await Assertions.Expect(page.Locator("h4:has-text('Configuration Audit Log')")).ToBeVisibleAsync(); await Assertions.Expect(page.Locator("#audit-filter-entity-type")).ToBeVisibleAsync(); // Filter by the unique marker so only this run's 3 rows remain, then // Search to load the grid. await page.Locator("#audit-filter-entity-type").FillAsync(marker); await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); // Seeded rows are isolated: a known row is visible AND the footer total // is exactly 3 (filtering by the unique marker makes this deterministic // regardless of the other rows the live cluster holds). await Assertions.Expect(page.Locator($"tr:has-text('{marker}-0')")).ToBeVisibleAsync(); await Assertions.Expect(page.Locator("span:has-text('(3 total)')")).ToBeVisibleAsync(); } finally { await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker); } } [SkippableFact] public async Task Pagination_PrevDisabledOnPage1_NextPaginates() { Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); var marker = NewMarker(); try { // 55 rows → 2 pages at 50/page. await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 55, bundleRows: 0); var page = await _fixture.NewAuthenticatedPageAsync(); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); // Filter by the marker so the totals/page-count reflect only this run. await page.Locator("#audit-filter-entity-type").FillAsync(marker); await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); // Page 1 of 2: Previous disabled, Next enabled. await Assertions.Expect(page.Locator("span:has-text('Page 1 of 2')")).ToBeVisibleAsync(); await Assertions.Expect(page.Locator("span:has-text('(55 total)')")).ToBeVisibleAsync(); await Assertions.Expect(page.Locator("button.btn-outline-secondary.btn-sm:has-text('Previous')")).ToBeDisabledAsync(); await Assertions.Expect(page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')")).ToBeEnabledAsync(); // Advance to the last page: Previous enabled, Next disabled. await page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await Assertions.Expect(page.Locator("span:has-text('Page 2 of 2')")).ToBeVisibleAsync(); await Assertions.Expect(page.Locator("button.btn-outline-secondary.btn-sm:has-text('Previous')")).ToBeEnabledAsync(); await Assertions.Expect(page.Locator("button.btn-outline-secondary.btn-sm:has-text('Next')")).ToBeDisabledAsync(); } finally { await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker); } } [SkippableFact] public async Task LargeState_OpensAndClosesModal() { Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); var marker = NewMarker(); try { // 1 bulk row, no bundle rows. The seeder gives bulk row index 0 a // > 1024-char AfterStateJson, so its State cell renders the large-state // "View in modal" button (small-state rows show an inline View/Hide // toggle instead, which we are not exercising here). await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 1, bundleRows: 0); var page = await _fixture.NewAuthenticatedPageAsync(); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); // Filter by the unique marker and Search so only this run's single // large-state row populates the grid. await page.Locator("#audit-filter-entity-type").FillAsync(marker); await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); // Open the modal from the seeded large-state row. The marker is unique, // so a page-wide "View in modal" locator resolves to exactly this row. await page.Locator("button.btn-outline-info.btn-sm:has-text('View in modal')").ClickAsync(); // The modal renders with a title like "Audit entry {id} — {EntityType} state". await Assertions.Expect(page.Locator("div.modal.show .modal-title")).ToBeVisibleAsync(); await Assertions.Expect(page.Locator("div.modal.show .modal-title")).ToContainTextAsync("state"); // Closing via the footer Close button removes the modal from the DOM. await page.Locator(".modal.show button.btn-outline-secondary.btn-sm:has-text('Close')").ClickAsync(); await Assertions.Expect(page.Locator("div.modal.show")).ToHaveCountAsync(0, new() { Timeout = 10_000 }); } finally { await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker); } } [SkippableFact] public async Task CopyEntityId_ShowsToast() { Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); var marker = NewMarker(); try { // 1 bulk row (non-empty EntityId) so the row renders the 📋 copy button. await ConfigAuditDataSeeder.SeedAsync(marker, Guid.NewGuid(), bulkCount: 1, bundleRows: 0); var page = await _fixture.NewAuthenticatedPageAsync(); await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{ConfigAuditUrl}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await page.Locator("#audit-filter-entity-type").FillAsync(marker); await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); // Click the copy-entity-ID button on the (only) seeded row. await page.Locator("button[aria-label^='Copy entity ID']").First.ClickAsync(); // Assert a toast appears, TOLERANTLY of which one. The cluster is served // over a non-secure, non-localhost http origin (http://scadabridge-traefik), // so navigator.clipboard is unavailable: CopyAsync's writeText throws and // the handler shows the ERROR toast "Copy failed." instead of the success // toast "Copied to clipboard.". Either path produces exactly one .toast, so // we assert the count only and deliberately do NOT assert the message text. await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 }); } finally { await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker); } } [SkippableFact] public async Task BundleImportId_DrillIn_ChipFiltersAndClears() { Skip.IfNot(await ConfigAuditDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason); var marker = NewMarker(); var bundleId = Guid.NewGuid(); try { // 1 bulk row (EntityName "{marker}-0", BundleImportId NULL) + 2 bundle // rows (EntityName "{marker}-bundle-0/1", BundleImportId = bundleId), all // EntityType = marker so the finally cleanup removes every row. await ConfigAuditDataSeeder.SeedAsync(marker, bundleId, bulkCount: 1, bundleRows: 2); var page = await _fixture.NewAuthenticatedPageAsync(); // Arriving WITH a ?bundleImportId= query param auto-loads the grid // pre-filtered (OnParametersSetAsync fires FetchPage because the param // differs from the initial null) — no Search click needed. await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/configuration?bundleImportId={bundleId}"); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); // The filter chip is shown for the active bundle import. await Assertions.Expect(page.Locator("span.badge.bg-primary:has-text('Filtered by Bundle Import:')")).ToBeVisibleAsync(); // Exactly the 2 bundle rows show (EntityName "{marker}-bundle-0/1"). The // bulk row (EntityName "{marker}-0", BundleImportId NULL) is filtered out. // The "{marker}-0" token is NOT a substring of "{marker}-bundle-0", so the // two locators distinguish the bundle rows from the bulk row unambiguously. await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = $"{marker}-bundle-" })) .ToHaveCountAsync(2, new() { Timeout = 10_000 }); await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = $"{marker}-0" })) .ToHaveCountAsync(0); // Clearing the filter removes the chip and navigates to the bare route; // because BundleImportId goes from the guid back to null, the page // re-fetches and reloads the grid with all entries (chip gone, rows ≥ 1). await page.Locator("button[aria-label='Clear Bundle Import filter']").ClickAsync(); await page.WaitForLoadStateAsync(LoadState.NetworkIdle); await Assertions.Expect(page.Locator("span.badge.bg-primary:has-text('Filtered by Bundle Import:')")).ToHaveCountAsync(0); await Assertions.Expect(page.Locator("tbody tr").First).ToBeVisibleAsync(); } finally { await ConfigAuditDataSeeder.DeleteByMarkerAsync(marker); } } }