Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...).
Expand All @@ -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/,
Expand Down
40 changes: 40 additions & 0 deletions playwright/factories/_shared.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
return setup.dedupe(FIRST_OFFICE_CACHE_KEY, () => setup.api.getFirstOfficeId());
}
73 changes: 73 additions & 0 deletions playwright/factories/client.factory.spec.ts
Original file line number Diff line number Diff line change
@@ -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 `<generatedFirstname> <lastname>` —
// 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);
});
});
137 changes: 137 additions & 0 deletions playwright/factories/client.factory.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

/**
* 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<TestClient> {
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<string, unknown> = {
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
};
}
66 changes: 66 additions & 0 deletions playwright/factories/group.factory.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
});
Comment on lines +40 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Override name can collide across parallel workers.

OverrideGroup_${Date.now()} relies solely on a millisecond timestamp, with no random component. Since group names are unique within an office in Fineract (see group.factory.ts Lines 20-22), two workers executing this test in the same millisecond would collide and the second createGroup would fail, producing flaky runs. Add a random suffix to make the override deterministically unique.

🔧 Proposed fix
-    const overrideName = `OverrideGroup_${Date.now()}`;
+    const overrideName = `OverrideGroup_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;

As per path instructions: tests should have "minimal brittle timing dependencies".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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'
});
// Use a timestamp suffix so the override is still unique enough
// not to clash with a previous test run.
const overrideName = `OverrideGroup_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const group = await createTestGroup(apiSetup, cleanupGuard, {
name: overrideName,
submittedOnDate: '15 March 2024'
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@playwright/factories/group.factory.spec.ts` around lines 40 - 46, The
override name in group.factory.spec.ts is only timestamp-based, so parallel
workers can still generate the same group name and collide with the unique-name
constraint in createTestGroup/createGroup. Update the overrideName construction
in the test to include a random suffix in addition to Date.now(), keeping it
unique per worker without relying on millisecond timing. Use the existing
createTestGroup call site and the overrideName variable as the place to make
this change.

Source: Path instructions

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);
});
});
Loading
Loading