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
108 changes: 107 additions & 1 deletion playwright/factories/client.factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
*/

import { test, expect } from '../fixtures/test-fixtures';
import { createTestClient, DEFAULT_TEST_CLIENT_LASTNAME } from './client.factory';
import {
createTestClient,
createActiveTestClient,
DEFAULT_TEST_CLIENT_LASTNAME,
DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE,
DEFAULT_TEST_CLIENT_ACTIVATION_DATE,
DEFAULT_ACCOUNT_OPENING_DATE
} from './client.factory';
import { E2E_NAME_PATTERN } from '../utils/naming';

// Live-backend specs — run under the `integration` Playwright project
Expand Down Expand Up @@ -71,3 +78,102 @@ test.describe('createTestClient() against live Fineract', () => {
await expect(apiSetup.api.getClient(client.resourceId)).rejects.toThrow(/404|not found/i);
});
});

test.describe('createActiveTestClient() against live Fineract', () => {
test('creates a client already in Active status', async ({ apiSetup, cleanupGuard }) => {
const client = await createActiveTestClient(apiSetup, cleanupGuard);

const fetched = await apiSetup.api.getClient(client.resourceId);
expect(fetched.id).toBe(client.resourceId);
expect(fetched.active).toBe(true);
expect(fetched.status?.value).toBe('Active');
// The activation date must land one day after submission — this is
// the ordering every downstream account/charge factory relies on.
expect(fetched.timeline?.submittedOnDate).toEqual([
2024,
1,
1
]);
expect(fetched.timeline?.activatedOnDate).toEqual([
2024,
1,
2
]);
});

test('rejects an activation date that precedes submission', async ({ apiSetup, cleanupGuard }) => {
// Fails locally, before any HTTP request, so the error names the
// offending fields instead of surfacing as an opaque Fineract
// validation message.
await expect(
createActiveTestClient(apiSetup, cleanupGuard, {
submittedOnDate: '10 January 2024',
activationDate: '05 January 2024'
})
).rejects.toThrow(/activationDate .* must not precede submittedOnDate/);
expect(cleanupGuard.size()).toBe(0);
});

test('rejects an unparseable date literal', async ({ apiSetup, cleanupGuard }) => {
await expect(createActiveTestClient(apiSetup, cleanupGuard, { activationDate: 'not-a-date' })).rejects.toThrow(
/unable to parse dates/
);
expect(cleanupGuard.size()).toBe(0);
});

test('rejects a calendar-invalid date that Date.parse would have rolled over', async ({ apiSetup, cleanupGuard }) => {
// '31 February 2024' is not a real date. Date.parse tolerates some
// overflow forms; the strict parser must reject it locally rather
// than let it reach Fineract.
await expect(
createActiveTestClient(apiSetup, cleanupGuard, { activationDate: '31 February 2024' })
).rejects.toThrow(/unable to parse dates/);
expect(cleanupGuard.size()).toBe(0);
});

test('rejects a non-dd-MMMM-yyyy format Date.parse would accept', async ({ apiSetup, cleanupGuard }) => {
// ISO '2024-01-05' parses fine under Date.parse but is not the
// Fineract wire format, so the strict guard must reject it.
await expect(createActiveTestClient(apiSetup, cleanupGuard, { activationDate: '2024-01-05' })).rejects.toThrow(
/unable to parse dates/
);
expect(cleanupGuard.size()).toBe(0);
});

test('exposes an account-opening date after the activation date', () => {
// Guards the constant chain itself: a future edit that bumps the
// activation date past the account-opening date would silently
// break every Track B/C factory that defaults to it.
expect(Date.parse(DEFAULT_ACCOUNT_OPENING_DATE)).toBeGreaterThan(Date.parse(DEFAULT_TEST_CLIENT_ACTIVATION_DATE));
expect(Date.parse(DEFAULT_TEST_CLIENT_ACTIVATION_DATE)).toBeGreaterThan(
Date.parse(DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE)
);
});

test('extra cannot override the validated active-client invariants', async ({ apiSetup, cleanupGuard }) => {
// `createTestClient` spreads `extra` after its own defaults, so an
// earlier implementation let `extra` replace `submittedOnDate` —
// meaning the date-order guard validated one pair while Fineract
// received another. The invariants must win.
const client = await createActiveTestClient(apiSetup, cleanupGuard, {
extra: {
active: false,
activationDate: '31 December 2023',
submittedOnDate: '20 January 2024'
}
});

const fetched = await apiSetup.api.getClient(client.resourceId);
expect(fetched.active).toBe(true);
expect(fetched.timeline?.submittedOnDate).toEqual([
2024,
1,
1
]);
expect(fetched.timeline?.activatedOnDate).toEqual([
2024,
1,
2
]);
});
});
195 changes: 195 additions & 0 deletions playwright/factories/client.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,28 @@ 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';

