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/types/test-data.types.ts b/playwright/types/test-data.types.ts new file mode 100644 index 0000000000..cfd9759b38 --- /dev/null +++ b/playwright/types/test-data.types.ts @@ -0,0 +1,104 @@ +/** + * 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/. + */ + +/** + * Portable test-data type surface for Playwright E2E factories. + * + * Design goals (per GSoC 2026 proposal WA-2.8): + * - Capture the minimum shape every test factory and page object + * needs from a freshly-created Fineract entity — `resourceId` to + * address it in subsequent API calls, `displayName` to assert it + * appears in the UI, and the narrow status/timeline projection + * actually inspected by E2E specs. + * - Stay deliberately narrower than the full Fineract REST payload. + * The platform response carries dozens of fields tests never read; + * typing them here would tie this file to a Fineract version and + * defeat the portability goal. + * - Zero imports. The file is intentionally dependency-free so the + * React port (`mifos-x-web-app-react`) can copy it verbatim — same + * shapes everywhere means factory output is interchangeable across + * the Angular and React Playwright suites. + * + * Field-naming convention follows the Fineract create-* response + * envelope (`resourceId`, `officeId`, ...). The optional `timeline` + * sub-shapes match the Fineract `ClientTimeline` / `LoanTimeline` + * projections, with every field optional because not every test path + * activates / approves / disburses the entity. + * + * Note: `getLoanTemplate()` and other domain-specific helpers on + * `ApiSetupManager` land in PR-3 alongside the factory functions + * that consume them. The `TestLoan` shape is included here because + * the naming utility in this PR already needs a place for loan + * factories in PR-3 to project into without churn. + */ + +/** Identifier projection shared by every freshly-created Fineract entity. */ +export interface TestEntityIdentity { + /** Numeric primary key returned by the create-* endpoint. */ + resourceId: number; + /** Human-readable label shown in the UI. */ + displayName: string; +} + +/** Two-field status projection used in UI assertions. */ +export interface TestStatus { + id: number; + code: string; + value: string; +} + +/** Narrow client-timeline projection — every field optional. */ +export interface TestClientTimeline { + submittedOnDate?: readonly number[]; + activatedOnDate?: readonly number[]; + closedOnDate?: readonly number[]; +} + +/** Narrow loan-timeline projection — every field optional. */ +export interface TestLoanTimeline { + submittedOnDate?: readonly number[]; + approvedOnDate?: readonly number[]; + disbursedOnDate?: readonly number[]; + closedOnDate?: readonly number[]; +} + +/** Created Fineract client as seen by E2E specs. */ +export interface TestClient extends TestEntityIdentity { + officeId: number; + status?: TestStatus; + timeline?: TestClientTimeline; +} + +/** Created Fineract group as seen by E2E specs. */ +export interface TestGroup extends TestEntityIdentity { + officeId: number; + status?: TestStatus; + /** Member client `resourceId`s, if the group was created with members. */ + members?: readonly number[]; +} + +/** Created Fineract user as seen by E2E specs. */ +export interface TestUser { + resourceId: number; + username: string; + /** Optional — not every test path exercises an email-bearing user. */ + email?: string; + /** Role names assigned at creation time. */ + roles?: readonly string[]; + officeId: number; +} + +/** Created Fineract loan as seen by E2E specs. */ +export interface TestLoan extends TestEntityIdentity { + clientId: number; + loanProductId: number; + /** Principal in the loan's minor currency unit. */ + principal: number; + status?: TestStatus; + timeline?: TestLoanTimeline; +} diff --git a/playwright/utils/api-setup-manager.spec.ts b/playwright/utils/api-setup-manager.spec.ts new file mode 100644 index 0000000000..d2d0354559 --- /dev/null +++ b/playwright/utils/api-setup-manager.spec.ts @@ -0,0 +1,370 @@ +/** + * 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 type { FineractApiClient } from '../fixtures/fineract-api'; +import { ApiSetupManager, ApiSetupManagerError } from './api-setup-manager'; + +// 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. The `FineractApiClient` is a +// stand-in: `ApiSetupManager` never calls any of its methods today, +// it only holds the reference for PR-3 factory wiring. +test.use({ storageState: { cookies: [], origins: [] } }); + +/** + * Build a fresh manager with an isolated cache so each `test()` is + * independent of every other (the production cache is module-level + * and would leak state across cases). + */ +function freshManager(): ApiSetupManager { + const stubClient = {} as unknown as FineractApiClient; + return new ApiSetupManager(stubClient, { cache: new Map() }); +} + +/** + * Deferred promise helper — lets a test control exactly when an + * in-flight `dedupe()` call settles. Mirrors the + * Promise-with-resolvers idiom without depending on the Node 22 + * built-in. + */ +function defer(): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// ───────────────────────────────────────────────────────────────────── +// dedupe() — input validation +// ───────────────────────────────────────────────────────────────────── + +test.describe('ApiSetupManager.dedupe() input contract', () => { + test('throws ApiSetupManagerError on an empty key', () => { + const manager = freshManager(); + expect(() => manager.dedupe('', async () => 1)).toThrow(ApiSetupManagerError); + expect(() => manager.dedupe('', async () => 1)).toThrow(/non-empty/); + }); + + test('exposes the api client read-only for PR-3 closure access', () => { + const stubClient = {} as unknown as FineractApiClient; + const manager = new ApiSetupManager(stubClient, { cache: new Map() }); + expect(manager.api).toBe(stubClient); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// dedupe() — in-flight sharing + cache hit +// ───────────────────────────────────────────────────────────────────── + +test.describe('ApiSetupManager.dedupe() in-flight sharing', () => { + test('two concurrent callers receive the same Promise instance', async () => { + const manager = freshManager(); + let calls = 0; + const fn = async (): Promise => { + calls++; + // Stay pending — we are asserting reference equality, not value. + return new Promise(() => {}); + }; + const p1 = manager.dedupe('k', fn); + const p2 = manager.dedupe('k', fn); + // Reference equality — the second caller attaches to the first + // caller's in-flight promise rather than creating a new one. + expect(p2).toBe(p1); + // Drain microtasks so the `Promise.resolve().then(fn)` chain + // inside `dedupe` actually invokes `fn` — only then can we + // assert the call count. + await Promise.resolve(); + expect(calls).toBe(1); + }); + + test('the work function runs exactly once across concurrent and serial callers', async () => { + const manager = freshManager(); + let calls = 0; + const fn = async (): Promise => { + calls++; + return 'value'; + }; + + const [ + a, + b + ] = await Promise.all([ + manager.dedupe('k', fn), + manager.dedupe('k', fn) + ]); + const c = await manager.dedupe('k', fn); + + expect(a).toBe('value'); + expect(b).toBe('value'); + expect(c).toBe('value'); + expect(calls).toBe(1); + }); + + test('different keys each fire their own work function', async () => { + const manager = freshManager(); + const calls: string[] = []; + const make = (id: string) => async (): Promise => { + calls.push(id); + return id; + }; + + const [ + a, + b + ] = await Promise.all([ + manager.dedupe('alpha', make('alpha')), + manager.dedupe('beta', make('beta')) + ]); + expect(a).toBe('alpha'); + expect(b).toBe('beta'); + expect(calls.sort()).toEqual([ + 'alpha', + 'beta' + ]); + }); + + test('serial callers after resolution receive the cached value without rerunning fn', async () => { + const manager = freshManager(); + let calls = 0; + const fn = async (): Promise => { + calls++; + return 42; + }; + + expect(await manager.dedupe('k', fn)).toBe(42); + expect(await manager.dedupe('k', fn)).toBe(42); + expect(await manager.dedupe('k', fn)).toBe(42); + expect(calls).toBe(1); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// dedupe() — reject eviction (default) + retainErrors opt-in +// ───────────────────────────────────────────────────────────────────── + +test.describe('ApiSetupManager.dedupe() reject handling', () => { + test('evicts a rejected promise so the next caller fires fresh fn()', async () => { + const manager = freshManager(); + let calls = 0; + const flakyThenStable = async (): Promise => { + calls++; + if (calls === 1) throw new Error('transient blip'); + return 'recovered'; + }; + + await expect(manager.dedupe('k', flakyThenStable)).rejects.toThrow(/transient blip/); + // Second call must execute a fresh attempt because the cache + // entry for the rejected promise was evicted. + expect(await manager.dedupe('k', flakyThenStable)).toBe('recovered'); + expect(calls).toBe(2); + }); + + test('concurrent callers all observe the same rejection from a single fn() invocation', async () => { + const manager = freshManager(); + let calls = 0; + const failing = async (): Promise => { + calls++; + throw new Error('boom'); + }; + + const results = await Promise.allSettled([ + manager.dedupe('k', failing), + manager.dedupe('k', failing), + manager.dedupe('k', failing) + ]); + + expect(calls).toBe(1); + expect(results.every((r) => r.status === 'rejected')).toBe(true); + expect(results.every((r) => r.status === 'rejected' && /boom/.test((r.reason as Error).message))).toBe(true); + }); + + test('retainErrors: true pins the rejection so subsequent callers fast-fail', async () => { + const manager = freshManager(); + let calls = 0; + const failing = async (): Promise => { + calls++; + throw new Error('permanent: template 999 not found'); + }; + + await expect(manager.dedupe('k', failing, { retainErrors: true })).rejects.toThrow(/template 999/); + await expect(manager.dedupe('k', failing, { retainErrors: true })).rejects.toThrow(/template 999/); + expect(calls).toBe(1); + }); + + test('does not overwrite a later successful retry that filled the same key', async () => { + // Regression guard for the eviction race: the catch handler on + // a rejected promise must only evict if the slot still holds + // *that* promise — a successful retry filling the slot first + // must survive. + const manager = freshManager(); + const failing = defer(); + const succeeding = defer(); + + const callsLog: string[] = []; + const p1 = manager.dedupe('k', () => { + callsLog.push('first'); + return failing.promise; + }); + + // Reject the first promise; its `.catch` handler will run + // microtask-asynchronously and try to evict. + failing.reject(new Error('first-failed')); + await p1.catch((): void => undefined); + + // Now a retry fills the slot. If the eviction logic were + // unguarded, this entry would be nuked by the lingering + // `.catch` of the *first* call — assert the opposite. + const p2 = manager.dedupe('k', () => { + callsLog.push('second'); + return succeeding.promise; + }); + succeeding.resolve(7); + expect(await p2).toBe(7); + + // Third call should hit the cache — no third fn invocation. + expect( + await manager.dedupe('k', () => { + callsLog.push('third'); + return Promise.resolve(99); + }) + ).toBe(7); + + expect(callsLog).toEqual([ + 'first', + 'second' + ]); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// dedupe() — error-shape handling +// ───────────────────────────────────────────────────────────────────── + +test.describe('ApiSetupManager.dedupe() fn error shapes', () => { + test('converts a synchronously-thrown fn into a rejected promise', async () => { + const manager = freshManager(); + const fn = (): Promise => { + throw new Error('sync boom'); + }; + + await expect(manager.dedupe('k', fn)).rejects.toThrow(/sync boom/); + // And eviction still works for sync throws. + let secondCalls = 0; + const fn2 = async (): Promise => { + secondCalls++; + return 1; + }; + expect(await manager.dedupe('k', fn2)).toBe(1); + expect(secondCalls).toBe(1); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// clearCache() — escape hatch +// ───────────────────────────────────────────────────────────────────── + +test.describe('ApiSetupManager.clearCache()', () => { + test('forces the next caller to fire a fresh fn() invocation', async () => { + const manager = freshManager(); + let calls = 0; + const fn = async (): Promise => { + calls++; + return calls; + }; + + expect(await manager.dedupe('k', fn)).toBe(1); + expect(await manager.dedupe('k', fn)).toBe(1); // cache hit + manager.clearCache(); + expect(await manager.dedupe('k', fn)).toBe(2); // fresh invocation + expect(calls).toBe(2); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// Cache-key convention example — sorted URLSearchParams stability +// ───────────────────────────────────────────────────────────────────── + +test.describe('Cache-key convention (sorted URLSearchParams)', () => { + test('callers using sorted URLSearchParams hit the same cache entry regardless of insert order', async () => { + // This test does NOT call into FineractApiClient — it asserts the + // recommended keying convention from the api-setup-manager.ts + // docstring: callers that serialise their params via sorted + // `URLSearchParams.toString()` produce identical keys even if + // they appended the params in different orders. + const manager = freshManager(); + let calls = 0; + const fn = async (): Promise => { + calls++; + return 'template'; + }; + + const buildKey = (entries: ReadonlyArray<[ + string, + string + ]>): string => { + const params = new URLSearchParams(); + for (const [ + k, + v + ] of entries) { + params.set(k, v); + } + params.sort(); + return `loanTemplate:${params.toString()}`; + }; + + const key1 = buildKey([ + [ + 'clientId', + '1' + ], + [ + 'productId', + '2' + ], + [ + 'activeOnly', + 'true' + ] + ]); + const key2 = buildKey([ + [ + 'activeOnly', + 'true' + ], + [ + 'productId', + '2' + ], + [ + 'clientId', + '1' + ] + ]); + expect(key1).toBe(key2); + + const [ + a, + b + ] = await Promise.all([ + manager.dedupe(key1, fn), + manager.dedupe(key2, fn) + ]); + expect(a).toBe('template'); + expect(b).toBe('template'); + expect(calls).toBe(1); + }); +}); diff --git a/playwright/utils/api-setup-manager.ts b/playwright/utils/api-setup-manager.ts new file mode 100644 index 0000000000..2d0e17594c --- /dev/null +++ b/playwright/utils/api-setup-manager.ts @@ -0,0 +1,193 @@ +/** + * 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 type { FineractApiClient } from '../fixtures/fineract-api'; + +/** + * In-flight de-duplication + process-lifetime cache for shared + * Fineract setup calls (loan-product templates, code-value lookups, + * office lists, …). + * + * Design goals (per GSoC 2026 proposal WA-2.7): + * - When two parallel tests both call `getOrLoadLoanTemplate(1)` + * within the same process, the second caller MUST attach to the + * in-flight `Promise` from the first caller instead of firing a + * duplicate HTTP request. Once that promise resolves, every + * subsequent caller for the same key gets the cached value + * synchronously for the lifetime of the process. + * - The cache is keyed by an opaque string so callers pick the + * granularity (e.g. `loanTemplate:clientId=1&productId=2`). The + * convention is to use a stable serialisation — sorted + * `URLSearchParams.toString()` is recommended for endpoints that + * already build one — so parameter-order changes do not produce + * cache misses. + * - Rejected promises are evicted from the cache by default. A + * transient CI failure (network blip, half-open TCP) must not + * poison the whole suite by pinning a rejection on the key. + * Strict callers can opt out via `{ retainErrors: true }` when the + * failure is genuinely permanent (e.g. requested template id + * does not exist). + * + * Scope note: this PR ships only the generic `dedupe` primitive. + * Domain-specific wrappers (`getLoanTemplate`, `getClientTemplate`, + * `getCodeValues`, …) land in PR-3 alongside the factory functions + * that call them. The `FineractApiClient` reference is held now so + * those PR-3 wrappers can be added without touching the constructor + * signature or the fixture wiring. + * + * Portability note: the module imports nothing from `@playwright/test` + * and the cache is a plain `Map`. The React port can adopt this file + * verbatim once it has its own `FineractApiClient` (or a structural + * equivalent — `FineractApiClient` is only referenced as a type here). + */ + +/** + * Module-level cache. Lives for the lifetime of the Node process so + * it outlives every per-test `FineractApiClient` instance created by + * `playwright/fixtures/test-fixtures.ts`. Exported only via the + * {@link clearApiSetupCache} helper — direct access from outside the + * module is intentionally not supported. + */ +const TEMPLATE_CACHE: Map> = new Map(); + +/** Options passed to {@link ApiSetupManager.dedupe}. */ +export interface DedupeOptions { + /** + * When `true`, a rejected `fn()` promise is left in the cache so + * subsequent callers fast-fail with the same error. Defaults to + * `false` — the standard CI-friendly behaviour, matching the + * retry mindset of `playwright/utils/retry.ts`. + */ + retainErrors?: boolean; +} + +/** Constructor options for {@link ApiSetupManager}. */ +export interface ApiSetupManagerOptions { + /** + * Override the module-level cache. Mainly used by the unit specs + * to keep per-test state isolated; production callers should never + * pass this and instead rely on the singleton. + */ + cache?: Map>; +} + +/** + * Error thrown when {@link ApiSetupManager.dedupe} is called with an + * input that cannot be honoured (e.g. empty cache key). Carries no + * extra fields — the message is the contract, mirroring the style of + * `ReadinessProbeFailure` in `playwright/utils/readiness.ts`. + */ +export class ApiSetupManagerError extends Error { + constructor(message: string) { + super(message); + this.name = 'ApiSetupManagerError'; + } +} + +/** + * Wraps a per-test `FineractApiClient` with a shared cache so + * concurrent tests requesting the same template share one in-flight + * HTTP call. See module docstring for the design rationale. + * + * Usage (factory functions in PR-3 will look like this): + * + * ```ts + * const setup = new ApiSetupManager(fineractApi); + * const template = await setup.dedupe( + * `loanTemplate:${sortedParams}`, + * () => setup.api.getLoanTemplate(clientId, productId) + * ); + * ``` + */ +export class ApiSetupManager { + /** + * Public so PR-3 factory functions can call any + * `FineractApiClient` method inside their `dedupe(...)` closure + * without re-plumbing the client through every helper. + */ + public readonly api: FineractApiClient; + + private readonly cache: Map>; + + constructor(api: FineractApiClient, options: ApiSetupManagerOptions = {}) { + this.api = api; + this.cache = options.cache ?? TEMPLATE_CACHE; + } + + /** + * Run `fn()` at most once per `key` for the lifetime of the cache. + * + * Concurrent callers with the same key share the in-flight + * promise. Once that promise settles successfully, every later + * caller receives the same resolved value synchronously. + * + * Rejected promises are evicted by default — the next caller will + * fire a fresh `fn()`. Pass `{ retainErrors: true }` to keep the + * rejection cached and let subsequent callers fast-fail. + * + * @param key Opaque cache key. Choose a stable serialisation + * (e.g. sorted `URLSearchParams.toString()`) so + * callers with equivalent parameters hit the cache. + * @param fn Zero-argument factory that performs the work. Only + * invoked when there is no entry under `key`. + * @param opts See {@link DedupeOptions}. + * @throws {@link ApiSetupManagerError} when `key` is empty. + */ + dedupe(key: string, fn: () => Promise, opts: DedupeOptions = {}): Promise { + if (typeof key !== 'string' || key.length === 0) { + throw new ApiSetupManagerError('dedupe(key, fn): key must be a non-empty string'); + } + + const existing = this.cache.get(key); + if (existing !== undefined) { + return existing as Promise; + } + + const retainErrors = opts.retainErrors === true; + // Wrap `fn()` in a synchronous Promise constructor so a `fn` that + // throws synchronously is still converted into a rejected promise + // — same observable behaviour as if it had returned `Promise.reject`. + const pending = Promise.resolve().then(fn); + + if (!retainErrors) { + // Eviction must use the captured `pending` reference, not a + // fresh `this.cache.get(key)` lookup — a later caller could + // have replaced the entry with a successful retry, and we + // must not nuke that. + pending.catch(() => { + if (this.cache.get(key) === pending) { + this.cache.delete(key); + } + }); + } + + this.cache.set(key, pending); + return pending; + } + + /** + * Test-only escape hatch. Clears the cache backing this instance — + * useful for unit specs that want a clean slate per `test()` + * without recreating the manager. Production code should never + * need to call this. + */ + clearCache(): void { + this.cache.clear(); + } +} + +/** + * Reset the module-level cache. Intended for the unit specs in + * `api-setup-manager.spec.ts` and for any future helper that needs + * to nuke shared state between test files. Production callers should + * use `ApiSetupManager#clearCache` on an instance that owns its own + * injected cache instead. + */ +export function clearApiSetupCache(): void { + TEMPLATE_CACHE.clear(); +} 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 new file mode 100644 index 0000000000..1c35303c0e --- /dev/null +++ b/playwright/utils/naming.spec.ts @@ -0,0 +1,236 @@ +/** + * 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 { + E2E_NAME_PATTERN, + E2E_NAME_PREFIX, + E2E_RANDOM_TOKEN_LENGTH, + E2ENameGenerationError, + generateE2EName +} from './naming'; + +// 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. Every dynamic dependency +// (clock, RNG, shard) is injected so assertions are deterministic. +test.use({ storageState: { cookies: [], origins: [] } }); + +// ───────────────────────────────────────────────────────────────────── +// naming.ts — exported constants +// ───────────────────────────────────────────────────────────────────── + +test.describe('naming constants', () => { + test('exports the documented prefix and token length', () => { + expect(E2E_NAME_PREFIX).toBe('E2E'); + expect(E2E_RANDOM_TOKEN_LENGTH).toBe(6); + }); + + test('E2E_NAME_PATTERN matches the documented format', () => { + expect('E2E_S0_1750000000000_abc123').toMatch(E2E_NAME_PATTERN); + expect('E2E_client_S3_1750000000000_zz9999').toMatch(E2E_NAME_PATTERN); + // Non-matches: missing prefix, uppercase token, missing shard, … + expect('S0_1750000000000_abc123').not.toMatch(E2E_NAME_PATTERN); + expect('E2E_S0_1750000000000_ABCDEF').not.toMatch(E2E_NAME_PATTERN); + expect('E2E_1750000000000_abc123').not.toMatch(E2E_NAME_PATTERN); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// generateE2EName() — format and injection contract +// ───────────────────────────────────────────────────────────────────── + +test.describe('generateE2EName() format', () => { + test('produces a name that matches E2E_NAME_PATTERN with all defaults', () => { + const name = generateE2EName(); + expect(name).toMatch(E2E_NAME_PATTERN); + expect(name.startsWith(`${E2E_NAME_PREFIX}_S`)).toBe(true); + }); + + test('embeds the injected shard, timestamp, and random token verbatim', () => { + const name = generateE2EName(undefined, { + shard: 3, + now: () => 1_750_000_000_000, + random: () => 0.123456789 + }); + // (0.123456789).toString(36) === '0.4fzzzxjylrx' — first 6 chars after '0.' + expect(name).toBe('E2E_S3_1750000000000_4fzzzx'); + }); + + test('prepends the sanitised prefix between E2E_ and S{shard}', () => { + const name = generateE2EName('client', { + shard: 0, + now: () => 1_750_000_000_000, + random: () => 0.5 + }); + expect(name.startsWith('E2E_client_S0_1750000000000_')).toBe(true); + expect(name).toMatch(E2E_NAME_PATTERN); + }); + + test('strips disallowed characters from the prefix', () => { + const name = generateE2EName('loan-product!@#', { + shard: 0, + now: () => 1, + random: () => 0.5 + }); + expect(name.startsWith('E2E_loanproduct_S0_1_')).toBe(true); + }); + + test('treats an all-symbol prefix the same as no prefix', () => { + const name = generateE2EName('!@#$', { + shard: 0, + now: () => 1, + random: () => 0.5 + }); + expect(name.startsWith('E2E_S0_1_')).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// 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'; + try { + const name = generateE2EName(undefined, { + now: () => 1, + random: () => 0.5 + }); + expect(name.startsWith('E2E_S7_1_')).toBe(true); + } finally { + if (original === undefined) { + delete process.env.TEST_PARALLEL_INDEX; + } else { + process.env.TEST_PARALLEL_INDEX = original; + } + } + }); + + test('falls back to "0" when neither option nor env var is set', () => { + const original = process.env.TEST_PARALLEL_INDEX; + delete process.env.TEST_PARALLEL_INDEX; + try { + const name = generateE2EName(undefined, { + now: () => 1, + random: () => 0.5 + }); + expect(name.startsWith('E2E_S0_1_')).toBe(true); + } finally { + if (original !== undefined) { + process.env.TEST_PARALLEL_INDEX = original; + } + } + }); + + test('sanitises a malformed env value to digits-only', () => { + const original = process.env.TEST_PARALLEL_INDEX; + process.env.TEST_PARALLEL_INDEX = 'w-12'; + try { + const name = generateE2EName(undefined, { + now: () => 1, + random: () => 0.5 + }); + expect(name.startsWith('E2E_S12_1_')).toBe(true); + } finally { + if (original === undefined) { + delete process.env.TEST_PARALLEL_INDEX; + } else { + process.env.TEST_PARALLEL_INDEX = original; + } + } + }); + + test('accepts a numeric shard option and truncates fractional values', () => { + const name = generateE2EName(undefined, { + shard: 2.9, + now: () => 1, + random: () => 0.5 + }); + expect(name.startsWith('E2E_S2_1_')).toBe(true); + }); + + test('floors a negative shard to 0 rather than embedding the minus sign', () => { + const name = generateE2EName(undefined, { + shard: -4, + now: () => 1, + random: () => 0.5 + }); + expect(name.startsWith('E2E_S0_1_')).toBe(true); + }); +}); + +// ───────────────────────────────────────────────────────────────────── +// generateE2EName() — uniqueness + edge cases +// ───────────────────────────────────────────────────────────────────── + +test.describe('generateE2EName() uniqueness', () => { + test('produces 100 unique names with a fixed clock when RNG varies', () => { + const fixedNow = (): number => 1_750_000_000_000; + const seen = new Set(); + let counter = 0; + const rng = (): number => { + counter++; + return counter / 1000; // 0.001, 0.002, … 0.100 — all distinct + }; + + for (let i = 0; i < 100; i++) { + seen.add(generateE2EName(undefined, { now: fixedNow, random: rng })); + } + + expect(seen.size).toBe(100); + }); + + test('always emits a token of exactly E2E_RANDOM_TOKEN_LENGTH characters', () => { + // Worst-case input: random() returns a value so small that + // `.toString(36)` produces almost nothing — the sanitiser must + // still pad to the configured length. + const name = generateE2EName(undefined, { + shard: 0, + now: () => 1, + random: () => 0 + }); + const token = name.split('_').slice(-1)[0]; + expect(token).toHaveLength(E2E_RANDOM_TOKEN_LENGTH); + }); + + test('honours a custom randomTokenLength', () => { + const name = generateE2EName(undefined, { + shard: 0, + now: () => 1, + random: () => 0.5, + randomTokenLength: 3 + }); + const token = name.split('_').slice(-1)[0]; + expect(token).toHaveLength(3); + }); + + test('throws E2ENameGenerationError on an invalid randomTokenLength', () => { + expect(() => + generateE2EName(undefined, { + randomTokenLength: 0 + }) + ).toThrow(E2ENameGenerationError); + expect(() => + generateE2EName(undefined, { + randomTokenLength: 99 + }) + ).toThrow(/randomTokenLength/); + }); + + test('clamps a fractional timestamp to an integer ms', () => { + const name = generateE2EName(undefined, { + shard: 0, + now: () => 1_750_000_000_000.9, + random: () => 0.5 + }); + expect(name.startsWith('E2E_S0_1750000000000_')).toBe(true); + }); +}); diff --git a/playwright/utils/naming.ts b/playwright/utils/naming.ts new file mode 100644 index 0000000000..3e9454f2d6 --- /dev/null +++ b/playwright/utils/naming.ts @@ -0,0 +1,174 @@ +/** + * 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/. + */ + +/** + * Shard-aware unique-name generator for Playwright E2E test data. + * + * Design goals (per GSoC 2026 proposal WA-2.8): + * - Every entity a test creates in Fineract (client, group, user, + * loan, …) must carry a name that is unique across a single CI + * run AND visibly recognisable as test data, so a stuck cleanup + * job never confuses production data with leftover E2E records. + * - Encode the worker/shard index in the name itself so logs from + * a flaky parallel run can be traced back to a specific worker + * without cross-referencing a separate report. + * - Be deterministic-under-injection. Every dynamic input — the + * clock, the RNG, the shard — is overridable via options so the + * unit specs can assert the exact output string without relying + * on `Date.now()` advancing or `Math.random()` cooperating. + * + * Output format: `E2E_S{shard}_{ts}_{rand}` where + * - `E2E_` is a fixed prefix used by cleanup grep patterns. + * - `S{shard}` is the Playwright worker index (or override). + * - `{ts}` is `Date.now()` (ms since epoch) for monotonicity. + * - `{rand}` is a 6-char base36 token from `Math.random()`, + * enough to defeat collisions within the same millisecond + * for the realistic worker counts Playwright supports. + * + * The optional `prefix` argument is sanitised and prepended *after* + * the `E2E_` marker so factory callers can pass a domain hint + * (`'client'`, `'group'`) without losing the cleanup grep anchor. + * + * Portability note: this module imports nothing. The React port can + * adopt it verbatim — `process.env.TEST_PARALLEL_INDEX` is set by + * the `@playwright/test` runtime regardless of host framework. + */ + +/** Fixed prefix used by cleanup tooling to identify E2E-owned data. */ +export const E2E_NAME_PREFIX = 'E2E'; + +/** Length of the random base36 token appended to each name. */ +export const E2E_RANDOM_TOKEN_LENGTH = 6; + +/** + * Regex describing the exact shape `generateE2EName` emits. Exported + * for the unit specs and for any future cleanup script that needs to + * match the full name surface — keep this in sync with the format + * documented at the top of the file. + */ +export const E2E_NAME_PATTERN = /^E2E(?:_[A-Za-z0-9]+)?_S\d+_\d+_[a-z0-9]+$/; + +/** Tuning knobs for {@link generateE2EName}. All fields optional. */ +export interface GenerateE2ENameOptions { + /** + * Override the shard / worker slot. Defaults to + * `process.env.TEST_PARALLEL_INDEX ?? '0'`. Non-numeric values are + * sanitised to digits-only so a malformed env var cannot produce a + * name that fails {@link E2E_NAME_PATTERN}. + */ + shard?: string | number; + /** + * Injectable wall-clock for tests. Defaults to `Date.now`. The + * returned value is rounded down to an integer ms so the formatted + * name is always digits-only. + */ + now?: () => number; + /** + * Injectable RNG returning a value in `[0, 1)`. Defaults to + * `Math.random`. The returned value is folded into a base36 token + * of {@link E2E_RANDOM_TOKEN_LENGTH} characters. + */ + random?: () => number; + /** + * Override the random-token length (max 11 — the practical ceiling + * of `Math.random().toString(36).slice(2)`). Mainly for tests. + */ + randomTokenLength?: number; +} + +/** + * Error thrown when {@link generateE2EName} cannot produce a valid + * name from its inputs (e.g. a negative `randomTokenLength`). Carries + * no extra fields — the message is the contract. + */ +export class E2ENameGenerationError extends Error { + constructor(message: string) { + super(message); + this.name = 'E2ENameGenerationError'; + } +} + +/** + * Build a unique, shard-tagged test-data name of the form + * `E2E_S{shard}_{ts}_{rand}` (or `E2E_{prefix}_S{shard}_{ts}_{rand}` + * when `prefix` is supplied). + * + * The function performs no I/O and never throws for the default + * argument shape. It throws {@link E2ENameGenerationError} only when + * the caller passes an invalid override (e.g. negative length). + * + * @param prefix Optional alphanumeric domain hint, e.g. `'client'`. + * Stripped to `[A-Za-z0-9]+`; an empty result is + * treated the same as omitting the prefix entirely. + * @param options Injectable inputs — see {@link GenerateE2ENameOptions}. + */ +export function generateE2EName(prefix?: string, options: GenerateE2ENameOptions = {}): string { + const randomTokenLength = options.randomTokenLength ?? E2E_RANDOM_TOKEN_LENGTH; + if (!Number.isInteger(randomTokenLength) || randomTokenLength < 1 || randomTokenLength > 11) { + throw new E2ENameGenerationError(`randomTokenLength must be an integer in [1, 11], got ${randomTokenLength}`); + } + + const now = options.now ?? Date.now; + const random = options.random ?? Math.random; + const ts = Math.max(0, Math.trunc(now())); + const shard = sanitiseShard(options.shard); + const rand = sanitiseRandomToken(random(), randomTokenLength); + const cleanedPrefix = sanitisePrefix(prefix); + + const segments: string[] = cleanedPrefix.length > 0 ? [ + E2E_NAME_PREFIX, + cleanedPrefix, + `S${shard}`, + String(ts), + rand + ] : [ + E2E_NAME_PREFIX, + `S${shard}`, + String(ts), + rand + ]; + + return segments.join('_'); +} + +/** + * Resolve the shard / worker slot. Precedence: + * 1. Explicit option (numbers stringified, strings digit-filtered). + * 2. `process.env.TEST_PARALLEL_INDEX` (set by Playwright runtime). + * 3. `'0'` as a safe fallback for unit specs and ad-hoc invocation. + */ +function sanitiseShard(input: GenerateE2ENameOptions['shard']): string { + if (typeof input === 'number' && Number.isFinite(input)) { + return String(Math.max(0, Math.trunc(input))); + } + const raw = typeof input === 'string' ? input : (process.env.TEST_PARALLEL_INDEX ?? '0'); + const digits = raw.replace(/[^0-9]/g, ''); + return digits.length > 0 ? digits : '0'; +} + +/** + * Fold a `[0, 1)` RNG draw into a fixed-length lower-case base36 token. + * `Math.random().toString(36).slice(2)` can occasionally yield a token + * shorter than expected (when the value is very small), so this helper + * pads with the next draw rather than emitting a name shorter than the + * pattern documents. + */ +function sanitiseRandomToken(value: number, length: number): string { + const normalised = Number.isFinite(value) && value >= 0 && value < 1 ? value : 0; + let token = normalised.toString(36).replace('0.', ''); + while (token.length < length) { + token += '0'; + } + return token.slice(0, length); +} + +/** Strip a caller-supplied prefix to safe `[A-Za-z0-9]` characters. */ +function sanitisePrefix(prefix: string | undefined): string { + if (!prefix) return ''; + return prefix.replace(/[^A-Za-z0-9]/g, ''); +}