Skip to content

Commit cdca011

Browse files
committed
WEB-1029: [Playwright] Add create-client form-validation E2E spec
1 parent cb8ce1b commit cdca011

5 files changed

Lines changed: 456 additions & 115 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
1717
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
1818
"start": "npm run env -s && ng serve --proxy-config proxy.conf.js",
19+
"start:local": "npm run env -s && ng serve --proxy-config proxy.localhost.conf.js",
1920
"serve:dev": "npm run env -s && ng serve --source-map",
2021
"serve:sw": "npm run build -s && npx http-server ./dist -p 4200",
2122
"lint": "eslint . && stylelint \"src/**/*.scss\" && prettier . --check && htmlhint \"src\" --config .htmlhintrc",

playwright.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ export default defineConfig({
226226
webServer: process.env.CI
227227
? undefined
228228
: {
229-
command: 'npm run start',
229+
command: 'npm run start:local',
230230
url: 'http://localhost:4200',
231231
reuseExistingServer: true,
232232
timeout: 180000
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
/**
2+
* Copyright since 2026 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
import { test, expect } from '../../fixtures/test-fixtures';
10+
import { CreateClientPage } from '../../pages';
11+
import { BEHAVIOR } from '../../config/behavior';
12+
13+
/**
14+
* Create-Client form-validation E2E spec (WEB-1029).
15+
*
16+
* Coverage:
17+
* 1. Required-field gate — each required General-step field, when
18+
* left blank (or cleared after being touched), prevents the wizard
19+
* from exposing the Preview step and surfaces a mat-error message.
20+
*
21+
* 2. Email format — an invalid email string triggers a format-level
22+
* validator in addition to the required-field validator.
23+
*
24+
* 3. Legal-form toggle — switching between PERSON and ENTITY tears
25+
* down the previous name-control subtree and builds the new one,
26+
* and switching back fully restores the PERSON controls to a state
27+
* where the form can become valid.
28+
*
29+
* No clients are created server-side by this spec; every test exits
30+
* before the stepper reaches the Preview-submit step, so no teardown
31+
* is necessary.
32+
*/
33+
34+
/**
35+
* The Fineract default-tenant Liquibase seed always inserts
36+
* `Head Office` as the first office. We rely on that invariant to keep
37+
* this spec self-contained (no API call needed to look up the office
38+
* name before filling the form).
39+
*/
40+
const SEEDED_HEAD_OFFICE = 'Head Office';
41+
42+
/** Date string that satisfies the Angular `DD MMMM YYYY` format. */
43+
const SUBMITTED_ON_DATE = '01 January 2024';
44+
45+
test.describe('Create Client — form validation', () => {
46+
test.beforeEach(async ({ page }) => {
47+
// Copy localStorage credentials into sessionStorage so the Angular
48+
// app can read its auth token after each page load (mirrors the
49+
// pattern used in create-client.spec.ts and close-client.spec.ts).
50+
await page.addInitScript((storageKey) => {
51+
const creds = localStorage.getItem(storageKey);
52+
if (creds) {
53+
sessionStorage.setItem(storageKey, creds);
54+
}
55+
}, BEHAVIOR.authStorageKey);
56+
});
57+
58+
// ── Required-field gate ──────────────────────────────────────────────
59+
60+
test('hides the Preview step when the office field is left empty', async ({ page }) => {
61+
const createClientPage = new CreateClientPage(page);
62+
63+
await createClientPage.navigate();
64+
await createClientPage.waitForLoad();
65+
66+
// Fill every required field except office so only that omission
67+
// keeps the form invalid.
68+
await createClientPage.legalFormDropdown.click();
69+
await page.getByRole('option', { name: 'Person', exact: false }).first().click();
70+
71+
await createClientPage.firstnameInput.waitFor({ state: 'visible', timeout: 10_000 });
72+
await createClientPage.firstnameInput.fill('ValidFirst');
73+
await createClientPage.lastnameInput.fill('ValidLast');
74+
await createClientPage.submittedOnDateInput.fill(SUBMITTED_ON_DATE);
75+
await createClientPage.submittedOnDateInput.blur();
76+
77+
// Touch the office dropdown without selecting a value so the
78+
// `required` validator marks the control as dirty + invalid.
79+
await createClientPage.officeDropdown.click();
80+
await page.keyboard.press('Escape');
81+
82+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(0);
83+
await expect(createClientPage.previewSubmitButton).toHaveCount(0);
84+
85+
// Recovery: selecting an office re-exposes the Preview step.
86+
await createClientPage.officeDropdown.click();
87+
await page.getByRole('option', { name: SEEDED_HEAD_OFFICE, exact: false }).first().click();
88+
89+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1);
90+
});
91+
92+
test('hides the Preview step when firstname is blank (PERSON legal form)', async ({ page }) => {
93+
const createClientPage = new CreateClientPage(page);
94+
95+
await createClientPage.navigate();
96+
await createClientPage.waitForLoad();
97+
98+
await createClientPage.officeDropdown.click();
99+
await page.getByRole('option', { name: SEEDED_HEAD_OFFICE, exact: false }).first().click();
100+
101+
await createClientPage.legalFormDropdown.click();
102+
await page.getByRole('option', { name: 'Person', exact: false }).first().click();
103+
104+
await createClientPage.firstnameInput.waitFor({ state: 'visible', timeout: 10_000 });
105+
106+
// Touch + blur without filling — fires the required validator.
107+
await createClientPage.firstnameInput.click();
108+
await createClientPage.firstnameInput.blur();
109+
110+
await createClientPage.lastnameInput.fill('ValidLast');
111+
await createClientPage.submittedOnDateInput.fill(SUBMITTED_ON_DATE);
112+
await createClientPage.submittedOnDateInput.blur();
113+
114+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(0);
115+
await expect(createClientPage.validationErrors.first()).toBeVisible();
116+
117+
// Recovery: filling firstname re-exposes the Preview step.
118+
await createClientPage.firstnameInput.fill('ValidFirst');
119+
await createClientPage.firstnameInput.blur();
120+
121+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1);
122+
});
123+
124+
test('hides the Preview step when lastname is blank (PERSON legal form)', async ({ page }) => {
125+
const createClientPage = new CreateClientPage(page);
126+
127+
await createClientPage.navigate();
128+
await createClientPage.waitForLoad();
129+
130+
await createClientPage.officeDropdown.click();
131+
await page.getByRole('option', { name: SEEDED_HEAD_OFFICE, exact: false }).first().click();
132+
133+
await createClientPage.legalFormDropdown.click();
134+
await page.getByRole('option', { name: 'Person', exact: false }).first().click();
135+
136+
await createClientPage.firstnameInput.waitFor({ state: 'visible', timeout: 10_000 });
137+
await createClientPage.firstnameInput.fill('ValidFirst');
138+
139+
// Touch + blur lastname without filling — fires the required validator.
140+
await createClientPage.lastnameInput.click();
141+
await createClientPage.lastnameInput.blur();
142+
143+
await createClientPage.submittedOnDateInput.fill(SUBMITTED_ON_DATE);
144+
await createClientPage.submittedOnDateInput.blur();
145+
146+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(0);
147+
await expect(createClientPage.validationErrors.first()).toBeVisible();
148+
149+
// Recovery: filling lastname re-exposes the Preview step.
150+
await createClientPage.lastnameInput.fill('ValidLast');
151+
await createClientPage.lastnameInput.blur();
152+
153+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1);
154+
});
155+
156+
test('hides the Preview step when fullname is blank (ENTITY legal form)', async ({ page }) => {
157+
const createClientPage = new CreateClientPage(page);
158+
159+
await createClientPage.navigate();
160+
await createClientPage.waitForLoad();
161+
162+
await createClientPage.officeDropdown.click();
163+
await page.getByRole('option', { name: SEEDED_HEAD_OFFICE, exact: false }).first().click();
164+
165+
await createClientPage.legalFormDropdown.click();
166+
await page.getByRole('option', { name: 'Entity', exact: false }).first().click();
167+
168+
await createClientPage.fullnameInput.waitFor({ state: 'visible', timeout: 10_000 });
169+
170+
// Touch + blur fullname without filling — fires the required validator.
171+
await createClientPage.fullnameInput.click();
172+
await createClientPage.fullnameInput.blur();
173+
174+
await createClientPage.submittedOnDateInput.fill(SUBMITTED_ON_DATE);
175+
await createClientPage.submittedOnDateInput.blur();
176+
177+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(0);
178+
await expect(createClientPage.validationErrors.first()).toBeVisible();
179+
180+
// Verify that just filling fullname alone still keeps Preview hidden
181+
// when constitutionId is also required (ENTITY form has multiple
182+
// required fields). This asserts the form truly validates fullname.
183+
await createClientPage.fullnameInput.fill('Valid Entity Name');
184+
await createClientPage.fullnameInput.blur();
185+
186+
// The ENTITY form also requires constitutionId. If the dropdown has
187+
// seeded options we select one; otherwise just assert that the form
188+
// correctly blocks Preview when only fullname is filled (constitutionId
189+
// still empty).
190+
const constitutionDropdown = page.locator('mat-select[formcontrolname="constitutionId"]');
191+
if (await constitutionDropdown.isVisible()) {
192+
await constitutionDropdown.click();
193+
const hasOptions = await page
194+
.locator('mat-option')
195+
.first()
196+
.isVisible({ timeout: 3000 })
197+
.catch(() => false);
198+
if (hasOptions) {
199+
await page.locator('mat-option').first().click();
200+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1, { timeout: 10_000 });
201+
} else {
202+
// No constitution options seeded — press Escape and verify Preview
203+
// is still blocked (constitutionId is required but has no values).
204+
await page.keyboard.press('Escape');
205+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(0);
206+
}
207+
} else {
208+
// No constitutionId field — fullname alone should suffice.
209+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1, { timeout: 10_000 });
210+
}
211+
});
212+
213+
test('hides the Preview step when the submitted-on date is absent', async ({ page }) => {
214+
const createClientPage = new CreateClientPage(page);
215+
216+
await createClientPage.navigate();
217+
await createClientPage.waitForLoad();
218+
219+
await createClientPage.officeDropdown.click();
220+
await page.getByRole('option', { name: SEEDED_HEAD_OFFICE, exact: false }).first().click();
221+
222+
await createClientPage.legalFormDropdown.click();
223+
await page.getByRole('option', { name: 'Person', exact: false }).first().click();
224+
225+
await createClientPage.firstnameInput.waitFor({ state: 'visible', timeout: 10_000 });
226+
await createClientPage.firstnameInput.fill('ValidFirst');
227+
await createClientPage.lastnameInput.fill('ValidLast');
228+
229+
// Clear the submitted-on date (the form pre-fills today's date).
230+
await createClientPage.submittedOnDateInput.fill('');
231+
await createClientPage.submittedOnDateInput.blur();
232+
233+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(0);
234+
235+
// Recovery: filling a valid date re-exposes the Preview step.
236+
await createClientPage.submittedOnDateInput.fill(SUBMITTED_ON_DATE);
237+
await createClientPage.submittedOnDateInput.blur();
238+
239+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1);
240+
});
241+
242+
// ── Email format ─────────────────────────────────────────────────────
243+
244+
test('surfaces a validation error when the email field contains a malformed address', async ({ page }) => {
245+
const createClientPage = new CreateClientPage(page);
246+
247+
await createClientPage.navigate();
248+
await createClientPage.waitForLoad();
249+
250+
await createClientPage.officeDropdown.click();
251+
await page.getByRole('option', { name: SEEDED_HEAD_OFFICE, exact: false }).first().click();
252+
253+
await createClientPage.legalFormDropdown.click();
254+
await page.getByRole('option', { name: 'Person', exact: false }).first().click();
255+
256+
await createClientPage.firstnameInput.waitFor({ state: 'visible', timeout: 10_000 });
257+
await createClientPage.firstnameInput.fill('ValidFirst');
258+
await createClientPage.lastnameInput.fill('ValidLast');
259+
await createClientPage.submittedOnDateInput.fill(SUBMITTED_ON_DATE);
260+
await createClientPage.submittedOnDateInput.blur();
261+
262+
// Type a syntactically invalid email and blur to trigger the validator.
263+
await createClientPage.emailInput.fill('not-an-email');
264+
await createClientPage.emailInput.blur();
265+
266+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(0);
267+
await expect(createClientPage.validationErrors.first()).toBeVisible();
268+
269+
// Recovery: a well-formed email address clears the error.
270+
await createClientPage.emailInput.fill('valid@example.com');
271+
await createClientPage.emailInput.blur();
272+
273+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1);
274+
});
275+
276+
// ── Legal-form toggle ────────────────────────────────────────────────
277+
278+
test('switches the name-control subtree when the legal form toggles between Person and Entity', async ({ page }) => {
279+
// The legalForm switch is a pure client-side re-render. The office
280+
// is filled with the seeded default so the form reaches a valid
281+
// baseline for the final round-trip assertion.
282+
const officeName = SEEDED_HEAD_OFFICE;
283+
const createClientPage = new CreateClientPage(page);
284+
285+
await createClientPage.navigate();
286+
await createClientPage.waitForLoad();
287+
288+
await createClientPage.officeDropdown.click();
289+
await page.getByRole('option', { name: officeName, exact: false }).first().click();
290+
291+
// Default legal form is PERSON (`setClientForm()` patches
292+
// `LegalFormId.PERSON` on init). PERSON should show first/last
293+
// name controls; ENTITY-only controls must be absent.
294+
await expect(createClientPage.firstnameInput).toBeVisible();
295+
await expect(createClientPage.lastnameInput).toBeVisible();
296+
await expect(createClientPage.fullnameInput).toHaveCount(0);
297+
298+
// Switch to ENTITY. `buildDependencies()` removes firstname /
299+
// middlename / lastname and adds fullname + a
300+
// `clientNonPersonDetails` FormGroup containing a required
301+
// `constitutionId`.
302+
await createClientPage.legalFormDropdown.click();
303+
await page.getByRole('option', { name: 'Entity', exact: false }).first().click();
304+
305+
await createClientPage.fullnameInput.waitFor({ state: 'visible', timeout: 10_000 });
306+
await expect(createClientPage.fullnameInput).toBeVisible();
307+
await expect(createClientPage.firstnameInput).toHaveCount(0);
308+
await expect(createClientPage.lastnameInput).toHaveCount(0);
309+
await expect(createClientPage.middlenameInput).toHaveCount(0);
310+
311+
// The clientNonPersonDetails subtree contains a `constitutionId`
312+
// mat-select that only exists in ENTITY mode. Assert it renders
313+
// so a regression that stops swapping the subtree is caught here.
314+
const constitutionDropdown = page.locator('mat-select[formcontrolname="constitutionId"]');
315+
await expect(constitutionDropdown).toBeVisible();
316+
317+
// Switch back to PERSON — the name-control subtree must return
318+
// and the ENTITY-only controls must be torn down.
319+
await createClientPage.legalFormDropdown.click();
320+
await page.getByRole('option', { name: 'Person', exact: false }).first().click();
321+
322+
await createClientPage.firstnameInput.waitFor({ state: 'visible', timeout: 10_000 });
323+
await expect(createClientPage.firstnameInput).toBeVisible();
324+
await expect(createClientPage.lastnameInput).toBeVisible();
325+
await expect(createClientPage.fullnameInput).toHaveCount(0);
326+
await expect(constitutionDropdown).toHaveCount(0);
327+
328+
// Round-trip assertion: fill the restored PERSON name controls and
329+
// a submitted-on date so the form reaches a genuinely valid state.
330+
// This verifies that switching back to PERSON not only renders the
331+
// correct DOM subtree but also fully re-wires the reactive form
332+
// (validators active, form-valid flag flipped) — a regression where
333+
// the subtree renders but the FormGroup stays INVALID would be
334+
// caught here by the absence of the Preview step header.
335+
await createClientPage.firstnameInput.fill('RoundTrip');
336+
await createClientPage.lastnameInput.fill('Client');
337+
await createClientPage.submittedOnDateInput.fill(SUBMITTED_ON_DATE);
338+
await createClientPage.submittedOnDateInput.blur();
339+
340+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1);
341+
});
342+
});

0 commit comments

Comments
 (0)