/**
* Default activation date for {@link createActiveTestClient}.
*
* Deliberately one day AFTER {@link DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE}:
* Fineract rejects an activation that predates submission, and the
* resulting error message names neither field, so an accidental
* inversion surfaces as an opaque validation failure.
*/
export const DEFAULT_TEST_CLIENT_ACTIVATION_DATE = '02 January 2024';

/**
* Earliest date a downstream account or charge may safely use.
*
* Loan applications, savings applications, and client charges are all
* rejected by Fineract when their own date precedes the owning
* client's activation date. Factories in Tracks B/C default their
* `submittedOnDate` to this constant so the whole chain
* (submitted -> activated -> account/charge) is monotonic by
* construction rather than by each spec author remembering the rule.
*/
export const DEFAULT_ACCOUNT_OPENING_DATE = '03 January 2024';

/** Fineract `legalFormId` for an individual person. */
const LEGAL_FORM_PERSON = 1;

Expand Down Expand Up @@ -135,3 +157,176 @@ export async function createTestClient(
officeId
};
}

/** Caller-supplied tweaks to the default active-client payload. */
export interface CreateActiveTestClientOverrides extends Omit<CreateTestClientOverrides, 'extra'> {
/**
* Override the default activation date. MUST NOT precede
* `submittedOnDate` — Fineract rejects the create outright.
*/
activationDate?: string;
/**
* Extra payload fields merged BEFORE this factory's own invariants.
*
* `active`, `activationDate`, and `submittedOnDate` are always
* applied last and cannot be overridden here — they are the fields
* this factory validates, and letting `extra` replace them would
* mean validating one value while sending another to Fineract. Use
* the dedicated `submittedOnDate` / `activationDate` options
* instead, which go through {@link assertDateNotBefore}.
*/
extra?: Record<string, unknown>;
}

