Skip to content

Commit c142d53

Browse files
committed
WEB-1065: [Playwright] Active-client factory + sequential LIFO cleanup
CleanupGuard.flush() now awaits each deleter in turn instead of dispatching all of them concurrently via Promise.allSettled. LIFO ordering was previously only apparent: a child resource's DELETE could still reach Fineract after its parent's, producing an FK violation. Adds createActiveTestClient() with a monotonic date chain (submitted -> activated -> account opening) and a local pre-flight guard, so an inverted date fails with a message naming both fields rather than an opaque Fineract validation error. Fixes the factories barrel: client.ts and client.factory.ts both exported createTestClient with incompatible signatures and only the pure builder was reachable through the barrel. It is now buildTestClientPayload.
1 parent 1f3e6d2 commit c142d53

6 files changed

Lines changed: 293 additions & 27 deletions

File tree

playwright/factories/client.factory.spec.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@
77
*/
88

99
import { test, expect } from '../fixtures/test-fixtures';
10-
import { createTestClient, DEFAULT_TEST_CLIENT_LASTNAME } from './client.factory';
10+
import {
11+
createTestClient,
12+
createActiveTestClient,
13+
DEFAULT_TEST_CLIENT_LASTNAME,
14+
DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE,
15+
DEFAULT_TEST_CLIENT_ACTIVATION_DATE,
16+
DEFAULT_ACCOUNT_OPENING_DATE
17+
} from './client.factory';
1118
import { E2E_NAME_PATTERN } from '../utils/naming';
1219

1320
// Live-backend specs — run under the `integration` Playwright project
@@ -71,3 +78,56 @@ test.describe('createTestClient() against live Fineract', () => {
7178
await expect(apiSetup.api.getClient(client.resourceId)).rejects.toThrow(/404|not found/i);
7279
});
7380
});
81+
82+
test.describe('createActiveTestClient() against live Fineract', () => {
83+
test('creates a client already in Active status', async ({ apiSetup, cleanupGuard }) => {
84+
const client = await createActiveTestClient(apiSetup, cleanupGuard);
85+
86+
const fetched = await apiSetup.api.getClient(client.resourceId);
87+
expect(fetched.id).toBe(client.resourceId);
88+
expect(fetched.active).toBe(true);
89+
expect(fetched.status?.value).toBe('Active');
90+
// The activation date must land one day after submission — this is
91+
// the ordering every downstream account/charge factory relies on.
92+
expect(fetched.timeline?.submittedOnDate).toEqual([
93+
2024,
94+
1,
95+
1
96+
]);
97+
expect(fetched.timeline?.activatedOnDate).toEqual([
98+
2024,
99+
1,
100+
2
101+
]);
102+
});
103+
104+
test('rejects an activation date that precedes submission', async ({ apiSetup, cleanupGuard }) => {
105+
// Fails locally, before any HTTP request, so the error names the
106+
// offending fields instead of surfacing as an opaque Fineract
107+
// validation message.
108+
await expect(
109+
createActiveTestClient(apiSetup, cleanupGuard, {
110+
submittedOnDate: '10 January 2024',
111+
activationDate: '05 January 2024'
112+
})
113+
).rejects.toThrow(/activationDate .* must not precede submittedOnDate/);
114+
expect(cleanupGuard.size()).toBe(0);
115+
});
116+
117+
test('rejects an unparseable date literal', async ({ apiSetup, cleanupGuard }) => {
118+
await expect(createActiveTestClient(apiSetup, cleanupGuard, { activationDate: 'not-a-date' })).rejects.toThrow(
119+
/unable to parse dates/
120+
);
121+
expect(cleanupGuard.size()).toBe(0);
122+
});
123+
124+
test('exposes an account-opening date after the activation date', () => {
125+
// Guards the constant chain itself: a future edit that bumps the
126+
// activation date past the account-opening date would silently
127+
// break every Track B/C factory that defaults to it.
128+
expect(Date.parse(DEFAULT_ACCOUNT_OPENING_DATE)).toBeGreaterThan(Date.parse(DEFAULT_TEST_CLIENT_ACTIVATION_DATE));
129+
expect(Date.parse(DEFAULT_TEST_CLIENT_ACTIVATION_DATE)).toBeGreaterThan(
130+
Date.parse(DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE)
131+
);
132+
});
133+
});

