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);
}