Files
pn-new-crm/tests/e2e/exhaustive/12-eoi-pathway-parity.spec.ts
Matt 7591231c47
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m12s
Build & Push Docker Images / build-and-push (push) Successful in 8m24s
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>
2026-06-04 14:10:35 +02:00

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