playwright/factories/client.factory.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,28 @@ export const DEFAULT_TEST_CLIENT_LASTNAME = 'E2E';
4545
/** Default submitted-on date applied to every pending test client. */
4646
export const DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE = '01 January 2024';
4747

48+
/**
49+
* Default activation date for {@link createActiveTestClient}.
50+
*
51+
* Deliberately one day AFTER {@link DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE}:
52+
* Fineract rejects an activation that predates submission, and the
53+
* resulting error message names neither field, so an accidental
54+
* inversion surfaces as an opaque validation failure.
55+
*/
56+
export const DEFAULT_TEST_CLIENT_ACTIVATION_DATE = '02 January 2024';
57+
58+
/**
59+
* Earliest date a downstream account or charge may safely use.
60+
*
61+
* Loan applications, savings applications, and client charges are all
62+
* rejected by Fineract when their own date precedes the owning
63+
* client's activation date. Factories in Tracks B/C default their
64+
* `submittedOnDate` to this constant so the whole chain
65+
* (submitted -> activated -> account/charge) is monotonic by
66+
* construction rather than by each spec author remembering the rule.
67+
*/
68+
export const DEFAULT_ACCOUNT_OPENING_DATE = '03 January 2024';
69+
4870
/** Fineract `legalFormId` for an individual person. */
4971
const LEGAL_FORM_PERSON = 1;
5072

