diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs
index 71bc017f..7628fe41 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Admin/LdapMappingCrudTests.cs
@@ -123,4 +123,97 @@ public class LdapMappingCrudTests
}
}
}
+
+ ///
+ /// The create form's SaveMapping() validates manually (no EditForm /
+ /// DataAnnotations): a blank LDAP Group Name renders "LDAP Group Name is
+ /// required." in div.text-danger.small.mt-2 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.
+ ///
+ [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.
+ }
+
+ ///
+ /// Attempting to create a mapping whose LDAP group already exists violates the DB
+ /// unique index on LdapGroupName. The form has no friendly pre-check, so
+ /// the persistence exception is caught and surfaced as "Save failed: {message}"
+ /// in div.text-danger.small.mt-2, 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.
+ ///
+ [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);
+ }
+ }
}