diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCrudTests.cs index 41befbc9..d794f012 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCrudTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/SiteCrudTests.cs @@ -195,6 +195,127 @@ public class SiteCrudTests } } + /// + /// Creating a site whose Identifier collides with the always-present seeded + /// site-a must fail at save and persist nothing. SiteForm.SaveSite() + /// catches the duplicate DbUpdateException → _formError = "Save failed: …" + /// (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). + /// + [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(); + + try + { + 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")); + } + finally + { + // Defensive: the failed create persists nothing, but if a stray site by the + // distinct name somehow exists, best-effort delete it (swallow not-found). + try + { + await CliRunner.DeleteSiteAsync(await CliRunner.ResolveSiteIdAsync(distinctName)); + } + catch + { + // Nothing persisted (expected) or cluster unreachable — ignore. + } + } + } + + /// + /// 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. + /// + [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); }