import { test, expect } from '@playwright/test'; import { login, apiHeaders, navigateTo, PORT_SLUG } from '../smoke/helpers'; import { seedFullyLoadedDeal, changeStage, readStage, type SeededDeal, } from '../fixtures/sales-journey'; /** * End-to-end sales journey — deterministic half. * * Walks an interest through the full 7-stage pipeline * (`src/lib/constants.ts → PIPELINE_STAGES`) and asserts every * transition, plus the milestone side-effects that the CRM surfaces * without needing any external service: * * enquiry → qualified → nurturing → eoi → reservation * → deposit_paid → contract * * The Documenso-dependent legs (real EOI / reservation / contract * signing webhooks) are covered separately by * `tests/e2e/realapi/sales-journey-signing.spec.ts`, which skips when * the Documenso env is absent. Here we drive the same stage transitions * deterministically through the `/stage` API + record the deposit and * create the tenancy + flip the berth to sold via their own endpoints, * so the spec is self-contained and reproducible in CI. */ test.describe('exhaustive: end-to-end sales journey (deterministic)', () => { test.beforeEach(async ({ page }) => { await login(page, 'super_admin'); }); test('walks an interest through every pipeline stage', async ({ page }) => { const headers = await apiHeaders(page); const deal: SeededDeal = await seedFullyLoadedDeal(page, 'Journey'); // Fresh interest starts in Enquiry. expect(await readStage(page.request, headers, deal.interestId)).toBe('enquiry'); // The yacht is linked at seed time, so leaving Enquiry is allowed; // the berth is priced, so the priced-stage gate passes too. Walk the // canonical forward path one legal transition at a time. const path = [ 'qualified', 'nurturing', 'eoi', 'reservation', 'deposit_paid', 'contract', ] as const; for (const stage of path) { await changeStage(page.request, headers, deal.interestId, stage); expect( await readStage(page.request, headers, deal.interestId), `interest should now be at ${stage}`, ).toBe(stage); } }); test('illegal stage skip is rejected by the transition table', async ({ page }) => { const headers = await apiHeaders(page); const deal = await seedFullyLoadedDeal(page, 'Journey Skip'); // enquiry → contract is not in STAGE_TRANSITIONS['enquiry']; without // an override the /stage route must reject it. const res = await page.request.patch(`/api/v1/interests/${deal.interestId}/stage`, { headers, data: { pipelineStage: 'contract' }, }); expect(res.ok(), 'illegal skip should be rejected').toBe(false); expect(res.status()).toBe(400); // Interest stays put. expect(await readStage(page.request, headers, deal.interestId)).toBe('enquiry'); }); test('records a deposit payment against the interest', async ({ page }) => { const headers = await apiHeaders(page); const deal = await seedFullyLoadedDeal(page, 'Journey Deposit'); // Advance to a stage where a deposit makes sense. for (const stage of ['qualified', 'eoi', 'reservation'] as const) { await changeStage(page.request, headers, deal.interestId, stage); } const depositRes = await page.request.post(`/api/v1/interests/${deal.interestId}/payments`, { headers, data: { interestId: deal.interestId, paymentType: 'deposit', amount: '25000', currency: 'EUR', receivedAt: new Date().toISOString(), }, }); expect( depositRes.ok(), `record deposit: ${depositRes.status()} ${await depositRes.text()}`, ).toBe(true); // The payments read endpoint returns a running deposit total. const listRes = await page.request.get(`/api/v1/interests/${deal.interestId}/payments`, { headers, }); expect(listRes.ok()).toBe(true); const body = (await listRes.json()) as { data: { payments: Array<{ amount: string; paymentType: string }>; depositTotal: string }; }; expect(body.data.payments.length).toBeGreaterThanOrEqual(1); expect(Number(body.data.depositTotal)).toBeGreaterThanOrEqual(25000); // Now the deposit is recorded, move the deal to deposit_paid. await changeStage(page.request, headers, deal.interestId, 'deposit_paid'); expect(await readStage(page.request, headers, deal.interestId)).toBe('deposit_paid'); }); test('creating a tenancy and marking the berth sold completes the deal', async ({ page }) => { const headers = await apiHeaders(page); const deal = await seedFullyLoadedDeal(page, 'Journey Sold'); // Walk all the way to contract. for (const stage of ['qualified', 'eoi', 'reservation', 'deposit_paid', 'contract'] as const) { await changeStage(page.request, headers, deal.interestId, stage); } expect(await readStage(page.request, headers, deal.interestId)).toBe('contract'); // Create the (pending) berth tenancy tying client + yacht + berth. const tenancyRes = await page.request.post(`/api/v1/berths/${deal.berthId}/tenancies`, { headers, data: { berthId: deal.berthId, clientId: deal.clientId, yachtId: deal.yachtId, interestId: deal.interestId, startDate: new Date().toISOString(), tenureType: 'permanent', }, }); expect( tenancyRes.ok(), `create tenancy: ${tenancyRes.status()} ${await tenancyRes.text()}`, ).toBe(true); const tenancy = (await tenancyRes.json()) as { data: { id: string; status: string } }; expect(tenancy.data.id).toBeTruthy(); // Flip the berth to sold (the contract-signed berth-rule does this // automatically on the signing webhook; here we drive the same // status transition explicitly). const statusRes = await page.request.patch(`/api/v1/berths/${deal.berthId}/status`, { headers, data: { status: 'sold', reason: 'Contract signed — deal won', interestId: deal.interestId, }, }); expect(statusRes.ok(), `berth → sold: ${statusRes.status()} ${await statusRes.text()}`).toBe( true, ); const sold = (await statusRes.json()) as { data: { status: string } }; expect(sold.data.status).toBe('sold'); }); test('UI: interest detail page renders a stage indicator', async ({ page }) => { const headers = await apiHeaders(page); const deal = await seedFullyLoadedDeal(page, 'Journey UI'); await changeStage(page.request, headers, deal.interestId, 'qualified'); await navigateTo(page, `/interests/${deal.interestId}`); await page.waitForURL(new RegExp(`/${PORT_SLUG}/interests/${deal.interestId}`), { timeout: 10_000, }); await page.waitForLoadState('networkidle'); // The detail header surfaces the stage; "Qualified" is the label for // the stage we just set (STAGE_LABELS.qualified). await expect(page.getByText(/qualified/i).first()).toBeVisible({ timeout: 10_000 }); }); });