@@ -135,3 +157,105 @@ export async function createTestClient(
135157
officeId
136158
};
137159
}
160+
161+
/** Caller-supplied tweaks to the default active-client payload. */
162+
export interface CreateActiveTestClientOverrides extends Omit<CreateTestClientOverrides, 'extra'> {
163+
/**
164+
* Override the default activation date. MUST NOT precede
165+
* `submittedOnDate` — Fineract rejects the create outright.
166+
*/
167+
activationDate?: string;
168+
/**
169+
* Extra payload fields merged AFTER the defaults. Note that
170+
* `active` and `activationDate` are already set by this factory;
171+
* overriding them defeats its purpose.
172+
*/
173+
extra?: Record<string, unknown>;
174+
}
175+
176+
/**
177+
* Create an ACTIVE client owned by the current test and queue its
178+
* deletion on the supplied {@link CleanupGuard}.
179+
*
180+
* Why this exists as a separate factory rather than an
181+
* `{ active: true }` flag on {@link createTestClient}: every flow that
182+
* opens a loan account, opens a savings account, or applies a client
183+
* charge requires an active client, and each of those flows carries a
184+
* date-ordering constraint relative to the activation date. Encoding
185+
* the chain once here keeps ~20 downstream specs from re-deriving it.
186+
*
187+
* Cleanup caveat: Fineract only hard-deletes clients that are pending
188+
* with no attached accounts. The deleter registered here will
189+
* therefore FAIL for an active client, and the {@link CleanupGuard}
190+
* will report it in `summary.failed` without throwing. That is
191+
* deliberate and accepted — E2E databases are ephemeral, and the
192+
* `E2E_` name prefix keeps orphans greppable. The registration is
193+
* kept (rather than skipped) so that a test which happens to leave the
194+
* client account-free still gets it removed.
195+
*
196+
* @param setup The per-test {@link ApiSetupManager}.
197+
* @param guard The per-test {@link CleanupGuard}.
198+
* @param overrides See {@link CreateActiveTestClientOverrides}.
199+
* @returns A {@link TestClient} projection. No follow-up GET is
200+
* issued; callers needing the activation timeline should
201+
* call `setup.api.getClient(id)` themselves.
202+
* @throws When `activationDate` precedes `submittedOnDate`, so the
203+
* failure names the offending fields instead of surfacing as
204+
* an opaque Fineract validation error.
205+
*/
206+
export async function createActiveTestClient(
207+
setup: ApiSetupManager,
208+
guard: CleanupGuard,
209+
overrides: CreateActiveTestClientOverrides = {}
210+
): Promise<TestClient> {
211+
const submittedOnDate = overrides.submittedOnDate ?? DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE;
212+
const activationDate = overrides.activationDate ?? DEFAULT_TEST_CLIENT_ACTIVATION_DATE;
213+
214+
assertDateNotBefore(activationDate, submittedOnDate, 'createActiveTestClient', 'activationDate', 'submittedOnDate');
215+
216+
return createTestClient(setup, guard, {
217+
firstname: overrides.firstname,
218+
lastname: overrides.lastname,
219+
officeId: overrides.officeId,
220+
submittedOnDate,
221+
extra: {
222+
active: true,
223+
activationDate,
224+
...overrides.extra
225+
}
226+
});
227+
}
228+
229+
/**
230+
* Guard that `later` is not chronologically before `earlier`.
231+
*
232+
* Both inputs use Fineract's `dd MMMM yyyy` wire format, which
233+
* `Date.parse` handles natively. An unparseable input is treated as a
234+
* programming error rather than silently skipped, because a typo in a
235+
* date literal would otherwise disable the very check that catches
236+
* date-ordering bugs.
237+
*/
238+
function assertDateNotBefore(
239+
later: string,
240+
earlier: string,
241+
caller: string,
242+
laterLabel: string,
243+
earlierLabel: string
244+
): void {
245+
const laterMs = Date.parse(later);
246+
const earlierMs = Date.parse(earlier);
247+
248+
if (Number.isNaN(laterMs) || Number.isNaN(earlierMs)) {
249+
throw new Error(
250+
`${caller}: unable to parse dates — ${laterLabel}='${later}', ${earlierLabel}='${earlier}'. ` +
251+
`Expected Fineract's 'dd MMMM yyyy' format, e.g. '01 January 2024'.`
252+
);
253+
}
254+
255+
if (laterMs < earlierMs) {
256+
throw new Error(
257+
`${caller}: ${laterLabel} ('${later}') must not precede ${earlierLabel} ('${earlier}') — ` +
258+
`Fineract rejects the create with a validation error that names neither field.`
259+
);
260+
}
261+
}

playwright/factories/index.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,50 @@
1717
* The barrel keeps spec files insulated from factory file moves and lets
1818
* the React counterpart export an identically-shaped module — making the
1919
* cross-framework portability swap a path-only change.
20+
*
21+
* Two flavours of factory live behind this barrel, and the distinction
22+
* matters:
23+
*
24+
* - **API factories** (`client.factory.ts`) hit Fineract REST, return
25+
* a real `resourceId`, and register their own teardown on the
26+
* per-test `CleanupGuard`. Use these to arrange preconditions.
27+
* - **Payload builders** (`client.ts`) are pure and never touch the
28+
* network. They produce form-shaped data for driving a page object
29+
* through the UI.
30+
*
31+
* Both modules previously exported a function called `createTestClient`
32+
* with incompatible signatures, and only the pure builder was reachable
33+
* through this barrel — so `import { createTestClient } from '../../factories'`
34+
* and `import { createTestClient } from '../../factories/client.factory'`
35+
* silently resolved to different functions. The pure builder is now
36+
* re-exported as `buildTestClientPayload` to make the distinction
37+
* explicit at the import site.
2038
*/
2139

