Skip to content

Commit a8e8ab8

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

5 files changed

Lines changed: 452 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
@@ -223,7 +223,7 @@ export default defineConfig({
223223
webServer: process.env.CI
224224
? undefined
225225
: {
226-
command: 'npm run start',
226+
command: 'npm run start:local',
227227
url: 'http://localhost:4200',
228228
reuseExistingServer: true,
229229
timeout: 180000
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
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.locator('mat-option').first().isVisible({ timeout: 3000 }).catch(() => false);
194+
if (hasOptions) {
195+
await page.locator('mat-option').first().click();
196+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1, { timeout: 10_000 });
197+
} else {
198+
// No constitution options seeded — press Escape and verify Preview
199+
// is still blocked (constitutionId is required but has no values).
200+
await page.keyboard.press('Escape');
201+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(0);
202+
}
203+
} else {
204+
// No constitutionId field — fullname alone should suffice.
205+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1, { timeout: 10_000 });
206+
}
207+
});
208+
209+
test('hides the Preview step when the submitted-on date is absent', async ({ page }) => {
210+
const createClientPage = new CreateClientPage(page);
211+
212+
await createClientPage.navigate();
213+
await createClientPage.waitForLoad();
214+
215+
await createClientPage.officeDropdown.click();
216+
await page.getByRole('option', { name: SEEDED_HEAD_OFFICE, exact: false }).first().click();
217+
218+
await createClientPage.legalFormDropdown.click();
219+
await page.getByRole('option', { name: 'Person', exact: false }).first().click();
220+
221+
await createClientPage.firstnameInput.waitFor({ state: 'visible', timeout: 10_000 });
222+
await createClientPage.firstnameInput.fill('ValidFirst');
223+
await createClientPage.lastnameInput.fill('ValidLast');
224+
225+
// Clear the submitted-on date (the form pre-fills today's date).
226+
await createClientPage.submittedOnDateInput.fill('');
227+
await createClientPage.submittedOnDateInput.blur();
228+
229+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(0);
230+
231+
// Recovery: filling a valid date re-exposes the Preview step.
232+
await createClientPage.submittedOnDateInput.fill(SUBMITTED_ON_DATE);
233+
await createClientPage.submittedOnDateInput.blur();
234+
235+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1);
236+
});
237+
238+
// ── Email format ─────────────────────────────────────────────────────
239+
240+
test('surfaces a validation error when the email field contains a malformed address', async ({ page }) => {
241+
const createClientPage = new CreateClientPage(page);
242+
243+
await createClientPage.navigate();
244+
await createClientPage.waitForLoad();
245+
246+
await createClientPage.officeDropdown.click();
247+
await page.getByRole('option', { name: SEEDED_HEAD_OFFICE, exact: false }).first().click();
248+
249+
await createClientPage.legalFormDropdown.click();
250+
await page.getByRole('option', { name: 'Person', exact: false }).first().click();
251+
252+
await createClientPage.firstnameInput.waitFor({ state: 'visible', timeout: 10_000 });
253+
await createClientPage.firstnameInput.fill('ValidFirst');
254+
await createClientPage.lastnameInput.fill('ValidLast');
255+
await createClientPage.submittedOnDateInput.fill(SUBMITTED_ON_DATE);
256+
await createClientPage.submittedOnDateInput.blur();
257+
258+
// Type a syntactically invalid email and blur to trigger the validator.
259+
await createClientPage.emailInput.fill('not-an-email');
260+
await createClientPage.emailInput.blur();
261+
262+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(0);
263+
await expect(createClientPage.validationErrors.first()).toBeVisible();
264+
265+
// Recovery: a well-formed email address clears the error.
266+
await createClientPage.emailInput.fill('valid@example.com');
267+
await createClientPage.emailInput.blur();
268+
269+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1);
270+
});
271+
272+
// ── Legal-form toggle ────────────────────────────────────────────────
273+
274+
test('switches the name-control subtree when the legal form toggles between Person and Entity', async ({ page }) => {
275+
// The legalForm switch is a pure client-side re-render. The office
276+
// is filled with the seeded default so the form reaches a valid
277+
// baseline for the final round-trip assertion.
278+
const officeName = SEEDED_HEAD_OFFICE;
279+
const createClientPage = new CreateClientPage(page);
280+
281+
await createClientPage.navigate();
282+
await createClientPage.waitForLoad();
283+
284+
await createClientPage.officeDropdown.click();
285+
await page.getByRole('option', { name: officeName, exact: false }).first().click();
286+
287+
// Default legal form is PERSON (`setClientForm()` patches
288+
// `LegalFormId.PERSON` on init). PERSON should show first/last
289+
// name controls; ENTITY-only controls must be absent.
290+
await expect(createClientPage.firstnameInput).toBeVisible();
291+
await expect(createClientPage.lastnameInput).toBeVisible();
292+
await expect(createClientPage.fullnameInput).toHaveCount(0);
293+
294+
// Switch to ENTITY. `buildDependencies()` removes firstname /
295+
// middlename / lastname and adds fullname + a
296+
// `clientNonPersonDetails` FormGroup containing a required
297+
// `constitutionId`.
298+
await createClientPage.legalFormDropdown.click();
299+
await page.getByRole('option', { name: 'Entity', exact: false }).first().click();
300+
301+
await createClientPage.fullnameInput.waitFor({ state: 'visible', timeout: 10_000 });
302+
await expect(createClientPage.fullnameInput).toBeVisible();
303+
await expect(createClientPage.firstnameInput).toHaveCount(0);
304+
await expect(createClientPage.lastnameInput).toHaveCount(0);
305+
await expect(createClientPage.middlenameInput).toHaveCount(0);
306+
307+
// The clientNonPersonDetails subtree contains a `constitutionId`
308+
// mat-select that only exists in ENTITY mode. Assert it renders
309+
// so a regression that stops swapping the subtree is caught here.
310+
const constitutionDropdown = page.locator('mat-select[formcontrolname="constitutionId"]');
311+
await expect(constitutionDropdown).toBeVisible();
312+
313+
// Switch back to PERSON — the name-control subtree must return
314+
// and the ENTITY-only controls must be torn down.
315+
await createClientPage.legalFormDropdown.click();
316+
await page.getByRole('option', { name: 'Person', exact: false }).first().click();
317+
318+
await createClientPage.firstnameInput.waitFor({ state: 'visible', timeout: 10_000 });
319+
await expect(createClientPage.firstnameInput).toBeVisible();
320+
await expect(createClientPage.lastnameInput).toBeVisible();
321+
await expect(createClientPage.fullnameInput).toHaveCount(0);
322+
await expect(constitutionDropdown).toHaveCount(0);
323+
324+
// Round-trip assertion: fill the restored PERSON name controls and
325+
// a submitted-on date so the form reaches a genuinely valid state.
326+
// This verifies that switching back to PERSON not only renders the
327+
// correct DOM subtree but also fully re-wires the reactive form
328+
// (validators active, form-valid flag flipped) — a regression where
329+
// the subtree renders but the FormGroup stays INVALID would be
330+
// caught here by the absence of the Preview step header.
331+
await createClientPage.firstnameInput.fill('RoundTrip');
332+
await createClientPage.lastnameInput.fill('Client');
333+
await createClientPage.submittedOnDateInput.fill(SUBMITTED_ON_DATE);
334+
await createClientPage.submittedOnDateInput.blur();
335+
336+
await expect(createClientPage.stepHeader('Preview')).toHaveCount(1);
337+
});
338+
});

0 commit comments

Comments
 (0)