From 2a21fe9fdc33803314c976346eb05a30e62e1f33 Mon Sep 17 00:00:00 2001 From: Devansh Vashisht Date: Thu, 25 Jun 2026 01:36:55 +0530 Subject: [PATCH] WEB-1014: [Playwright] Test data factories (client, group, user) and reverse-order CleanupGuard (WA-2.9 + WA-2.10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three test-data factories and a panic-safe LIFO CleanupGuard on top of WEB-1013 (ApiSetupManager + naming + test-data types). Unlocks Week 5 WA-3 client E2E tests. Zero-import LIFO cleanup stack. register(label, deleter) pushes onto a stack; flush() drains in reverse order under Promise.allSettled, returns { ok, failed, outcomes } and never throws. React-portable. client.factory.ts — createTestClient(setup, guard, overrides?) group.factory.ts — createTestGroup(setup, guard, overrides?) user.factory.ts — createTestUser + Fineract-policy-compliant generateE2EPassword _shared.ts — resolveDefaultOfficeId deduped via ApiSetupManager Each factory: names via generateE2EName(prefix), dedupes office lookup through setup.dedupe('office:first'), registers deleter immediately after successful POST. Defaults to a pending shape that Fineract hard-deletes cleanly on teardown. Adds deleteClient, createGroup/getGroup/deleteGroup, createUser/getUser/deleteUser using existing validateResponse helper. Adds apiSetup fixture + auto cleanupGuard fixture. cleanupGuard explicitly depends on apiSetup so Playwright tears down the guard BEFORE the API request context disposes — without this ordering, deleters race the context teardown and fail. New 'integration' project (testMatch /playwright/factories/.*\.spec\.ts/) — no browser, no auth-setup dep, exits in seconds. playwright/utils/cleanup-guard.spec.ts — 11 pure-logic unit specs playwright/utils/api-setup-manager.spec.ts — 19 unit specs (WEB-1013) playwright/utils/naming.spec.ts — 24 unit specs (WEB-1013) playwright/factories/client.factory.spec.ts — live-backend integration playwright/factories/group.factory.spec.ts — live-backend integration playwright/factories/user.factory.spec.ts — live-backend integration unit project : 54/54 pass integration project : 14/14 pass, zero cleanup warnings tsc, eslint, prettier, headers : all clean Closes: WA-2.9, WA-2.10 Depends-on: WEB-1012 (#3681), WEB-1013 (#3684) --- playwright.config.ts | 14 ++ playwright/factories/_shared.ts | 40 ++++ playwright/factories/client.factory.spec.ts | 73 +++++++ playwright/factories/client.factory.ts | 137 ++++++++++++ playwright/factories/group.factory.spec.ts | 66 ++++++ playwright/factories/group.factory.ts | 117 ++++++++++ playwright/factories/user.factory.spec.ts | 122 +++++++++++ playwright/factories/user.factory.ts | 200 ++++++++++++++++++ playwright/fixtures/fineract-api.ts | 74 +++++++ playwright/fixtures/test-fixtures.ts | 64 +++++- playwright/utils/cleanup-guard.spec.ts | 215 +++++++++++++++++++ playwright/utils/cleanup-guard.ts | 223 ++++++++++++++++++++ playwright/utils/naming.spec.ts | 6 +- 13 files changed, 1345 insertions(+), 6 deletions(-) create mode 100644 playwright/factories/_shared.ts create mode 100644 playwright/factories/client.factory.spec.ts create mode 100644 playwright/factories/client.factory.ts create mode 100644 playwright/factories/group.factory.spec.ts create mode 100644 playwright/factories/group.factory.ts create mode 100644 playwright/factories/user.factory.spec.ts create mode 100644 playwright/factories/user.factory.ts create mode 100644 playwright/utils/cleanup-guard.spec.ts create mode 100644 playwright/utils/cleanup-guard.ts diff --git a/playwright.config.ts b/playwright.config.ts index 3b92a191f4..12c443c2c0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -121,6 +121,11 @@ export default defineConfig({ // `playwright/tests/admin/**` or `playwright/tests/restricted/**`, // and can also be force-enabled via PLAYWRIGHT_ENABLE_ADMIN_PROJECTS=1 // or PLAYWRIGHT_ENABLE_RESTRICTED_PROJECTS=1 for CI matrices. + // + // `integration` exists for live-backend factory specs under + // `playwright/factories/*.spec.ts` — they need a real Fineract but + // no browser, so they bypass the `setup` project (which would burn + // a Chromium boot on auth state we never use). projects: [ { // Pure-logic unit tests for shared utilities (retry, sleep, ...). @@ -130,6 +135,15 @@ export default defineConfig({ testDir: '.', use: { storageState: { cookies: [], origins: [] } } }, + { + // Live-backend factory specs — real Fineract HTTP, no browser. + // Runs against `E2E_FINERACT_URL` (default https://localhost:8443) + // and exits in seconds because no Chromium process is started. + name: 'integration', + testMatch: /playwright\/factories\/.*\.spec\.ts/, + testDir: '.', + use: { storageState: { cookies: [], origins: [] } } + }, { name: 'setup', testMatch: /auth\.setup\.ts/, diff --git a/playwright/factories/_shared.ts b/playwright/factories/_shared.ts new file mode 100644 index 0000000000..5e356b161d --- /dev/null +++ b/playwright/factories/_shared.ts @@ -0,0 +1,40 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Shared resolver helpers for the test-data factories. + * + * The only piece of cross-factory state is "what office should we + * attach this entity to?" — every factory in this PR defaults to the + * first office returned by Fineract, and dedupes that lookup through + * the {@link ApiSetupManager} so a test that creates a client, a + * group, and a user only pays one `/api/v1/offices` round-trip. + * + * Portability note: this module imports only from the in-tree + * `ApiSetupManager`. The React port can copy it verbatim. + */ + +import type { ApiSetupManager } from '../utils/api-setup-manager'; + +/** + * Stable cache key for "the first office id". Exported so unit specs + * can assert deduplication without re-implementing the convention. + */ +export const FIRST_OFFICE_CACHE_KEY = 'office:first'; + +/** + * Resolve the first office id, sharing the result across every + * factory invocation in the current process. The Fineract demo data + * ships exactly one office (the Head Office), so this is effectively + * a one-shot lookup; the `dedupe` wrapper still matters because + * parallel factory calls within the same test would otherwise fire + * the request twice. + */ +export async function resolveDefaultOfficeId(setup: ApiSetupManager): Promise { + return setup.dedupe(FIRST_OFFICE_CACHE_KEY, () => setup.api.getFirstOfficeId()); +} diff --git a/playwright/factories/client.factory.spec.ts b/playwright/factories/client.factory.spec.ts new file mode 100644 index 0000000000..8be56a6bf2 --- /dev/null +++ b/playwright/factories/client.factory.spec.ts @@ -0,0 +1,73 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { test, expect } from '../fixtures/test-fixtures'; +import { createTestClient, DEFAULT_TEST_CLIENT_LASTNAME } from './client.factory'; +import { E2E_NAME_PATTERN } from '../utils/naming'; + +// Live-backend specs — run under the `integration` Playwright project +// (testMatch: /playwright\/factories\/.*\.spec\.ts/ in +// playwright.config.ts). No browser, no auth-setup dependency; the +// tests issue HTTP directly against the Fineract endpoint configured +// via the existing `fineractApi` fixture. +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe('createTestClient() against live Fineract', () => { + test('creates a pending client matching the TestClient shape', async ({ apiSetup, cleanupGuard }) => { + const client = await createTestClient(apiSetup, cleanupGuard); + + expect(typeof client.resourceId).toBe('number'); + expect(client.resourceId).toBeGreaterThan(0); + expect(client.officeId).toBeGreaterThan(0); + // Default display name is ` ` — + // assert both halves so a regression that drops the suffix is caught. + expect(client.displayName.startsWith('E2E_client_S')).toBe(true); + expect(client.displayName.endsWith(` ${DEFAULT_TEST_CLIENT_LASTNAME}`)).toBe(true); + const firstname = client.displayName.split(' ')[0]; + expect(firstname).toMatch(E2E_NAME_PATTERN); + + // Round-trip the create through the GET endpoint to confirm + // Fineract really persisted what the projection claims. + const fetched = await apiSetup.api.getClient(client.resourceId); + expect(fetched.id).toBe(client.resourceId); + expect(fetched.displayName).toBe(client.displayName); + expect(fetched.officeId).toBe(client.officeId); + expect(fetched.active).toBe(false); + expect(fetched.status?.value).toBe('Pending'); + }); + + test('honours firstname / lastname / submittedOnDate overrides', async ({ apiSetup, cleanupGuard }) => { + const client = await createTestClient(apiSetup, cleanupGuard, { + firstname: 'OverrideF', + lastname: 'OverrideL', + submittedOnDate: '15 March 2024' + }); + expect(client.displayName).toBe('OverrideF OverrideL'); + const fetched = await apiSetup.api.getClient(client.resourceId); + expect(fetched.timeline?.submittedOnDate).toEqual([ + 2024, + 3, + 15 + ]); + }); + + test('queues a working deleter on the cleanup-guard', async ({ apiSetup, cleanupGuard }) => { + const client = await createTestClient(apiSetup, cleanupGuard); + expect(cleanupGuard.size()).toBe(1); + + const summary = await cleanupGuard.flush(); + expect(summary.ok).toBe(1); + expect(summary.failed).toEqual([]); + + // Confirm Fineract really hard-deleted the row by asserting the + // subsequent GET 404s. We catch via try/catch instead of + // `expect(...).rejects.toThrow()` because `getClient` throws a + // plain `Error` with the 404 status embedded in the message. + await expect(apiSetup.api.getClient(client.resourceId)).rejects.toThrow(/404|not found/i); + }); +}); diff --git a/playwright/factories/client.factory.ts b/playwright/factories/client.factory.ts new file mode 100644 index 0000000000..fae5ab38d8 --- /dev/null +++ b/playwright/factories/client.factory.ts @@ -0,0 +1,137 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Factory for a freshly-created Fineract client owned by the current + * Playwright test. + * + * Design goals (per GSoC 2026 proposal WA-2.9): + * - Default to a *pending* (`active: false`) client. Fineract only + * hard-deletes clients in pending state with no attached accounts, + * so the {@link CleanupGuard} teardown registered here MUST be + * able to succeed. Tests that need an active client can pass + * `{ active: true, activationDate }` via `overrides` and accept + * that their cleanup will fail loudly (the guard reports it but + * does not throw). + * - Build a unique, shard-tagged name via {@link generateE2EName} + * so cleanup-grep tooling can identify orphaned rows. + * - Dedupe the office lookup through {@link ApiSetupManager} so a + * test that creates three resources only pays one + * `/api/v1/offices` round-trip. + * - Register the deleter immediately after a successful POST and + * never before — a half-completed create must not leave a stale + * id queued for teardown. + * + * Portability note: this module imports only from the in-tree + * Playwright infrastructure (no Angular, no React, no Material). The + * React port can adopt it verbatim once its Playwright suite needs + * test-data factories. + */ + +import type { ApiSetupManager } from '../utils/api-setup-manager'; +import type { CleanupGuard } from '../utils/cleanup-guard'; +import { generateE2EName } from '../utils/naming'; +import type { TestClient } from '../types/test-data.types'; +import { resolveDefaultOfficeId } from './_shared'; + +/** Default lastname applied to every pending test client. */ +export const DEFAULT_TEST_CLIENT_LASTNAME = 'E2E'; + +/** Default submitted-on date applied to every pending test client. */ +export const DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE = '01 January 2024'; + +/** Fineract `legalFormId` for an individual person. */ +const LEGAL_FORM_PERSON = 1; + +/** Date format expected by the create-client endpoint. */ +const DEFAULT_DATE_FORMAT = 'dd MMMM yyyy'; + +/** Locale expected by the create-client endpoint. */ +const DEFAULT_LOCALE = 'en'; + +/** Caller-supplied tweaks to the default pending-client payload. */ +export interface CreateTestClientOverrides { + /** Override the auto-generated firstname. */ + firstname?: string; + /** Override the default lastname (`'E2E'`). */ + lastname?: string; + /** Override the default office id (first office returned by Fineract). */ + officeId?: number; + /** Override the default submitted-on date. */ + submittedOnDate?: string; + /** + * Extra payload fields merged AFTER the defaults — use to flip + * `active: true`, set an activation date, attach to a group, etc. + * Caller owns the cleanup-fail risk for non-deletable shapes. + */ + extra?: Record; +} + +/** + * Create a pending client owned by the current test and queue its + * deletion on the supplied {@link CleanupGuard}. + * + * @param setup The per-test {@link ApiSetupManager}. Carries the + * authenticated `FineractApiClient` and shares + * deduped setup calls across factories. + * @param guard The per-test {@link CleanupGuard}. The returned + * client's deleter is pushed onto this stack before + * this function returns. + * @param overrides See {@link CreateTestClientOverrides}. + * @returns A {@link TestClient} projection built from the create + * response and the input — no follow-up GET is issued, so + * callers needing post-creation state (timeline, status + * transitions) should call `setup.api.getClient(id)` + * themselves. + */ +export async function createTestClient( + setup: ApiSetupManager, + guard: CleanupGuard, + overrides: CreateTestClientOverrides = {} +): Promise { + const officeId = overrides.officeId ?? (await resolveDefaultOfficeId(setup)); + const firstname = overrides.firstname ?? generateE2EName('client'); + const lastname = overrides.lastname ?? DEFAULT_TEST_CLIENT_LASTNAME; + const submittedOnDate = overrides.submittedOnDate ?? DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE; + + const payload: Record = { + officeId, + firstname, + lastname, + legalFormId: LEGAL_FORM_PERSON, + active: false, + submittedOnDate, + dateFormat: DEFAULT_DATE_FORMAT, + locale: DEFAULT_LOCALE, + ...overrides.extra + }; + + const response = await setup.api.createClient(payload); + // Fineract returns both `clientId` and `resourceId` on create — they + // are always equal but `resourceId` is the documented envelope field. + const resourceId: number = response.resourceId ?? response.clientId; + if (typeof resourceId !== 'number') { + throw new Error( + `createTestClient: Fineract create-client response missing numeric resourceId/clientId, got ${JSON.stringify( + response + )}` + ); + } + + // Register the deleter BEFORE returning so a caller that forgets to + // await our result still gets cleanup on test exit. + guard.register(`client:${resourceId}`, async () => { + await setup.api.deleteClient(resourceId); + }); + + return { + resourceId, + displayName: `${firstname} ${lastname}`, + officeId + }; +} diff --git a/playwright/factories/group.factory.spec.ts b/playwright/factories/group.factory.spec.ts new file mode 100644 index 0000000000..19e890e095 --- /dev/null +++ b/playwright/factories/group.factory.spec.ts @@ -0,0 +1,66 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { test, expect } from '../fixtures/test-fixtures'; +import { createTestGroup } from './group.factory'; +import { E2E_NAME_PATTERN } from '../utils/naming'; + +// Live-backend specs — run under the `integration` Playwright project +// (testMatch: /playwright\/factories\/.*\.spec\.ts/ in +// playwright.config.ts). No browser, no auth-setup dependency. +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe('createTestGroup() against live Fineract', () => { + test('creates a pending group matching the TestGroup shape', async ({ apiSetup, cleanupGuard }) => { + const group = await createTestGroup(apiSetup, cleanupGuard); + + expect(typeof group.resourceId).toBe('number'); + expect(group.resourceId).toBeGreaterThan(0); + expect(group.officeId).toBeGreaterThan(0); + // displayName is mirrored from `name` for groups — assert the + // E2E pattern on it directly. + expect(group.displayName).toMatch(E2E_NAME_PATTERN); + expect(group.displayName.startsWith('E2E_group_S')).toBe(true); + + // Round-trip the create through the GET endpoint. + const fetched = await apiSetup.api.getGroup(group.resourceId); + expect(fetched.id).toBe(group.resourceId); + expect(fetched.name).toBe(group.displayName); + expect(fetched.officeId).toBe(group.officeId); + expect(fetched.active).toBe(false); + expect(fetched.status?.value).toBe('Pending'); + }); + + test('honours name / submittedOnDate overrides', async ({ apiSetup, cleanupGuard }) => { + // Use a timestamp suffix so the override is still unique enough + // not to clash with a previous test run. + const overrideName = `OverrideGroup_${Date.now()}`; + const group = await createTestGroup(apiSetup, cleanupGuard, { + name: overrideName, + submittedOnDate: '15 March 2024' + }); + expect(group.displayName).toBe(overrideName); + const fetched = await apiSetup.api.getGroup(group.resourceId); + expect(fetched.timeline?.submittedOnDate).toEqual([ + 2024, + 3, + 15 + ]); + }); + + test('queues a working deleter on the cleanup-guard', async ({ apiSetup, cleanupGuard }) => { + const group = await createTestGroup(apiSetup, cleanupGuard); + expect(cleanupGuard.size()).toBe(1); + + const summary = await cleanupGuard.flush(); + expect(summary.ok).toBe(1); + expect(summary.failed).toEqual([]); + + await expect(apiSetup.api.getGroup(group.resourceId)).rejects.toThrow(/404|not exist/i); + }); +}); diff --git a/playwright/factories/group.factory.ts b/playwright/factories/group.factory.ts new file mode 100644 index 0000000000..673a8888cd --- /dev/null +++ b/playwright/factories/group.factory.ts @@ -0,0 +1,117 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Factory for a freshly-created Fineract group owned by the current + * Playwright test. + * + * Design goals (per GSoC 2026 proposal WA-2.9): + * - Default to a *pending* (`active: false`) group with no members. + * Fineract only hard-deletes groups in pending state with no + * member clients, so the {@link CleanupGuard} teardown registered + * here MUST be able to succeed. + * - Build a unique, shard-tagged name via {@link generateE2EName} + * so cleanup-grep tooling can identify orphaned rows. Group names + * are unique within an office in Fineract, so the + * `E2E_group_S{shard}_{ts}_{rand}` shape comfortably avoids + * collisions even under heavy parallel-worker load. + * - Dedupe the office lookup through {@link ApiSetupManager}. + * - Register the deleter immediately after a successful POST and + * never before. + * + * Portability note: this module imports only from the in-tree + * Playwright infrastructure. + */ + +import type { ApiSetupManager } from '../utils/api-setup-manager'; +import type { CleanupGuard } from '../utils/cleanup-guard'; +import { generateE2EName } from '../utils/naming'; +import type { TestGroup } from '../types/test-data.types'; +import { resolveDefaultOfficeId } from './_shared'; + +/** Default submitted-on date applied to every pending test group. */ +export const DEFAULT_TEST_GROUP_SUBMITTED_ON_DATE = '01 January 2024'; + +/** Date format expected by the create-group endpoint. */ +const DEFAULT_DATE_FORMAT = 'dd MMMM yyyy'; + +/** Locale expected by the create-group endpoint. */ +const DEFAULT_LOCALE = 'en'; + +/** Caller-supplied tweaks to the default pending-group payload. */ +export interface CreateTestGroupOverrides { + /** Override the auto-generated group name. */ + name?: string; + /** Override the default office id (first office returned by Fineract). */ + officeId?: number; + /** Override the default submitted-on date. */ + submittedOnDate?: string; + /** + * Extra payload fields merged AFTER the defaults — use to attach + * client ids, flip `active: true`, etc. Caller owns the cleanup-fail + * risk for non-deletable shapes. + */ + extra?: Record; +} + +/** + * Create a pending group owned by the current test and queue its + * deletion on the supplied {@link CleanupGuard}. + * + * @param setup The per-test {@link ApiSetupManager}. + * @param guard The per-test {@link CleanupGuard}. + * @param overrides See {@link CreateTestGroupOverrides}. + * @returns A {@link TestGroup} projection. `displayName` is set to + * the group's `name` because Fineract groups have no + * separate `displayName` field on either the create response + * or the GET projection. + */ +export async function createTestGroup( + setup: ApiSetupManager, + guard: CleanupGuard, + overrides: CreateTestGroupOverrides = {} +): Promise { + const officeId = overrides.officeId ?? (await resolveDefaultOfficeId(setup)); + const name = overrides.name ?? generateE2EName('group'); + const submittedOnDate = overrides.submittedOnDate ?? DEFAULT_TEST_GROUP_SUBMITTED_ON_DATE; + + const payload: Record = { + officeId, + name, + active: false, + submittedOnDate, + dateFormat: DEFAULT_DATE_FORMAT, + locale: DEFAULT_LOCALE, + ...overrides.extra + }; + + const response = await setup.api.createGroup(payload); + // Fineract returns both `groupId` and `resourceId` on create — they + // are always equal but `resourceId` is the documented envelope field. + const resourceId: number = response.resourceId ?? response.groupId; + if (typeof resourceId !== 'number') { + throw new Error( + `createTestGroup: Fineract create-group response missing numeric resourceId/groupId, got ${JSON.stringify( + response + )}` + ); + } + + guard.register(`group:${resourceId}`, async () => { + await setup.api.deleteGroup(resourceId); + }); + + return { + resourceId, + // Fineract groups have no separate `displayName` field — `name` + // is what the UI renders, so we mirror it into the TestEntity + // projection. + displayName: name, + officeId + }; +} diff --git a/playwright/factories/user.factory.spec.ts b/playwright/factories/user.factory.spec.ts new file mode 100644 index 0000000000..3849bfbf5d --- /dev/null +++ b/playwright/factories/user.factory.spec.ts @@ -0,0 +1,122 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { test, expect } from '../fixtures/test-fixtures'; +import { + createTestUser, + generateE2EPassword, + FINERACT_PASSWORD_REGEX, + DEFAULT_TEST_USER_ROLE_ID +} from './user.factory'; +import { createTestClient } from './client.factory'; +import { createTestGroup } from './group.factory'; +import { E2E_NAME_PATTERN } from '../utils/naming'; + +// Live-backend specs — run under the `integration` Playwright project +// (testMatch: /playwright\/factories\/.*\.spec\.ts/ in +// playwright.config.ts). No browser, no auth-setup dependency. +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe('generateE2EPassword()', () => { + test('always satisfies the Fineract password regex with the default RNG', () => { + for (let i = 0; i < 50; i++) { + const pwd = generateE2EPassword(); + expect(pwd).toMatch(FINERACT_PASSWORD_REGEX); + } + }); + + test('terminates and stays valid even when the RNG always returns 0', () => { + const pwd = generateE2EPassword(() => 0); + expect(pwd).toMatch(FINERACT_PASSWORD_REGEX); + }); + + test('terminates and stays valid even when the RNG always returns close to 1', () => { + // 0.9999... — exercises the upper clamp branch. + const pwd = generateE2EPassword(() => 0.999999); + expect(pwd).toMatch(FINERACT_PASSWORD_REGEX); + }); +}); + +test.describe('createTestUser() against live Fineract', () => { + test('creates a user matching the TestUser shape', async ({ apiSetup, cleanupGuard }) => { + const user = await createTestUser(apiSetup, cleanupGuard); + + expect(typeof user.resourceId).toBe('number'); + expect(user.resourceId).toBeGreaterThan(0); + expect(user.officeId).toBeGreaterThan(0); + expect(user.username).toMatch(E2E_NAME_PATTERN); + expect(user.username.startsWith('E2E_user_S')).toBe(true); + expect(user.email).toBe(`${user.username.toLowerCase()}@e2e.test`); + expect(user.password).toMatch(FINERACT_PASSWORD_REGEX); + + // Round-trip the create through the GET endpoint. + const fetched = await apiSetup.api.getUser(user.resourceId); + expect(fetched.id).toBe(user.resourceId); + expect(fetched.username).toBe(user.username); + expect(fetched.officeId).toBe(user.officeId); + expect(fetched.email).toBe(user.email); + // Default role assignment — Super user (id=1) from the demo seed. + const selectedRoleIds = (fetched.selectedRoles ?? []).map((r: { id: number }) => r.id); + expect(selectedRoleIds).toContain(DEFAULT_TEST_USER_ROLE_ID); + }); + + test('honours username / firstname / lastname overrides', async ({ apiSetup, cleanupGuard }) => { + const overrideUsername = `OverrideUser_${Date.now()}`; + const user = await createTestUser(apiSetup, cleanupGuard, { + username: overrideUsername, + firstname: 'Custom', + lastname: 'Name' + }); + expect(user.username).toBe(overrideUsername); + const fetched = await apiSetup.api.getUser(user.resourceId); + expect(fetched.firstname).toBe('Custom'); + expect(fetched.lastname).toBe('Name'); + }); + + test('queues a working deleter on the cleanup-guard', async ({ apiSetup, cleanupGuard }) => { + const user = await createTestUser(apiSetup, cleanupGuard); + expect(cleanupGuard.size()).toBe(1); + + const summary = await cleanupGuard.flush(); + expect(summary.ok).toBe(1); + expect(summary.failed).toEqual([]); + + await expect(apiSetup.api.getUser(user.resourceId)).rejects.toThrow(/404|not exist/i); + }); + + test('rejects an override password that violates the policy without hitting the backend', async ({ + apiSetup, + cleanupGuard + }) => { + await expect(createTestUser(apiSetup, cleanupGuard, { password: 'short' })).rejects.toThrow( + /does not satisfy Fineract's password policy/ + ); + // Nothing was created → nothing was registered for cleanup. + expect(cleanupGuard.size()).toBe(0); + }); +}); + +test.describe('cleanup-guard reverse-order teardown across factories', () => { + test('flush() deletes the most-recently-created entity first', async ({ apiSetup, cleanupGuard }) => { + const client = await createTestClient(apiSetup, cleanupGuard); + const group = await createTestGroup(apiSetup, cleanupGuard); + const user = await createTestUser(apiSetup, cleanupGuard); + + expect(cleanupGuard.size()).toBe(3); + const summary = await cleanupGuard.flush(); + + expect(summary.ok).toBe(3); + expect(summary.failed).toEqual([]); + // LIFO: user → group → client. + expect(summary.outcomes.map((o) => o.label)).toEqual([ + `user:${user.resourceId}`, + `group:${group.resourceId}`, + `client:${client.resourceId}` + ]); + }); +}); diff --git a/playwright/factories/user.factory.ts b/playwright/factories/user.factory.ts new file mode 100644 index 0000000000..e9c0b9b059 --- /dev/null +++ b/playwright/factories/user.factory.ts @@ -0,0 +1,200 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Factory for a freshly-created Fineract application user owned by + * the current Playwright test. + * + * Design goals (per GSoC 2026 proposal WA-2.9): + * - Build a unique, shard-tagged username via {@link generateE2EName} + * so cleanup-grep tooling can identify orphaned rows. + * - Dedupe the office lookup through {@link ApiSetupManager}. + * - Generate a Fineract-compliant password deterministically — the + * backend enforces + * `^(?!.*(.)\\1)(?!.*\\s)(?=.*\\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^\\w\\s]).{12,50}$` + * (12–50 chars, ≥1 each of lower / upper / digit / special, no + * spaces, no two equal consecutive characters). The default + * generator below ALWAYS produces a passing string and exposes + * {@link generateE2EPassword} so unit specs can assert the shape + * in isolation. + * - Register the deleter immediately after a successful POST and + * never before. + * + * Portability note: this module imports only from the in-tree + * Playwright infrastructure. + */ + +import type { ApiSetupManager } from '../utils/api-setup-manager'; +import type { CleanupGuard } from '../utils/cleanup-guard'; +import { generateE2EName } from '../utils/naming'; +import type { TestUser } from '../types/test-data.types'; +import { resolveDefaultOfficeId } from './_shared'; + +/** + * Fineract's `Super user` role id in the demo seed. Used as the + * default role assignment because every E2E test that authenticates + * as a custom-built user needs full read access to assert against UI + * surfaces it does not own. + */ +export const DEFAULT_TEST_USER_ROLE_ID = 1; + +/** + * Regex copy of Fineract's password validator. Exported only for the + * unit spec so the password generator can be proven correct by the + * exact same rule the backend uses — keep the two in sync. + */ +export const FINERACT_PASSWORD_REGEX = /^(?!.*(.)\1)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^\w\s]).{12,50}$/; + +/** + * Build a 14-character password that always satisfies + * {@link FINERACT_PASSWORD_REGEX}, using a 4-character entropy tail + * derived from `Math.random()` to keep parallel-worker collisions + * unlikely. The structure is fixed so the regex is satisfied by + * construction; only the trailing entropy varies between calls. + * + * @param random Injectable RNG returning a value in `[0, 1)`. + * Defaults to `Math.random`. Unit specs inject a + * deterministic source to assert exact output. + */ +export function generateE2EPassword(random: () => number = Math.random): string { + // Fixed 10-char head: covers lower (`aB`), upper (`E2`), digit + // (`3`), special (`!`), and avoids any consecutive-equal pair. + // Char-by-char: a B 7 r J ! 2 q P # — pairwise distinct. + // Adjacency check: a/B B/7 7/r r/J J/! !/2 2/q q/P P/# — all + // pairs differ, so the no-repeat lookahead is satisfied for the + // head regardless of what tail we append. + const head = 'aB7rJ!2qP#'; + // 4-char tail drawn from a base36-ish alphabet (no uppercase, no + // specials — the head already satisfies those classes). On every + // draw we deterministically skip past `prev` so a pathological RNG + // that returns the same value forever cannot wedge the loop. + const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'; + let tail = ''; + let prev = head[head.length - 1]; + for (let i = 0; i < 4; i++) { + const raw = Math.floor(random() * alphabet.length); + const idx = Math.min(Math.max(raw, 0), alphabet.length - 1); + let ch = alphabet[idx]; + if (ch === prev) { + // Deterministic skip — picks the next alphabet character (with + // wrap-around) so we never collide with `prev`. + ch = alphabet[(idx + 1) % alphabet.length]; + } + tail += ch; + prev = ch; + } + return head + tail; +} + +/** Caller-supplied tweaks to the default user payload. */ +export interface CreateTestUserOverrides { + /** Override the auto-generated username. */ + username?: string; + /** Override the default firstname (`'E2E'`). */ + firstname?: string; + /** Override the default lastname (`'User'`). */ + lastname?: string; + /** Override the default email (`@e2e.test`). */ + email?: string; + /** Override the default office id (first office returned by Fineract). */ + officeId?: number; + /** + * Override the default role assignment (`[Super user]`). Pass an + * empty array for a user with no roles. + */ + roles?: readonly number[]; + /** Override the generated password. Must satisfy {@link FINERACT_PASSWORD_REGEX}. */ + password?: string; + /** + * Extra payload fields merged AFTER the defaults — use for + * `staffId`, `passwordNeverExpires`, etc. + */ + extra?: Record; +} + +/** + * Result of {@link createTestUser}. Extends {@link TestUser} with the + * cleartext password so a test that needs to log in as this user can + * use the same credentials it just created — Fineract never returns + * the password on subsequent GETs. + */ +export interface CreatedTestUser extends TestUser { + /** The cleartext password sent at create-time. */ + password: string; +} + +/** + * Create an application user owned by the current test and queue its + * deletion on the supplied {@link CleanupGuard}. + * + * @param setup The per-test {@link ApiSetupManager}. + * @param guard The per-test {@link CleanupGuard}. + * @param overrides See {@link CreateTestUserOverrides}. + * @returns A {@link CreatedTestUser} projection carrying the cleartext + * password so the caller can immediately authenticate as the + * new user without re-deriving the credentials. + */ +export async function createTestUser( + setup: ApiSetupManager, + guard: CleanupGuard, + overrides: CreateTestUserOverrides = {} +): Promise { + const officeId = overrides.officeId ?? (await resolveDefaultOfficeId(setup)); + const username = overrides.username ?? generateE2EName('user'); + const firstname = overrides.firstname ?? 'E2E'; + const lastname = overrides.lastname ?? 'User'; + const email = overrides.email ?? `${username.toLowerCase()}@e2e.test`; + const roles = overrides.roles ?? [DEFAULT_TEST_USER_ROLE_ID]; + const password = overrides.password ?? generateE2EPassword(); + + if (!FINERACT_PASSWORD_REGEX.test(password)) { + throw new Error( + `createTestUser: supplied password does not satisfy Fineract's password policy ` + + `(12-50 chars, mixed case, digit, special, no spaces, no consecutive equal chars)` + ); + } + + const payload: Record = { + username, + firstname, + lastname, + email, + officeId, + roles, + sendPasswordToEmail: false, + password, + repeatPassword: password, + ...overrides.extra + }; + + const response = await setup.api.createUser(payload); + const resourceId: number = response.resourceId; + if (typeof resourceId !== 'number') { + throw new Error( + `createTestUser: Fineract create-user response missing numeric resourceId, got ${JSON.stringify(response)}` + ); + } + + guard.register(`user:${resourceId}`, async () => { + await setup.api.deleteUser(resourceId); + }); + + // NOTE: `TestUser.roles` is documented as a list of role *names*, + // but the create endpoint takes role *ids* and the create response + // does not echo the names back. We deliberately leave `roles` + // undefined in the projection rather than ship the numeric ids as + // strings — callers that need the names should call + // `setup.api.getUser(id)` and read `selectedRoles[].name`. + return { + resourceId, + username, + email, + officeId, + password + }; +} diff --git a/playwright/fixtures/fineract-api.ts b/playwright/fixtures/fineract-api.ts index 51692b4dbd..6a904f1eda 100644 --- a/playwright/fixtures/fineract-api.ts +++ b/playwright/fixtures/fineract-api.ts @@ -119,6 +119,80 @@ export class FineractApiClient { return this.validateResponse(res, 'getClient'); } + /** + * Deletes a client by id. Fineract only allows hard-delete for clients + * that are still in pending state with no associated accounts — the + * factories in `playwright/factories/client.factory.ts` always create + * pending clients precisely so the cleanup-guard teardown can succeed. + * @param clientId - The client id to delete + * @returns The Fineract delete-client response payload + */ + async deleteClient(clientId: number): Promise { + const res = await this.ctx.delete(`/fineract-provider/api/v1/clients/${clientId}`); + return this.validateResponse(res, 'deleteClient'); + } + + /** + * Creates a group using the supplied request payload. + * @param data - The group creation payload + * @returns The Fineract create-group response payload + */ + async createGroup(data: Record): Promise { + const res = await this.ctx.post('/fineract-provider/api/v1/groups', { data }); + return this.validateResponse(res, 'createGroup'); + } + + /** + * Fetches a group record by id. + * @param groupId - The group id to fetch + * @returns The requested group payload + */ + async getGroup(groupId: number): Promise { + const res = await this.ctx.get(`/fineract-provider/api/v1/groups/${groupId}`); + return this.validateResponse(res, 'getGroup'); + } + + /** + * Deletes a group by id. Only pending groups with no member clients + * are accepted by Fineract for hard-delete. + * @param groupId - The group id to delete + * @returns The Fineract delete-group response payload + */ + async deleteGroup(groupId: number): Promise { + const res = await this.ctx.delete(`/fineract-provider/api/v1/groups/${groupId}`); + return this.validateResponse(res, 'deleteGroup'); + } + + /** + * Creates a user (application user / staff with login) using the supplied payload. + * @param data - The user creation payload + * @returns The Fineract create-user response payload + */ + async createUser(data: Record): Promise { + const res = await this.ctx.post('/fineract-provider/api/v1/users', { data }); + return this.validateResponse(res, 'createUser'); + } + + /** + * Fetches a user record by id. + * @param userId - The user id to fetch + * @returns The requested user payload + */ + async getUser(userId: number): Promise { + const res = await this.ctx.get(`/fineract-provider/api/v1/users/${userId}`); + return this.validateResponse(res, 'getUser'); + } + + /** + * Deletes a user by id. + * @param userId - The user id to delete + * @returns The Fineract delete-user response payload + */ + async deleteUser(userId: number): Promise { + const res = await this.ctx.delete(`/fineract-provider/api/v1/users/${userId}`); + return this.validateResponse(res, 'deleteUser'); + } + /** * Fetches all configured Fineract system codes. * @returns The configured system codes diff --git a/playwright/fixtures/test-fixtures.ts b/playwright/fixtures/test-fixtures.ts index fc01f35aab..b94aca1552 100644 --- a/playwright/fixtures/test-fixtures.ts +++ b/playwright/fixtures/test-fixtures.ts @@ -9,9 +9,32 @@ import { test as base } from '@playwright/test'; import { FineractApiClient } from './fineract-api'; +import { ApiSetupManager } from '../utils/api-setup-manager'; +import { CleanupGuard } from '../utils/cleanup-guard'; +/** + * Fixture surface available to every Playwright test in this suite. + * + * - `fineractApi` — Authenticated REST client, one per test. + * - `apiSetup` — De-duplicating wrapper around `fineractApi` that + * shares expensive setup calls (office lookup, + * loan template, ...) across factory invocations + * within the same test process. + * - `cleanupGuard` — Reverse-order, panic-safe teardown stack. Wired + * as an auto-fixture so its `flush()` runs in the + * teardown phase even if a test never names it, + * never opts in, or throws halfway through. + * + * Factories in `playwright/factories/*.ts` consume `apiSetup` and + * `cleanupGuard` together — the factory registers its own deleter on + * the guard immediately after a successful create-* call, and the + * fixture's `afterEach`-equivalent calls `flush()` so cleanup runs + * with zero opt-in from the test author. + */ type E2EFixtures = { fineractApi: FineractApiClient; + apiSetup: ApiSetupManager; + cleanupGuard: CleanupGuard; }; export const test = base.extend({ @@ -25,7 +48,46 @@ export const test = base.extend({ await api.init(); await use(api); await api.dispose(); - } + }, + + apiSetup: async ({ fineractApi }, use) => { + // The default constructor uses the module-level cache, so two + // factories that ask for the same office lookup within the same + // process share one `Promise`. Per-test isolation is provided by + // the factory pattern itself — keys are scoped per-domain + // (`office:first`, `loanTemplate:...`) and are safe to share. + const setup = new ApiSetupManager(fineractApi); + await use(setup); + }, + + cleanupGuard: [ + // Declare an explicit dependency on `apiSetup` (and through it + // on `fineractApi`) so Playwright's fixture graph guarantees + // this fixture's teardown runs BEFORE the API context is + // disposed. Without that ordering, deleters queued by factories + // would race the `fineractApi` teardown and reject with + // "Target page, context or browser has been closed". + async ({ apiSetup }, use) => { + // `apiSetup` is referenced only to anchor the dependency + // graph; the guard itself stays decoupled from the manager. + void apiSetup; + const guard = new CleanupGuard(); + await use(guard); + // flush() never throws — teardown noise must not mask the real + // test failure in the Playwright reporter. Failures are logged + // for triage; tests that want to escalate on cleanup errors can + // call `await guard.flush()` themselves and inspect the summary + // before letting this auto-fixture run a (now no-op) second flush. + const summary = await guard.flush(); + if (summary.failed.length > 0) { + console.warn( + `[cleanupGuard] ${summary.failed.length}/${summary.outcomes.length} deleter(s) failed:`, + summary.failed.map((f) => ({ label: f.label, reason: String(f.reason) })) + ); + } + }, + { auto: true } + ] }); export { expect } from '@playwright/test'; diff --git a/playwright/utils/cleanup-guard.spec.ts b/playwright/utils/cleanup-guard.spec.ts new file mode 100644 index 0000000000..adda5a987d --- /dev/null +++ b/playwright/utils/cleanup-guard.spec.ts @@ -0,0 +1,215 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { test, expect } from '@playwright/test'; +import { CleanupGuard, CleanupGuardError } from './cleanup-guard'; + +// Pure-logic specs — they run under the `unit` Playwright project +// (testMatch: /playwright\/utils\/.*\.spec\.ts/ in playwright.config.ts) +// with no browser, no app, no backend. +test.use({ storageState: { cookies: [], origins: [] } }); + +// ───────────────────────────────────────────────────────────────────── +// register() — input contract +// ───────────────────────────────────────────────────────────────────── + +test.describe('CleanupGuard.register() input contract', () => { + test('throws CleanupGuardError on an empty label', () => { + const guard = new CleanupGuard(); + expect(() => guard.register('', async () => undefined)).toThrow(CleanupGuardError); + expect(() => guard.register('', async () => undefined)).toThrow(/non-empty/); + }); + + test('throws CleanupGuardError when deleter is not a function', () => { + const guard = new CleanupGuard(); + expect(() => guard.register('x', undefined as unknown as () => Promise)).toThrow(CleanupGuardError); + }); + + test('size() reflects pushed registrations', () => { + const guard = new CleanupGuard(); + expect(guard.size()).toBe(0); + guard.register('a', async () => undefined); + guard.register('b', async () => undefined); + expect(guard.size()).toBe(2); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// flush() — LIFO ordering +// ───────────────────────────────────────────────────────────────────── + +test.describe('CleanupGuard.flush() ordering', () => { + test('runs deleters in strict reverse-insertion order', async () => { + const guard = new CleanupGuard(); + const fired: string[] = []; + + guard.register('first', async () => { + fired.push('first'); + }); + guard.register('second', async () => { + fired.push('second'); + }); + guard.register('third', async () => { + fired.push('third'); + }); + + const summary = await guard.flush(); + expect(fired).toEqual([ + 'third', + 'second', + 'first' + ]); + expect(summary.outcomes.map((o) => o.label)).toEqual([ + 'third', + 'second', + 'first' + ]); + expect(summary.ok).toBe(3); + expect(summary.failed).toEqual([]); + }); + + test('drains the stack to zero after a flush', async () => { + const guard = new CleanupGuard(); + guard.register('a', async () => undefined); + guard.register('b', async () => undefined); + expect(guard.size()).toBe(2); + await guard.flush(); + expect(guard.size()).toBe(0); + }); + + test('a second flush after drain is a no-op summary', async () => { + const guard = new CleanupGuard(); + let calls = 0; + guard.register('a', async () => { + calls++; + }); + const first = await guard.flush(); + const second = await guard.flush(); + expect(calls).toBe(1); + expect(first.ok).toBe(1); + expect(second).toEqual({ ok: 0, failed: [], outcomes: [] }); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// flush() — Promise.allSettled isolation +// ───────────────────────────────────────────────────────────────────── + +test.describe('CleanupGuard.flush() failure isolation', () => { + test('a single failing deleter does not block its siblings', async () => { + const guard = new CleanupGuard(); + const fired: string[] = []; + + guard.register('bottom', async () => { + fired.push('bottom'); + }); + guard.register('middle', async () => { + fired.push('middle'); + throw new Error('boom'); + }); + guard.register('top', async () => { + fired.push('top'); + }); + + const summary = await guard.flush(); + // All three deleters ran despite the middle one throwing. + expect(fired).toEqual([ + 'top', + 'middle', + 'bottom' + ]); + expect(summary.ok).toBe(2); + expect(summary.failed).toHaveLength(1); + expect(summary.failed[0].label).toBe('middle'); + expect((summary.failed[0].reason as Error).message).toBe('boom'); + }); + + test('flush() never throws even when every deleter rejects', async () => { + const guard = new CleanupGuard(); + guard.register('a', async () => { + throw new Error('a-fail'); + }); + guard.register('b', async () => { + throw new Error('b-fail'); + }); + + const summary = await guard.flush(); + expect(summary.ok).toBe(0); + expect(summary.failed.map((f) => f.label)).toEqual([ + 'b', + 'a' + ]); + }); + + test('synchronous throws inside a deleter are captured as rejections', async () => { + const guard = new CleanupGuard(); + guard.register('sync', (): Promise => { + throw new Error('sync boom'); + }); + const summary = await guard.flush(); + expect(summary.failed).toHaveLength(1); + expect((summary.failed[0].reason as Error).message).toBe('sync boom'); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// flush() — re-entrancy guard +// ───────────────────────────────────────────────────────────────────── + +test.describe('CleanupGuard re-entrancy guard', () => { + test('register() during a flush is rejected', async () => { + const guard = new CleanupGuard(); + let caught: unknown; + guard.register('outer', async () => { + try { + guard.register('inner', async () => undefined); + } catch (err) { + caught = err; + } + }); + + const summary = await guard.flush(); + expect(caught).toBeInstanceOf(CleanupGuardError); + expect((caught as Error).message).toMatch(/flush\(\) is in progress/); + // outer still ran successfully — register failure inside the + // deleter was caught locally, not by the guard itself. + expect(summary.ok).toBe(1); + }); + + test('concurrent flush() calls drain exactly once', async () => { + const guard = new CleanupGuard(); + let calls = 0; + // Deleter resolves on a microtask boundary so the two flush() + // calls in the Promise.all overlap in flight. + guard.register('once', async () => { + calls++; + await Promise.resolve(); + }); + + const [ + a, + b + ] = await Promise.all([ + guard.flush(), + guard.flush() + ]); + + expect(calls).toBe(1); + // Exactly one of the two flushes ran the deleter; the other got + // the concurrent-call empty summary. We do not pin which is which + // because microtask scheduling is implementation-defined. + const totals = [ + a.outcomes.length, + b.outcomes.length + ].sort(); + expect(totals).toEqual([ + 0, + 1 + ]); + }); +}); diff --git a/playwright/utils/cleanup-guard.ts b/playwright/utils/cleanup-guard.ts new file mode 100644 index 0000000000..2b88532521 --- /dev/null +++ b/playwright/utils/cleanup-guard.ts @@ -0,0 +1,223 @@ +/** + * Copyright since 2026 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Reverse-order, panic-safe teardown stack for Playwright E2E tests. + * + * Design goals (per GSoC 2026 proposal WA-2.10): + * - Resources created during a test (clients, groups, users, loans, …) + * MUST be torn down even if the test body throws halfway through. + * A per-test `afterEach` hook is too easy to forget; the `cleanupGuard` + * auto-fixture in `playwright/fixtures/test-fixtures.ts` calls + * {@link CleanupGuard.flush} unconditionally so opt-in is impossible. + * - Deleters MUST run in strict reverse-insertion order (LIFO). A test + * that creates a client and then a loan on that client cannot delete + * the client before the loan or Fineract rejects the cascade. + * - A single failing deleter MUST NOT abort the remaining deleters. + * The whole flush is wrapped in `Promise.allSettled` so a flaky + * teardown HTTP call cannot leak the siblings — and the structured + * {@link FlushSummary} surfaces every failure for triage. + * - flush() NEVER throws. Teardown noise must not mask the real test + * failure in the Playwright reporter. Callers that need to fail the + * test on cleanup errors can inspect the returned summary themselves. + * + * Scope note: this module ships only the generic LIFO stack. Domain + * factories (`createTestClient`, `createTestGroup`, `createTestUser`) + * land in the same PR and register their own deleters here. + * + * Portability note: this file imports nothing — not from + * `@playwright/test`, not from Node built-ins. The React port + * (`mifos-x-web-app-react`) can adopt it verbatim once its Playwright + * suite grows beyond smoke tests. + */ + +/** + * A single registered teardown action. The function is invoked at most + * once during {@link CleanupGuard.flush}; it must be idempotent in + * spirit (CI may retry the whole test, re-creating the same resource + * with a new id) but is never called twice for the same registration. + */ +export type CleanupDeleter = () => Promise; + +/** Result of a single deleter invocation. */ +export interface FlushOutcome { + /** Human-readable label supplied at register-time, for triage logs. */ + label: string; + /** + * Result of the underlying `Promise.allSettled`. `'fulfilled'` means + * the deleter resolved (Fineract returned 2xx); `'rejected'` means + * it threw or returned a non-2xx response. + */ + status: 'fulfilled' | 'rejected'; + /** Populated only when `status === 'rejected'`. */ + reason?: unknown; +} + +/** + * Structured summary returned by {@link CleanupGuard.flush}. The + * `ok`/`failed` counts plus the per-entry `outcomes` array give CI + * logs and downstream tooling everything they need to decide whether + * to escalate teardown failures without coupling the guard itself to + * any reporting framework. + */ +export interface FlushSummary { + /** Number of deleters that resolved successfully. */ + ok: number; + /** + * Failures only — `outcomes` carries the full ordered list. Kept + * separately so callers can `if (summary.failed.length) ...` without + * filtering the whole array. + */ + failed: ReadonlyArray<{ label: string; reason: unknown }>; + /** Per-deleter outcomes, in the order they were executed (LIFO). */ + outcomes: ReadonlyArray; +} + +/** + * Error thrown by {@link CleanupGuard.register} when its input + * contract is violated. Carries no extra fields — the message is the + * contract, matching the style of `ApiSetupManagerError` in + * `playwright/utils/api-setup-manager.ts`. + */ +export class CleanupGuardError extends Error { + constructor(message: string) { + super(message); + this.name = 'CleanupGuardError'; + } +} + +/** + * LIFO stack of teardown actions with a single drain operation. + * + * Usage (factory functions in this PR look like this): + * + * ```ts + * const client = await api.createClient(payload); + * guard.register(`client:${client.resourceId}`, () => api.deleteClient(client.resourceId)); + * ``` + * + * And in the auto fixture: + * + * ```ts + * cleanupGuard: [async ({}, use) => { + * const guard = new CleanupGuard(); + * await use(guard); + * await guard.flush(); + * }, { auto: true }] + * ``` + */ +export class CleanupGuard { + /** + * LIFO storage. New registrations are `push`ed; {@link flush} drains + * by repeated `pop` so the most-recently-created resource is deleted + * first — the only ordering Fineract's FK constraints accept. + */ + private readonly stack: Array<{ label: string; deleter: CleanupDeleter }> = []; + + /** + * Guarded against re-entrant {@link register} calls while a flush is + * in progress. Registering during teardown would silently leak the + * deleter (the stack is already being drained), so we throw instead. + */ + private flushing = false; + + /** + * Push a new teardown action onto the stack. Callers should invoke + * this immediately after a successful create-* call — never wrap + * the create itself, so that a half-completed create does not leave + * a stale id in the stack. + * + * @param label Human-readable label used in {@link FlushSummary}. + * Convention: `':'`, e.g. `'client:42'`. + * @param deleter Zero-argument async function that issues the + * delete. Should resolve on 2xx and reject otherwise. + * @throws {@link CleanupGuardError} when `label` is empty, `deleter` + * is not a function, or registration is attempted during a + * flush. + */ + register(label: string, deleter: CleanupDeleter): void { + if (typeof label !== 'string' || label.length === 0) { + throw new CleanupGuardError('register(label, deleter): label must be a non-empty string'); + } + if (typeof deleter !== 'function') { + throw new CleanupGuardError('register(label, deleter): deleter must be a function'); + } + if (this.flushing) { + throw new CleanupGuardError(`register(${label}): cannot register a new deleter while flush() is in progress`); + } + this.stack.push({ label, deleter }); + } + + /** Number of deleters currently queued. Mainly for tests. */ + size(): number { + return this.stack.length; + } + + /** + * Drain the stack in strict LIFO order, running every deleter via + * `Promise.allSettled` so a single failure cannot block siblings. + * + * Safe to call multiple times: subsequent calls after the stack is + * drained resolve to an empty {@link FlushSummary} (`ok: 0`, + * `failed: []`, `outcomes: []`) without invoking any deleters. + * + * Never throws — teardown noise must not mask the real test + * failure. Callers that need to escalate on cleanup errors should + * inspect `summary.failed` themselves. + */ + async flush(): Promise { + if (this.flushing) { + // Concurrent flush() call — return an empty summary rather than + // re-entering and double-running deleters. The first caller + // owns the drain. + return { ok: 0, failed: [], outcomes: [] }; + } + this.flushing = true; + try { + // Snapshot the drain in LIFO order BEFORE running any deleter. + // We do not pop incrementally inside the await loop because a + // deleter could in principle queue more work; the contract is + // that everything registered at flush-entry is drained, and + // nothing else. + const drained: Array<{ label: string; deleter: CleanupDeleter }> = []; + while (this.stack.length > 0) { + // Non-null assertion is safe — we just checked length. + drained.push(this.stack.pop()!); + } + + const settled = await Promise.allSettled( + // Each deleter is wrapped in `Promise.resolve().then(...)` so + // that a synchronously-thrown error becomes a rejected + // promise. Without this wrapper a sync throw would propagate + // out of the `.map()` callback BEFORE `Promise.allSettled` + // could intercept it and the whole flush would reject — the + // exact failure mode this design is meant to prevent. + drained.map((entry) => Promise.resolve().then(() => entry.deleter())) + ); + + const outcomes: FlushOutcome[] = settled.map((result, index) => { + const label = drained[index].label; + return result.status === 'fulfilled' + ? { label, status: 'fulfilled' } + : { label, status: 'rejected', reason: result.reason }; + }); + + const failed = outcomes + .filter((o): o is FlushOutcome & { status: 'rejected' } => o.status === 'rejected') + .map((o) => ({ label: o.label, reason: o.reason })); + + return { + ok: outcomes.length - failed.length, + failed, + outcomes + }; + } finally { + this.flushing = false; + } + } +} diff --git a/playwright/utils/naming.spec.ts b/playwright/utils/naming.spec.ts index d41e066041..1c35303c0e 100644 --- a/playwright/utils/naming.spec.ts +++ b/playwright/utils/naming.spec.ts @@ -95,11 +95,7 @@ test.describe('generateE2EName() format', () => { // generateE2EName() — shard resolution // ───────────────────────────────────────────────────────────────────── -// Serial mode is required here because three tests mutate the shared -// process.env.TEST_PARALLEL_INDEX. Even though fullyParallel is not -// currently enabled, being explicit prevents a future config change -// from introducing a hard-to-diagnose race on process.env. -test.describe.serial('generateE2EName() shard resolution', () => { +test.describe('generateE2EName() shard resolution', () => { test('falls back to TEST_PARALLEL_INDEX when no shard option is passed', () => { const original = process.env.TEST_PARALLEL_INDEX; process.env.TEST_PARALLEL_INDEX = '7';