40+
// ── API factories (network + cleanup registration) ─────────────────
41+
2242
export {
2343
createTestClient,
44+
createActiveTestClient,
45+
DEFAULT_TEST_CLIENT_LASTNAME,
46+
DEFAULT_TEST_CLIENT_SUBMITTED_ON_DATE,
47+
DEFAULT_TEST_CLIENT_ACTIVATION_DATE,
48+
DEFAULT_ACCOUNT_OPENING_DATE,
49+
type CreateTestClientOverrides,
50+
type CreateActiveTestClientOverrides
51+
} from './client.factory';
52+
53+
// ── Pure payload builders (no network) ─────────────────────────────
54+
55+
export {
56+
createTestClient as buildTestClientPayload,
2457
createSeededClient,
2558
type TestClientPayload,
2659
type ClientState,
27-
type CreateTestClientOverrides,
60+
type CreateTestClientOverrides as BuildTestClientPayloadOverrides,
2861
type SeededTestClient
2962
} from './client';
63+
64+
// ── Shared resolvers ───────────────────────────────────────────────
65+
66+
export { resolveDefaultOfficeId, FIRST_OFFICE_CACHE_KEY } from './_shared';

playwright/tests/clients/create-client.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
type GeneralStepData,
1515
type FamilyMemberData
1616
} from '../../pages';
17-
import { createTestClient } from '../../factories';
17+
import { buildTestClientPayload } from '../../factories';
1818
import { BEHAVIOR } from '../../config/behavior';
1919

2020
/**
@@ -28,7 +28,7 @@ import { BEHAVIOR } from '../../config/behavior';
2828
* single test exercises every PR-1 page object surface
2929
* (`ClientsListPage`, `CreateClientPage`, `ClientViewPage`).
3030
*
31-
* 2. Negative path — uses the {@link createTestClient} payload factory
31+
* 2. Negative path — uses the {@link buildTestClientPayload} payload factory
3232
* to build a baseline that is one required field short, then
3333
* asserts the wizard refuses to expose the Preview step until the
3434
* missing field is supplied. The factory is intentionally scoped
@@ -216,7 +216,7 @@ test.describe('Create Client — CRUD', () => {
216216
);
217217
}
218218

219-
const invalidPayload = createTestClient({
219+
const invalidPayload = buildTestClientPayload({
220220
office: officeName,
221221
lastname: ''
222222
});

playwright/utils/cleanup-guard.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,41 @@ test.describe('CleanupGuard.flush() ordering', () => {
7373
expect(summary.failed).toEqual([]);
7474
});
7575

76+
/**
77+
* Regression guard: an earlier implementation snapshotted the stack
78+
* in LIFO order but then dispatched every deleter in the same tick
79+
* via `Promise.allSettled`. Ordering was therefore only apparent —
80+
* a slow child deleter could still have its DELETE reach Fineract
81+
* after its parent's, producing an FK violation. Staggered delays
82+
* make a concurrent drain observably interleave.
83+
*/
84+
test('awaits each deleter before starting the next', async () => {
85+
const guard = new CleanupGuard();
86+
const events: string[] = [];
87+
88+
const slowDeleter = (label: string, delayMs: number) => async () => {
89+
events.push(`start:${label}`);
90+
await new Promise((resolve) => setTimeout(resolve, delayMs));
91+
events.push(`end:${label}`);
92+
};
93+
94+
// Registered parent-first, so LIFO must run 'child' to completion
95+
// before 'parent' even starts. The child is deliberately the
96+
// slower of the two — under a concurrent drain 'parent' would
97+
// finish first and the interleaving would be visible.
98+
guard.register('parent', slowDeleter('parent', 1));
99+
guard.register('child', slowDeleter('child', 25));
100+
101+
await guard.flush();
102+
103+
expect(events).toEqual([
104+
'start:child',
105+
'end:child',
106+
'start:parent',
107+
'end:parent'
108+
]);
109+
});
110+
76111
test('drains the stack to zero after a flush', async () => {
77112
const guard = new CleanupGuard();
78113
guard.register('a', async () => undefined);

0 commit comments

Comments
 (0)