/**
* Create an ACTIVE client owned by the current test and queue its
* deletion on the supplied {@link CleanupGuard}.
*
* Why this exists as a separate factory rather than an
* `{ active: true }` flag on {@link createTestClient}: every flow that
* opens a loan account, opens a savings account, or applies a client
* charge requires an active client, and each of those flows carries a
* date-ordering constraint relative to the activation date. Encoding
* the chain once here keeps ~20 downstream specs from re-deriving it.
*
* Cleanup caveat: Fineract only hard-deletes clients that are pending
* with no attached accounts. The deleter registered here will
* therefore FAIL for an active client, and the {@link CleanupGuard}
* will report it in `summary.failed` without throwing. That is
* deliberate and accepted — E2E databases are ephemeral, and the
* `E2E_` name prefix keeps orphans greppable. The registration is
* kept (rather than skipped) so that a test which happens to leave the
* client account-free still gets it removed.
*
* @param setup The per-test {@link ApiSetupManager}.
* @param guard The per-test {@link CleanupGuard}.
* @param overrides See {@link CreateActiveTestClientOverrides}.
* @returns A {@link TestClient} projection. No follow-up GET is
* issued; callers needing the activation timeline should
* call `setup.api.getClient(id)` themselves.
* @throws When `activationDate` precedes `submittedOnDate`, so the
* failure names the offending fields instead of surfacing as
* an opaque Fineract validation error.
*/
export async function createActiveTestClient(
setup: ApiSetupManager,
guard: CleanupGuard,
overrides: CreateActiveTestClientOverrides = {}
): Promise<TestClient> {
const submittedOnDate = overrides.submittedOnDate ?? DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE;
const activationDate = overrides.activationDate ?? DEFAULT_TEST_CLIENT_ACTIVATION_DATE;

assertDateNotBefore(activationDate, submittedOnDate, 'createActiveTestClient', 'activationDate', 'submittedOnDate');

return createTestClient(setup, guard, {
firstname: overrides.firstname,
lastname: overrides.lastname,
officeId: overrides.officeId,
submittedOnDate,
extra: {
// Caller extras first, invariants last. `createTestClient`
// spreads this object after its own defaults, so anything left
// in here wins — including `submittedOnDate`. Ordering the
// spread this way keeps the three validated fields
// authoritative: otherwise the guard above could pass on one
// date pair while Fineract received another.
...overrides.extra,
active: true,
activationDate,
submittedOnDate
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}

/**
* Guard that `later` is not chronologically before `earlier`.
*
* Both inputs must use Fineract's `dd MMMM yyyy` wire format (e.g.
* `'01 January 2024'`). Parsing goes through {@link parseFineractDate}
* rather than `Date.parse`, whose support for this format is
* implementation-defined and which would also accept unrelated
* date-time strings — either of which could let a malformed literal
* slip past this local check and fail only after the API request.
*/
function assertDateNotBefore(
later: string,
earlier: string,
caller: string,
laterLabel: string,
earlierLabel: string
): void {
const laterMs = parseFineractDate(later);
const earlierMs = parseFineractDate(earlier);

if (laterMs === null || earlierMs === null) {
throw new Error(
`${caller}: unable to parse dates — ${laterLabel}='${later}', ${earlierLabel}='${earlier}'. ` +
`Expected Fineract's 'dd MMMM yyyy' format, e.g. '01 January 2024'.`
);
}

if (laterMs < earlierMs) {
throw new Error(
`${caller}: ${laterLabel} ('${later}') must not precede ${earlierLabel} ('${earlier}') — ` +
`Fineract rejects the create with a validation error that names neither field.`
);
}
}

/**
* The twelve English month names Fineract emits and accepts in its
* `dd MMMM yyyy` format, indexed 0-11 to match `Date.UTC`.
*/
const FINERACT_MONTHS = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december'
];

/**
* Strictly parse a Fineract `dd MMMM yyyy` date into epoch ms (UTC).
*
* Deliberately narrow: it accepts only `<1-2 digit day> <full month
* name> <4-digit year>` and validates that the day is real for the
* given month/year (rejecting e.g. `'31 February 2024'`). Anything
* else returns `null`, so a malformed literal is caught here rather
* than by an opaque Fineract 400 after the request goes out.
*
* @param value - The candidate date string.
* @returns Epoch milliseconds, or `null` when `value` is not a valid
* `dd MMMM yyyy` date.
*/
function parseFineractDate(value: string): number | null {
const match = /^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/.exec(value.trim());
if (!match) {
return null;
}

const day = Number(match[1]);
const monthIndex = FINERACT_MONTHS.indexOf(match[2].toLowerCase());
const year = Number(match[3]);

if (monthIndex === -1) {
return null;
}

const ms = Date.UTC(year, monthIndex, day);
// Reject overflow dates (e.g. day 31 in a 30-day month): Date.UTC
// would roll them into the next month, so a round-trip mismatch
// means the input was not a real calendar date.
const roundTrip = new Date(ms);
if (roundTrip.getUTCFullYear() !== year || roundTrip.getUTCMonth() !== monthIndex || roundTrip.getUTCDate() !== day) {
return null;
}

return ms;
}
39 changes: 38 additions & 1 deletion playwright/factories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,50 @@
* The barrel keeps spec files insulated from factory file moves and lets
* the React counterpart export an identically-shaped module — making the
* cross-framework portability swap a path-only change.
*
* Two flavours of factory live behind this barrel, and the distinction
* matters:
*
* - **API factories** (`client.factory.ts`) hit Fineract REST, return
* a real `resourceId`, and register their own teardown on the
* per-test `CleanupGuard`. Use these to arrange preconditions.
* - **Payload builders** (`client.ts`) are pure and never touch the
* network. They produce form-shaped data for driving a page object
* through the UI.
*
* Both modules previously exported a function called `createTestClient`
* with incompatible signatures, and only the pure builder was reachable
* through this barrel — so `import { createTestClient } from '../../factories'`
* and `import { createTestClient } from '../../factories/client.factory'`
* silently resolved to different functions. The pure builder is now
* re-exported as `buildTestClientPayload` to make the distinction
* explicit at the import site.
*/

// ── API factories (network + cleanup registration) ─────────────────

export {
createTestClient,
createActiveTestClient,
DEFAULT_TEST_CLIENT_LASTNAME,
DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE,
DEFAULT_TEST_CLIENT_ACTIVATION_DATE,
DEFAULT_ACCOUNT_OPENING_DATE,
type CreateTestClientOverrides,
type CreateActiveTestClientOverrides
} from './client.factory';

// ── Pure payload builders (no network) ─────────────────────────────

export {
createTestClient as buildTestClientPayload,
createSeededClient,
type TestClientPayload,
type ClientState,
type CreateTestClientOverrides,
type CreateTestClientOverrides as BuildTestClientPayloadOverrides,
type SeededTestClient
} from './client';

// ── Shared resolvers ───────────────────────────────────────────────

export { resolveDefaultOfficeId, FIRST_OFFICE_CACHE_KEY } from './_shared';
Loading
Loading