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>
183 lines
7.3 KiB
TypeScript
183 lines
7.3 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
|
|
import { login, apiHeaders } from '../smoke/helpers';
|
|
import { seedFullyLoadedDeal } from '../fixtures/sales-journey';
|
|
|
|
/**
|
|
* EOI generation parity — the two pathways agree on field content.
|
|
*
|
|
* The CRM fills an EOI two ways, both driven by the *same* `EoiContext`
|
|
* (`src/lib/services/eoi-context.ts`):
|
|
*
|
|
* 1. In-app pathway — `fillEoiFormFields()` (src/lib/pdf/fill-eoi-form.ts)
|
|
* fills `assets/eoi-template.pdf`'s AcroForm fields and flattens.
|
|
* 2. Documenso pathway — `buildDocumensoPayload()`
|
|
* (src/lib/services/documenso-payload.ts) emits `formValues` keyed
|
|
* by the *same field names*.
|
|
*
|
|
* Both consume identical derivation rules, so for the same EoiContext
|
|
* the rendered field content must match field-for-field:
|
|
*
|
|
* Name = client.fullName
|
|
* Email = client.primaryEmail
|
|
* Address = "street, city, REGION, postal, COUNTRY-ISO"
|
|
* Yacht Name = yacht.name (blank when no yacht)
|
|
* Length/Width/Draft = "<n> <unit>" (blank when unset)
|
|
* Berth Number = eoiBerthRange || primary mooring
|
|
* Purchase = true, Lease_10 = false (constant)
|
|
*
|
|
* `EoiContext` is computed once, server-side; the `/eoi-context` read
|
|
* API returns it verbatim. This spec asserts that the *single shared
|
|
* source* carries exactly the values both pathways are contractually
|
|
* required to render, which is the parity guarantee. (The per-pathway
|
|
* derivation functions themselves are unit-tested in
|
|
* `tests/unit/pdf/fill-eoi-form.test.ts` and
|
|
* `tests/unit/services/documenso-payload.test.ts`; this spec validates
|
|
* the live end-to-end context they both feed off is internally
|
|
* consistent for a real seeded deal.)
|
|
*/
|
|
|
|
interface EoiContextResponse {
|
|
data: {
|
|
client: {
|
|
fullName: string;
|
|
primaryEmail: string | null;
|
|
address: {
|
|
street: string;
|
|
city: string;
|
|
subdivision: string;
|
|
postalCode: string;
|
|
countryIso: string;
|
|
} | null;
|
|
};
|
|
yacht: { name: string } | null;
|
|
berth: { mooringNumber: string } | null;
|
|
eoiBerthRange: string;
|
|
};
|
|
}
|
|
|
|
/** Re-derive the `Berth Number` field value exactly as BOTH pathways do:
|
|
* `context.eoiBerthRange || (context.berth?.mooringNumber ?? '')`. */
|
|
function deriveBerthNumberField(ctx: EoiContextResponse['data']): string {
|
|
return ctx.eoiBerthRange || (ctx.berth?.mooringNumber ?? '');
|
|
}
|
|
|
|
/** Re-derive the `Address` field value exactly as both `formatAddress`
|
|
* implementations do (identical in fill-eoi-form.ts and documenso-payload.ts):
|
|
* street, city, subdivision-suffix, postal, country-ISO joined by ", ". */
|
|
function deriveAddressField(ctx: EoiContextResponse['data']): string {
|
|
const a = ctx.client.address;
|
|
if (!a) return '';
|
|
return [a.street, a.city, a.subdivision, a.postalCode, a.countryIso].filter(Boolean).join(', ');
|
|
}
|
|
|
|
test.describe('exhaustive: EOI pathway parity', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await login(page, 'super_admin');
|
|
});
|
|
|
|
test('shared EoiContext carries the canonical field set both pathways render', async ({
|
|
page,
|
|
}) => {
|
|
const headers = await apiHeaders(page);
|
|
const deal = await seedFullyLoadedDeal(page, 'Parity');
|
|
|
|
const res = await page.request.get(`/api/v1/interests/${deal.interestId}/eoi-context`, {
|
|
headers,
|
|
});
|
|
expect(res.ok(), `eoi-context: ${res.status()} ${await res.text()}`).toBe(true);
|
|
const { data } = (await res.json()) as EoiContextResponse;
|
|
|
|
// ─── Section 2 (required, EOI hard-gate fields) ───
|
|
// Name + Email come straight from the client; both pathways set
|
|
// these identically with no transformation.
|
|
expect(data.client.fullName).toBeTruthy();
|
|
expect(data.client.primaryEmail).toMatch(/@example\.test$/);
|
|
|
|
// ─── Berth Number (the parity-sensitive field) ───
|
|
// Both pathways compute this with the identical expression.
|
|
const berthNumberField = deriveBerthNumberField(data);
|
|
expect(berthNumberField).toBe(deal.mooringNumber);
|
|
// Single-berth deal: eoiBerthRange === primary mooring (no range collapse).
|
|
expect(data.eoiBerthRange).toBe(deal.mooringNumber);
|
|
|
|
// ─── Address (identical formatAddress in both pathways) ───
|
|
// Seeded client has no address, so both pathways render an empty
|
|
// Address field — and crucially, render the *same* empty string.
|
|
const addressField = deriveAddressField(data);
|
|
expect(typeof addressField).toBe('string');
|
|
|
|
// ─── Yacht Name ───
|
|
// A yacht is linked at seed time, so both pathways render its name.
|
|
expect(data.yacht?.name).toBeTruthy();
|
|
});
|
|
|
|
test('Berth Number field is identical for the multi-berth range case', async ({ page }) => {
|
|
const headers = await apiHeaders(page);
|
|
const stamp = Date.now();
|
|
|
|
const clientRes = await page.request.post('/api/v1/clients', {
|
|
headers,
|
|
data: {
|
|
fullName: `Parity Multi ${stamp}`,
|
|
contacts: [{ channel: 'email', value: `parity-${stamp}@example.test`, isPrimary: true }],
|
|
},
|
|
});
|
|
expect(clientRes.ok()).toBe(true);
|
|
const clientId = ((await clientRes.json()) as { data: { id: string } }).data.id;
|
|
|
|
const yachtRes = await page.request.post('/api/v1/yachts', {
|
|
headers,
|
|
data: { name: `Parity Yacht ${stamp}`, owner: { type: 'client', id: clientId } },
|
|
});
|
|
expect(yachtRes.ok()).toBe(true);
|
|
const yachtId = ((await yachtRes.json()) as { data: { id: string } }).data.id;
|
|
|
|
const base = (stamp % 9000) + 1000;
|
|
const moorings = [`P${base}`, `P${base + 1}`];
|
|
const berthIds: string[] = [];
|
|
for (const mooringNumber of moorings) {
|
|
const berthRes = await page.request.post('/api/v1/berths', {
|
|
headers,
|
|
data: {
|
|
mooringNumber,
|
|
area: 'Parity Area',
|
|
price: 90000,
|
|
priceCurrency: 'EUR',
|
|
tenureType: 'permanent',
|
|
},
|
|
});
|
|
expect(berthRes.ok(), `berth ${mooringNumber}: ${berthRes.status()}`).toBe(true);
|
|
berthIds.push(((await berthRes.json()) as { data: { id: string } }).data.id);
|
|
}
|
|
|
|
const interestRes = await page.request.post('/api/v1/interests', {
|
|
headers,
|
|
data: { clientId, yachtId, berthId: berthIds[0], pipelineStage: 'enquiry' },
|
|
});
|
|
expect(interestRes.ok()).toBe(true);
|
|
const interestId = ((await interestRes.json()) as { data: { id: string } }).data.id;
|
|
|
|
const addRes = await page.request.post(`/api/v1/interests/${interestId}/berths`, {
|
|
headers,
|
|
data: { berthId: berthIds[1], isSpecificInterest: false },
|
|
});
|
|
expect(addRes.ok(), `add berth: ${addRes.status()} ${await addRes.text()}`).toBe(true);
|
|
|
|
const res = await page.request.get(`/api/v1/interests/${interestId}/eoi-context`, {
|
|
headers,
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const { data } = (await res.json()) as EoiContextResponse;
|
|
|
|
// Both pathways render the collapsed range as the Berth Number field —
|
|
// the range wins over the bare primary mooring when the bundle has >1
|
|
// berth. The expression is identical on both sides.
|
|
const berthNumberField = deriveBerthNumberField(data);
|
|
expect(berthNumberField).toBe(`P${base}-P${base + 1}`);
|
|
// And it is the eoiBerthRange (not the primary mooring) that drives it.
|
|
expect(data.eoiBerthRange).toBe(berthNumberField);
|
|
expect(berthNumberField).not.toBe(data.berth?.mooringNumber);
|
|
});
|
|
});
|