test(e2e): add Initiative 4 end-to-end + integration specs
Sales-process coverage (launch-readiness Initiative 4): - exhaustive: full 7-stage sales journey + illegal-skip rejection + deposit total + tenancy/berth-sold; multi-berth EOI berth-range; EOI pathway parity (in-app vs Documenso, shared EoiContext); mobile-viewport journey. - realapi (Documenso-gated, opt-in): generate-and-sign + post-EOI stages. - integration: Documenso DOCUMENT_COMPLETED webhook idempotency (3x replay -> single file/audit write); storage backend swap (s3 <-> filesystem) with a real on-disk filesystem round-trip. - visual: Reports UI snapshot cases (baselines captured separately). 1615 unit/integration pass; tsc + lint clean. Test-only change (specs are not bundled into the app image) - no app behavior modified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
134
tests/e2e/fixtures/sales-journey.ts
Normal file
134
tests/e2e/fixtures/sales-journey.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Shared seed + drive helpers for the end-to-end sales-journey specs.
|
||||
*
|
||||
* These drive the JSON API directly (`page.request.*`) with the
|
||||
* `X-Port-Id` header the `withAuth` helper requires (see
|
||||
* `tests/e2e/smoke/helpers.ts → apiHeaders`). They give the
|
||||
* exhaustive (deterministic) and realapi (Documenso-gated) journey
|
||||
* specs a single source of truth for building a fully-loaded interest
|
||||
* with a priced primary berth — the minimum a deal needs before it can
|
||||
* legally leave the Enquiry stage and walk the pipeline.
|
||||
*
|
||||
* Pipeline stage machine lives in `src/lib/constants.ts`
|
||||
* (`PIPELINE_STAGES` / `STAGE_TRANSITIONS` / `canTransitionStage`):
|
||||
*
|
||||
* enquiry → qualified → nurturing → eoi → reservation
|
||||
* → deposit_paid → contract
|
||||
*
|
||||
* Gates encoded by `changeInterestStage`:
|
||||
* - a yacht must be linked before leaving `enquiry`;
|
||||
* - the primary berth must carry a non-zero `price` before entering
|
||||
* any of eoi / reservation / deposit_paid / contract.
|
||||
*/
|
||||
import { type APIRequestContext, type Page, expect } from '@playwright/test';
|
||||
|
||||
import { apiHeaders } from '../smoke/helpers';
|
||||
|
||||
export interface SeededDeal {
|
||||
clientId: string;
|
||||
yachtId: string;
|
||||
berthId: string;
|
||||
interestId: string;
|
||||
mooringNumber: string;
|
||||
}
|
||||
|
||||
interface DataEnvelope<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a client + yacht + priced berth + interest, all wired together,
|
||||
* via the v1 JSON API. The interest is created with the berth as its
|
||||
* primary link (the create path materialises a primary `interest_berths`
|
||||
* row), and the berth carries a real price so the deal can advance past
|
||||
* Qualified.
|
||||
*/
|
||||
export async function seedFullyLoadedDeal(page: Page, label: string): Promise<SeededDeal> {
|
||||
const headers = await apiHeaders(page);
|
||||
const stamp = Date.now();
|
||||
// Mooring numbers must match the canonical `^[A-Z]+\d+$` form; use a
|
||||
// high prefix so we never collide with the global-setup seed berths
|
||||
// (A1, A2, B1).
|
||||
const mooringNumber = `Z${(stamp % 9000) + 1000}`;
|
||||
|
||||
const clientRes = await page.request.post('/api/v1/clients', {
|
||||
headers,
|
||||
data: {
|
||||
fullName: `${label} Client ${stamp}`,
|
||||
contacts: [{ channel: 'email', value: `journey-${stamp}@example.test`, isPrimary: true }],
|
||||
},
|
||||
});
|
||||
expect(clientRes.ok(), `client create: ${clientRes.status()} ${await clientRes.text()}`).toBe(
|
||||
true,
|
||||
);
|
||||
const clientId = ((await clientRes.json()) as DataEnvelope<{ id: string }>).data.id;
|
||||
|
||||
const yachtRes = await page.request.post('/api/v1/yachts', {
|
||||
headers,
|
||||
data: {
|
||||
name: `${label} Yacht ${stamp}`,
|
||||
owner: { type: 'client', id: clientId },
|
||||
},
|
||||
});
|
||||
expect(yachtRes.ok(), `yacht create: ${yachtRes.status()} ${await yachtRes.text()}`).toBe(true);
|
||||
const yachtId = ((await yachtRes.json()) as DataEnvelope<{ id: string }>).data.id;
|
||||
|
||||
const berthRes = await page.request.post('/api/v1/berths', {
|
||||
headers,
|
||||
data: {
|
||||
mooringNumber,
|
||||
area: 'Journey Area',
|
||||
// Priced so the interest can leave Qualified — `changeInterestStage`
|
||||
// rejects a $0 primary berth on any priced stage.
|
||||
price: 250000,
|
||||
priceCurrency: 'EUR',
|
||||
tenureType: 'permanent',
|
||||
},
|
||||
});
|
||||
expect(berthRes.ok(), `berth create: ${berthRes.status()} ${await berthRes.text()}`).toBe(true);
|
||||
const berthId = ((await berthRes.json()) as DataEnvelope<{ id: string }>).data.id;
|
||||
|
||||
const interestRes = await page.request.post('/api/v1/interests', {
|
||||
headers,
|
||||
data: { clientId, yachtId, berthId, pipelineStage: 'enquiry' },
|
||||
});
|
||||
expect(
|
||||
interestRes.ok(),
|
||||
`interest create: ${interestRes.status()} ${await interestRes.text()}`,
|
||||
).toBe(true);
|
||||
const interestId = ((await interestRes.json()) as DataEnvelope<{ id: string }>).data.id;
|
||||
|
||||
return { clientId, yachtId, berthId, interestId, mooringNumber };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a single stage transition through the canonical `/stage` PATCH
|
||||
* route and assert the interest now reports the requested stage. Mirrors
|
||||
* the UI: the route enforces `canTransitionStage` + the priced-berth +
|
||||
* yacht gates, so an illegal jump surfaces as a 4xx the caller can assert
|
||||
* on separately.
|
||||
*/
|
||||
export async function changeStage(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
interestId: string,
|
||||
pipelineStage: string,
|
||||
): Promise<void> {
|
||||
const res = await request.patch(`/api/v1/interests/${interestId}/stage`, {
|
||||
headers,
|
||||
data: { pipelineStage },
|
||||
});
|
||||
expect(res.ok(), `stage → ${pipelineStage}: ${res.status()} ${await res.text()}`).toBe(true);
|
||||
}
|
||||
|
||||
/** Fetch the current pipeline stage for an interest via the read API. */
|
||||
export async function readStage(
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
interestId: string,
|
||||
): Promise<string> {
|
||||
const res = await request.get(`/api/v1/interests/${interestId}`, { headers });
|
||||
expect(res.ok(), `read interest: ${res.status()}`).toBe(true);
|
||||
const body = (await res.json()) as DataEnvelope<{ pipelineStage: string }>;
|
||||
return body.data.pipelineStage;
|
||||
}
|
||||
Reference in New Issue
Block a user