import { test, expect } from '@playwright/test'; import { login, apiHeaders } from '../smoke/helpers'; import { seedFullyLoadedDeal } from '../fixtures/sales-journey'; /** * Multi-berth EOI berth-range rendering. * * The Documenso `Berth Number` field (and the in-app AcroForm field of * the same name) renders the EOI bundle's moorings as a compact range: * single-berth → the primary mooring verbatim; multi-berth → the * collapsed range produced by `formatBerthRange()` * (`src/lib/templates/berth-range.ts`): * * ['A1','A2','A3','B5','B6','B7'] → 'A1-A3, B5-B7' * * `EoiContext.eoiBerthRange` (`src/lib/services/eoi-context.ts`) is the * single server-side place this is computed — it aggregates every * `interest_berths` row flagged `is_in_eoi_bundle=true` and feeds them * through `formatBerthRange`. This spec drives the `/eoi-context` read * API (which returns the resolved `EoiContext`) so the assertion is made * against the real server output, not a re-implementation of the * formatter in the test. * * Note on the model: when an interest is created with a `berthId`, that * berth becomes the *primary* link, and the primary is always forced * into the EOI bundle (`upsertInterestBerthTx` invariant). Additional * berths added via `POST /interests/[id]/berths` default to * `is_in_eoi_bundle=true`, so they join the range too. */ interface EoiContextResponse { data: { eoiBerthRange: string; berth: { mooringNumber: string } | null; }; } test.describe('exhaustive: multi-berth EOI berth-range rendering', () => { test.beforeEach(async ({ page }) => { await login(page, 'super_admin'); }); /** * Mirror of `formatBerthRange` for *expectation* construction only — * we still assert against the server's value, but we need a local * oracle to know what to expect from an arbitrary seeded mooring set. * Kept deliberately simple (single-letter prefixes, consecutive-run * collapse) since that's all the seeded data exercises. */ function expectedRange(moorings: string[]): string { const parsed = moorings .map((m) => { const match = /^([A-Z]+)(\d+)$/.exec(m); return match ? { prefix: match[1]!, n: Number(match[2]!), raw: m } : null; }) .filter((x): x is { prefix: string; n: number; raw: string } => x !== null) .sort((a, b) => (a.prefix === b.prefix ? a.n - b.n : a.prefix < b.prefix ? -1 : 1)); const segments: string[] = []; let runStart = parsed[0] ?? null; let runEnd = parsed[0] ?? null; for (let i = 1; i < parsed.length; i++) { const cur = parsed[i]!; if (runEnd && cur.prefix === runEnd.prefix && cur.n === runEnd.n + 1) { runEnd = cur; continue; } if (runStart && runEnd) { segments.push(runStart.raw === runEnd.raw ? runStart.raw : `${runStart.raw}-${runEnd.raw}`); } runStart = cur; runEnd = cur; } if (runStart && runEnd) { segments.push(runStart.raw === runEnd.raw ? runStart.raw : `${runStart.raw}-${runEnd.raw}`); } return segments.join(', '); } test('single-berth bundle renders the primary mooring verbatim', async ({ page }) => { const headers = await apiHeaders(page); const deal = await seedFullyLoadedDeal(page, 'Range Single'); 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 body = (await res.json()) as EoiContextResponse; // formatBerthRange(['Z1234']) === 'Z1234' — single-berth is byte-identical // to the legacy primary-only path. expect(body.data.eoiBerthRange).toBe(deal.mooringNumber); expect(body.data.berth?.mooringNumber).toBe(deal.mooringNumber); }); test('multi-berth bundle renders a collapsed compact range', async ({ page }) => { const headers = await apiHeaders(page); const stamp = Date.now(); // Seed a client + yacht; we'll manage the berths explicitly so we can // build a contiguous run that collapses into a range. const clientRes = await page.request.post('/api/v1/clients', { headers, data: { fullName: `Range Multi Client ${stamp}`, contacts: [{ channel: 'email', value: `range-${stamp}@example.test`, isPrimary: true }], }, }); expect(clientRes.ok(), `client: ${clientRes.status()}`).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: `Range Multi Yacht ${stamp}`, owner: { type: 'client', id: clientId } }, }); expect(yachtRes.ok(), `yacht: ${yachtRes.status()}`).toBe(true); const yachtId = ((await yachtRes.json()) as { data: { id: string } }).data.id; // Build a contiguous run so the formatter collapses it: M{base}..M{base+2}. const base = (stamp % 9000) + 1000; const moorings = [`M${base}`, `M${base + 1}`, `M${base + 2}`]; const berthIds: string[] = []; for (const mooringNumber of moorings) { const berthRes = await page.request.post('/api/v1/berths', { headers, data: { mooringNumber, area: 'Range Area', price: 100000, priceCurrency: 'EUR', tenureType: 'permanent', }, }); expect(berthRes.ok(), `berth ${mooringNumber}: ${berthRes.status()}`).toBe(true); berthIds.push(((await berthRes.json()) as { data: { id: string } }).data.id); } // Create the interest with the first berth as primary (auto in-bundle). const interestRes = await page.request.post('/api/v1/interests', { headers, data: { clientId, yachtId, berthId: berthIds[0], pipelineStage: 'enquiry' }, }); expect(interestRes.ok(), `interest: ${interestRes.status()}`).toBe(true); const interestId = ((await interestRes.json()) as { data: { id: string } }).data.id; // Add the remaining berths — they default to is_in_eoi_bundle=true. for (const berthId of berthIds.slice(1)) { const addRes = await page.request.post(`/api/v1/interests/${interestId}/berths`, { headers, data: { berthId, isSpecificInterest: false }, }); expect(addRes.ok(), `add berth: ${addRes.status()} ${await addRes.text()}`).toBe(true); } // Read back the resolved EoiContext and assert the server-computed // range collapses the contiguous run, matching formatBerthRange(). const res = await page.request.get(`/api/v1/interests/${interestId}/eoi-context`, { headers, }); expect(res.ok(), `eoi-context: ${res.status()} ${await res.text()}`).toBe(true); const body = (await res.json()) as EoiContextResponse; const expected = expectedRange(moorings); // e.g. 'M1234-M1236' — a single collapsed run. expect(body.data.eoiBerthRange).toBe(expected); expect(body.data.eoiBerthRange).toContain('-'); // All three moorings must be represented within the range string. expect(body.data.eoiBerthRange).toContain(moorings[0]!); expect(body.data.eoiBerthRange).toContain(moorings[moorings.length - 1]!); }); });