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 = " " (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); }); });