diff --git a/tests/e2e/exhaustive/10-sales-journey.spec.ts b/tests/e2e/exhaustive/10-sales-journey.spec.ts new file mode 100644 index 00000000..a21661ea --- /dev/null +++ b/tests/e2e/exhaustive/10-sales-journey.spec.ts @@ -0,0 +1,183 @@ +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 }); + }); +}); diff --git a/tests/e2e/exhaustive/11-multi-berth-eoi-range.spec.ts b/tests/e2e/exhaustive/11-multi-berth-eoi-range.spec.ts new file mode 100644 index 00000000..a08457b5 --- /dev/null +++ b/tests/e2e/exhaustive/11-multi-berth-eoi-range.spec.ts @@ -0,0 +1,172 @@ +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]!); + }); +}); diff --git a/tests/e2e/exhaustive/12-eoi-pathway-parity.spec.ts b/tests/e2e/exhaustive/12-eoi-pathway-parity.spec.ts new file mode 100644 index 00000000..c384b134 --- /dev/null +++ b/tests/e2e/exhaustive/12-eoi-pathway-parity.spec.ts @@ -0,0 +1,182 @@ +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); + }); +}); diff --git a/tests/e2e/exhaustive/13-sales-journey-mobile.spec.ts b/tests/e2e/exhaustive/13-sales-journey-mobile.spec.ts new file mode 100644 index 00000000..4ef95b0e --- /dev/null +++ b/tests/e2e/exhaustive/13-sales-journey-mobile.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from '@playwright/test'; + +import { login, apiHeaders, navigateTo, PORT_SLUG } from '../smoke/helpers'; +import { IPHONE_DEVICES } from '../fixtures/devices'; +import { seedFullyLoadedDeal, changeStage, readStage } from '../fixtures/sales-journey'; + +/** + * Mobile parity for the core sales journey. + * + * Re-runs the deterministic pipeline walk at a phone viewport + * (iPhone 15/16, 393×852 — the "common" anchor in + * `tests/e2e/fixtures/devices.ts`) to confirm the stage transitions and + * the interest-detail surface behave identically on mobile. Uses the + * existing device descriptor + `test.use({ viewport, … })` rather than + * introducing a new Playwright project, since the exhaustive project + * already runs the rest of the journey on desktop. + */ + +const phone = IPHONE_DEVICES.iphone16; + +test.use({ + viewport: phone.viewport, + deviceScaleFactor: phone.deviceScaleFactor, + isMobile: phone.isMobile, + hasTouch: phone.hasTouch, + userAgent: phone.userAgent, +}); + +test.describe(`exhaustive: sales journey on ${phone.name}`, () => { + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('pipeline walk reaches contract on a mobile viewport', async ({ page }) => { + const headers = await apiHeaders(page); + const deal = await seedFullyLoadedDeal(page, 'Mobile Journey'); + + expect(await readStage(page.request, headers, deal.interestId)).toBe('enquiry'); + + for (const stage of [ + 'qualified', + 'nurturing', + 'eoi', + 'reservation', + 'deposit_paid', + 'contract', + ] as const) { + await changeStage(page.request, headers, deal.interestId, stage); + expect( + await readStage(page.request, headers, deal.interestId), + `mobile: interest should be at ${stage}`, + ).toBe(stage); + } + }); + + test('interest detail renders its stage on a mobile viewport', async ({ page }) => { + const headers = await apiHeaders(page); + const deal = await seedFullyLoadedDeal(page, 'Mobile Detail'); + 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 viewport is genuinely mobile-width. + expect(page.viewportSize()?.width).toBe(phone.viewport.width); + + // The stage label is present on the mobile-rendered detail page. + await expect(page.getByText(/qualified/i).first()).toBeVisible({ timeout: 10_000 }); + }); + + test('interests list is reachable and renders on a mobile viewport', async ({ page }) => { + await navigateTo(page, '/interests'); + await page.waitForLoadState('networkidle'); + + // The page heading is present regardless of whether the mobile layout + // shows a table or a card/board view. + await expect(page.getByText(/interests/i).first()).toBeVisible({ timeout: 15_000 }); + + await expect + .poll( + async () => { + const table = await page + .locator('table') + .first() + .isVisible() + .catch(() => false); + const cards = await page + .locator('[data-testid="pipeline-board"], [class*="board"], [class*="card"]') + .first() + .isVisible() + .catch(() => false); + return table || cards; + }, + { timeout: 15_000 }, + ) + .toBe(true); + }); +}); diff --git a/tests/e2e/fixtures/sales-journey.ts b/tests/e2e/fixtures/sales-journey.ts new file mode 100644 index 00000000..ae2969fa --- /dev/null +++ b/tests/e2e/fixtures/sales-journey.ts @@ -0,0 +1,134 @@ +/** + * Shared seed + drive helpers for the end-to-end sales-journey specs. + * + * These drive the JSON API directly (`page.request.*`) with the + * `X-Port-Id` header the `withAuth` helper requires (see + * `tests/e2e/smoke/helpers.ts → apiHeaders`). They give the + * exhaustive (deterministic) and realapi (Documenso-gated) journey + * specs a single source of truth for building a fully-loaded interest + * with a priced primary berth — the minimum a deal needs before it can + * legally leave the Enquiry stage and walk the pipeline. + * + * Pipeline stage machine lives in `src/lib/constants.ts` + * (`PIPELINE_STAGES` / `STAGE_TRANSITIONS` / `canTransitionStage`): + * + * enquiry → qualified → nurturing → eoi → reservation + * → deposit_paid → contract + * + * Gates encoded by `changeInterestStage`: + * - a yacht must be linked before leaving `enquiry`; + * - the primary berth must carry a non-zero `price` before entering + * any of eoi / reservation / deposit_paid / contract. + */ +import { type APIRequestContext, type Page, expect } from '@playwright/test'; + +import { apiHeaders } from '../smoke/helpers'; + +export interface SeededDeal { + clientId: string; + yachtId: string; + berthId: string; + interestId: string; + mooringNumber: string; +} + +interface DataEnvelope { + data: T; +} + +/** + * Seed a client + yacht + priced berth + interest, all wired together, + * via the v1 JSON API. The interest is created with the berth as its + * primary link (the create path materialises a primary `interest_berths` + * row), and the berth carries a real price so the deal can advance past + * Qualified. + */ +export async function seedFullyLoadedDeal(page: Page, label: string): Promise { + const headers = await apiHeaders(page); + const stamp = Date.now(); + // Mooring numbers must match the canonical `^[A-Z]+\d+$` form; use a + // high prefix so we never collide with the global-setup seed berths + // (A1, A2, B1). + const mooringNumber = `Z${(stamp % 9000) + 1000}`; + + const clientRes = await page.request.post('/api/v1/clients', { + headers, + data: { + fullName: `${label} Client ${stamp}`, + contacts: [{ channel: 'email', value: `journey-${stamp}@example.test`, isPrimary: true }], + }, + }); + expect(clientRes.ok(), `client create: ${clientRes.status()} ${await clientRes.text()}`).toBe( + true, + ); + const clientId = ((await clientRes.json()) as DataEnvelope<{ id: string }>).data.id; + + const yachtRes = await page.request.post('/api/v1/yachts', { + headers, + data: { + name: `${label} Yacht ${stamp}`, + owner: { type: 'client', id: clientId }, + }, + }); + expect(yachtRes.ok(), `yacht create: ${yachtRes.status()} ${await yachtRes.text()}`).toBe(true); + const yachtId = ((await yachtRes.json()) as DataEnvelope<{ id: string }>).data.id; + + const berthRes = await page.request.post('/api/v1/berths', { + headers, + data: { + mooringNumber, + area: 'Journey Area', + // Priced so the interest can leave Qualified — `changeInterestStage` + // rejects a $0 primary berth on any priced stage. + price: 250000, + priceCurrency: 'EUR', + tenureType: 'permanent', + }, + }); + expect(berthRes.ok(), `berth create: ${berthRes.status()} ${await berthRes.text()}`).toBe(true); + const berthId = ((await berthRes.json()) as DataEnvelope<{ id: string }>).data.id; + + const interestRes = await page.request.post('/api/v1/interests', { + headers, + data: { clientId, yachtId, berthId, pipelineStage: 'enquiry' }, + }); + expect( + interestRes.ok(), + `interest create: ${interestRes.status()} ${await interestRes.text()}`, + ).toBe(true); + const interestId = ((await interestRes.json()) as DataEnvelope<{ id: string }>).data.id; + + return { clientId, yachtId, berthId, interestId, mooringNumber }; +} + +/** + * Drive a single stage transition through the canonical `/stage` PATCH + * route and assert the interest now reports the requested stage. Mirrors + * the UI: the route enforces `canTransitionStage` + the priced-berth + + * yacht gates, so an illegal jump surfaces as a 4xx the caller can assert + * on separately. + */ +export async function changeStage( + request: APIRequestContext, + headers: Record, + interestId: string, + pipelineStage: string, +): Promise { + const res = await request.patch(`/api/v1/interests/${interestId}/stage`, { + headers, + data: { pipelineStage }, + }); + expect(res.ok(), `stage → ${pipelineStage}: ${res.status()} ${await res.text()}`).toBe(true); +} + +/** Fetch the current pipeline stage for an interest via the read API. */ +export async function readStage( + request: APIRequestContext, + headers: Record, + interestId: string, +): Promise { + const res = await request.get(`/api/v1/interests/${interestId}`, { headers }); + expect(res.ok(), `read interest: ${res.status()}`).toBe(true); + const body = (await res.json()) as DataEnvelope<{ pipelineStage: string }>; + return body.data.pipelineStage; +} diff --git a/tests/e2e/realapi/sales-journey-signing.spec.ts b/tests/e2e/realapi/sales-journey-signing.spec.ts new file mode 100644 index 00000000..a4227809 --- /dev/null +++ b/tests/e2e/realapi/sales-journey-signing.spec.ts @@ -0,0 +1,133 @@ +import 'dotenv/config'; +import { test, expect } from '@playwright/test'; + +import { login, apiHeaders } from '../smoke/helpers'; +import { seedFullyLoadedDeal, changeStage, readStage } from '../fixtures/sales-journey'; + +/** + * End-to-end sales journey — Documenso-dependent half. + * + * Covers the signing legs of the pipeline that need a live Documenso + * instance: generating the EOI through the documenso-template pathway + * and confirming the document is created remotely + persisted locally, + * then walking the interest forward through the post-signing stages. + * + * Skips cleanly when the Documenso env is absent (same guard as + * `tests/e2e/realapi/documenso-real-api.spec.ts`), so this spec is a + * no-op in CI runs that don't opt into the real-API project. + * + * The deterministic stage/UI assertions for the journey live in + * `tests/e2e/exhaustive/10-sales-journey.spec.ts`; this file only adds + * the legs that genuinely require the external signer. + */ + +const DOCUMENSO_BASE = process.env.DOCUMENSO_API_URL; +const DOCUMENSO_API_KEY = process.env.DOCUMENSO_API_KEY; + +test.describe('realapi: sales journey — Documenso signing legs', () => { + test.skip(!DOCUMENSO_BASE || !DOCUMENSO_API_KEY, 'DOCUMENSO_API_URL / DOCUMENSO_API_KEY not set'); + + test.beforeEach(async ({ page }) => { + await login(page, 'super_admin'); + }); + + test('generate-and-sign EOI then advance the deal through signing stages', async ({ page }) => { + const headers = await apiHeaders(page); + const deal = await seedFullyLoadedDeal(page, 'Documenso Journey'); + + // ─── 1. Qualify + move into the EOI stage ───────────────────────────── + await changeStage(page.request, headers, deal.interestId, 'qualified'); + await changeStage(page.request, headers, deal.interestId, 'eoi'); + expect(await readStage(page.request, headers, deal.interestId)).toBe('eoi'); + + // ─── 2. Fire generate-and-sign through the documenso-template pathway ── + const signRes = await page.request.post( + '/api/v1/document-templates/documenso-template/generate-and-sign', + { + headers, + data: { + interestId: deal.interestId, + clientId: deal.clientId, + berthId: deal.berthId, + pathway: 'documenso-template', + signers: [], + }, + }, + ); + expect(signRes.ok(), `generate-and-sign: ${signRes.status()} ${await signRes.text()}`).toBe( + true, + ); + + const body = (await signRes.json()) as { + data: { document: { id: string; documensoId: string | null; status: string } }; + }; + expect(body.data.document.documensoId, 'documensoId persisted locally').toBeTruthy(); + expect(body.data.document.status).toBe('sent'); + + // ─── 3. Confirm the document exists on the Documenso side ───────────── + const documensoId = body.data.document.documensoId!; + const documensoRes = await page.request.get( + `${DOCUMENSO_BASE}/api/v1/documents/${documensoId}`, + { headers: { Authorization: `Bearer ${DOCUMENSO_API_KEY}` } }, + ); + expect( + documensoRes.ok(), + `documenso GET: ${documensoRes.status()} ${await documensoRes.text()}`, + ).toBe(true); + const documensoDoc = (await documensoRes.json()) as { id: number; status: string }; + expect(String(documensoDoc.id)).toBe(documensoId); + expect(['PENDING', 'DRAFT'], `unexpected status ${documensoDoc.status}`).toContain( + documensoDoc.status, + ); + + // ─── 4. Walk the post-EOI legs of the pipeline ──────────────────────── + // The webhook-driven signed → reservation → contract advances happen + // on real signing events (which Documenso has no machine-driveable + // "sign on behalf of recipient" endpoint for). We exercise the same + // forward path the webhook would, recording the deposit en route so + // the deposit_paid gate is met realistically. + await changeStage(page.request, headers, deal.interestId, 'reservation'); + + 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(), `deposit: ${depositRes.status()} ${await depositRes.text()}`).toBe( + true, + ); + + await changeStage(page.request, headers, deal.interestId, 'deposit_paid'); + await changeStage(page.request, headers, deal.interestId, 'contract'); + expect(await readStage(page.request, headers, deal.interestId)).toBe('contract'); + + // ─── 5. Complete: tenancy + berth sold ──────────────────────────────── + 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 statusRes = await page.request.patch(`/api/v1/berths/${deal.berthId}/status`, { + headers, + data: { status: 'sold', reason: 'Contract signed', interestId: deal.interestId }, + }); + expect(statusRes.ok(), `berth → sold: ${statusRes.status()}`).toBe(true); + expect(((await statusRes.json()) as { data: { status: string } }).data.status).toBe('sold'); + }); +}); diff --git a/tests/e2e/visual/reports.spec.ts b/tests/e2e/visual/reports.spec.ts new file mode 100644 index 00000000..6d496114 --- /dev/null +++ b/tests/e2e/visual/reports.spec.ts @@ -0,0 +1,69 @@ +import { test, expect, type Page } from '@playwright/test'; + +import { login, navigateTo } from '../smoke/helpers'; + +/** + * Initiative 4 (e2e testing) — visual regression baseline for the new + * Reports UI (Initiative 1, `docs/reports-content-spec.md`). Mirrors the + * list/landing pattern in `snapshots.spec.ts` so the two specs share the + * same project config (project=visual, matched by /visual/.*\.spec\.ts/). + * + * NOTE — NO baseline images are committed with this change. A human must run + * + * pnpm exec playwright test --project=visual --update-snapshots + * + * once the Reports UI is visually settled to generate the PNGs under + * tests/e2e/visual/reports.spec.ts-snapshots/. Until then these cases will + * report "missing snapshot" on the first run (expected) — this is by design: + * the task brief explicitly asked NOT to generate/commit baselines, only to + * wire up the spec cases so the baselines can be captured later. + * + * The Reports landing (`/reports`) is a stable list/landing screen — it does + * not depend on per-row fixture data, so it tolerates seed drift the same way + * the other visual baselines do. The category sub-pages (sales / financial / + * operational) are charts-heavy; they're included but a generous + * maxDiffPixelRatio absorbs antialiasing differences across machines. + */ + +const REPORT_PAGES = [ + { name: 'reports-landing', path: '/reports' }, + { name: 'reports-sales', path: '/reports/sales' }, + { name: 'reports-financial', path: '/reports/financial' }, + { name: 'reports-operational', path: '/reports/operational' }, +] as const; + +async function settle(page: Page) { + // Quiet animations / timers / blinking carets so dynamic content doesn't + // produce flaky pixel diffs (identical to snapshots.spec.ts). + await page.addStyleTag({ + content: ` + *, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + caret-color: transparent !important; + } + `, + }); + await page.waitForLoadState('networkidle'); + // Let TanStack Query + chart libraries flush their first paint. + await page.waitForTimeout(750); +} + +test.describe('Visual regression — Reports UI', () => { + for (const p of REPORT_PAGES) { + test(`${p.name} matches baseline`, async ({ page }) => { + await login(page, 'super_admin'); + await navigateTo(page, p.path); + await settle(page); + + await expect(page).toHaveScreenshot(`${p.name}.png`, { + fullPage: true, + // Charts/SVG render with sub-pixel antialiasing variance across + // machines — match the tolerance the existing visual baselines use. + maxDiffPixelRatio: 0.02, + }); + }); + } +}); diff --git a/tests/integration/documenso-webhook-completion-idempotency.test.ts b/tests/integration/documenso-webhook-completion-idempotency.test.ts new file mode 100644 index 00000000..1db9202e --- /dev/null +++ b/tests/integration/documenso-webhook-completion-idempotency.test.ts @@ -0,0 +1,265 @@ +/** + * Initiative 4 (e2e testing) — Documenso DOCUMENT_COMPLETED idempotency. + * + * CLAUDE.md states `handleDocumentCompleted` is idempotent: it early-returns + * when `status='completed' && signedFileId` is already set, so Documenso's + * 5xx-retry storm (and the reconciling poll worker) can't double-write the + * signed PDF, re-clobber `documents.signedFileId`, or leak the first blob. + * + * Existing coverage gap (checked 2026-06-04): + * - `documents-completion-auto-deposit.test.ts` calls `handleDocumentCompleted` + * exactly ONCE per case (asserts the deposit folder/FK wiring), never a replay. + * - `documenso-webhook-route.test.ts` exercises the route, and its dedup case + * only covers the *route-level* `signatureHash` replay guard for an + * OPENED event with an identical body — NOT the *handler-level* idempotency + * gate inside `handleDocumentCompleted` (line ~1464 of documents.service.ts). + * + * This file fills that gap: replay DOCUMENT_COMPLETED 3× for the same document + * and assert exactly one `files` row is minted by completion (single + * `signedFileId` pointer, stable across replays) and exactly one + * `audit_logs` provenance row (`action='create'`, `entityType='file'`, + * `newValue.source='documenso_completion'`). Mocks all externals (Documenso + * download + storage backend) per the integration-test convention. + */ + +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { and, eq, sql } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documents, files, documentFolders, documentEvents } from '@/lib/db/schema/documents'; +import { auditLogs } from '@/lib/db/schema/system'; +import { user } from '@/lib/db/schema/users'; +import { handleDocumentCompleted } from '@/lib/services/documents.service'; +import { ensureSystemRoots } from '@/lib/services/document-folders.service'; +import { makeClient, makePort } from '../helpers/factories'; + +// Module-scoped counters the mock factories write into. Plain mutable +// containers (not vi.fn) so they can be safely referenced from the hoisted +// `vi.mock` factory below without the "cannot access before init" trap. +const storagePuts: string[] = []; +const downloadCalls: { count: number } = { count: 0 }; + +// Stub the Documenso signed-PDF download so we never hit the network. A +// fresh non-empty buffer keeps the 0-byte guard in the handler happy. +vi.mock('@/lib/services/documenso-client', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + downloadSignedPdf: async () => { + downloadCalls.count += 1; + return Buffer.from('%PDF-1.4 idempotency stub\n'); + }, + }; +}); + +// Stub storage so no MinIO is required. We count `put` calls to prove the +// idempotency gate short-circuits BEFORE re-uploading on the 2nd/3rd replay. +vi.mock('@/lib/storage', async (importOriginal) => { + const real = await importOriginal(); + const blobs = new Map(); + return { + ...real, + getStorageBackend: async () => ({ + name: 's3' as const, + put: async (key: string, body: Buffer | NodeJS.ReadableStream) => { + const buf = Buffer.isBuffer(body) ? body : Buffer.from(''); + blobs.set(key, buf); + storagePuts.push(key); + return { key, sizeBytes: buf.length, sha256: 'stub'.padEnd(64, '0') }; + }, + get: async (key: string) => { + const { Readable } = await import('node:stream'); + return Readable.from([blobs.get(key) ?? Buffer.alloc(0)]); + }, + head: async (key: string) => { + const buf = blobs.get(key); + return buf ? { sizeBytes: buf.length, contentType: 'application/pdf' } : null; + }, + delete: async (key: string) => { + blobs.delete(key); + }, + presignUpload: async () => ({ url: 'http://stub', method: 'PUT' as const }), + presignDownload: async () => ({ url: 'http://stub', expiresAt: new Date(Date.now() + 1000) }), + listByPrefix: async (prefix: string) => [...blobs.keys()].filter((k) => k.startsWith(prefix)), + }), + }; +}); + +/** + * `handleDocumentCompleted` fires its audit-log write as `void createAuditLog(...)` + * (fire-and-forget). Give the microtask + the single DB insert a beat to land + * before we assert on `audit_logs`. + */ +async function flushAudit(): Promise { + await new Promise((r) => setTimeout(r, 150)); +} + +let TEST_USER_ID = ''; + +beforeAll(async () => { + const [u] = await db.select({ id: user.id }).from(user).limit(1); + if (!u) throw new Error('No user available; run pnpm db:seed first'); + TEST_USER_ID = u.id; +}); + +afterEach(() => { + storagePuts.length = 0; + downloadCalls.count = 0; +}); + +describe('handleDocumentCompleted · idempotency on webhook replay', () => { + let portId: string; + let clientId: string; + + beforeEach(async () => { + storagePuts.length = 0; + downloadCalls.count = 0; + const port = await makePort(); + portId = port.id; + + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + await ensureSystemRoots(portId, TEST_USER_ID); + + const client = await makeClient({ portId }); + clientId = client.id; + }); + + it('replaying DOCUMENT_COMPLETED 3× writes exactly one signed file + one audit row', async () => { + const documensoId = `docu-idem-${Date.now()}`; + const [doc] = await db + .insert(documents) + .values({ + portId, + clientId, + documentType: 'eoi', + title: 'Idempotency replay EOI', + status: 'partially_signed', + documensoId, + createdBy: 'seed', + }) + .returning(); + + // Three identical deliveries, exactly as Documenso would retry on a 5xx. + await handleDocumentCompleted({ documentId: documensoId, portId }); + await handleDocumentCompleted({ documentId: documensoId, portId }); + await handleDocumentCompleted({ documentId: documensoId, portId }); + + // ── Gate 1: storage.put fired exactly once. The 2nd/3rd replay must + // short-circuit at the `status==='completed' && signedFileId` guard + // BEFORE re-downloading + re-uploading. ────────────────────────────── + expect(storagePuts).toHaveLength(1); + expect(downloadCalls.count).toBe(1); + + // ── Gate 2: the document points at one stable signedFileId. ──────────── + const updatedDoc = await db.query.documents.findFirst({ + where: eq(documents.id, doc!.id), + }); + expect(updatedDoc?.status).toBe('completed'); + expect(updatedDoc?.signedFileId).not.toBeNull(); + const signedFileId = updatedDoc!.signedFileId!; + + // ── Gate 3: exactly one `files` row exists for this completion. A + // double-write would leave a 2nd files row orphaned (no DB pointer). ── + const fileRows = await db + .select({ id: files.id, folderId: files.folderId }) + .from(files) + .where(eq(files.portId, portId)); + expect(fileRows).toHaveLength(1); + expect(fileRows[0]!.id).toBe(signedFileId); + // And it was deposited into the client entity subfolder (folder_id set once). + expect(fileRows[0]!.folderId).not.toBeNull(); + + // ── Gate 4: exactly one audit_logs provenance row for the file create. ── + await flushAudit(); + const auditRows = await db + .select({ id: auditLogs.id, entityId: auditLogs.entityId }) + .from(auditLogs) + .where( + and( + eq(auditLogs.portId, portId), + eq(auditLogs.action, 'create'), + eq(auditLogs.entityType, 'file'), + sql`${auditLogs.newValue}->>'source' = 'documenso_completion'`, + ), + ); + expect(auditRows).toHaveLength(1); + expect(auditRows[0]!.entityId).toBe(signedFileId); + + // ── Gate 5: only one 'completed' documentEvents row (the handler writes + // one per non-short-circuited pass). ────────────────────────────────── + const completedEvents = await db + .select({ id: documentEvents.id }) + .from(documentEvents) + .where( + and(eq(documentEvents.documentId, doc!.id), eq(documentEvents.eventType, 'completed')), + ); + expect(completedEvents).toHaveLength(1); + }); + + it('a single delivery and a 3× replay converge on the same end state', async () => { + // Control: prove the replay leaves the DB in the identical shape a single + // delivery would — same signedFileId, same file count, same audit count. + const singleId = `docu-idem-single-${Date.now()}`; + const replayId = `docu-idem-replay-${Date.now()}`; + + const client2 = await makeClient({ portId }); + + const [docSingle] = await db + .insert(documents) + .values({ + portId, + clientId, + documentType: 'eoi', + title: 'Single delivery', + status: 'partially_signed', + documensoId: singleId, + createdBy: 'seed', + }) + .returning(); + const [docReplay] = await db + .insert(documents) + .values({ + portId, + clientId: client2.id, + documentType: 'eoi', + title: 'Replayed delivery', + status: 'partially_signed', + documensoId: replayId, + createdBy: 'seed', + }) + .returning(); + + await handleDocumentCompleted({ documentId: singleId, portId }); + + await handleDocumentCompleted({ documentId: replayId, portId }); + await handleDocumentCompleted({ documentId: replayId, portId }); + await handleDocumentCompleted({ documentId: replayId, portId }); + + const single = await db.query.documents.findFirst({ where: eq(documents.id, docSingle!.id) }); + const replay = await db.query.documents.findFirst({ where: eq(documents.id, docReplay!.id) }); + + expect(single?.status).toBe('completed'); + expect(replay?.status).toBe('completed'); + expect(single?.signedFileId).not.toBeNull(); + expect(replay?.signedFileId).not.toBeNull(); + + // Two completions across the two docs → exactly two file rows in the port. + const fileCount = await db.select({ id: files.id }).from(files).where(eq(files.portId, portId)); + expect(fileCount).toHaveLength(2); + + await flushAudit(); + const auditRows = await db + .select({ id: auditLogs.id }) + .from(auditLogs) + .where( + and( + eq(auditLogs.portId, portId), + eq(auditLogs.action, 'create'), + eq(auditLogs.entityType, 'file'), + sql`${auditLogs.newValue}->>'source' = 'documenso_completion'`, + ), + ); + // One provenance row per completed document — the 3× replay added zero extra. + expect(auditRows).toHaveLength(2); + }); +}); diff --git a/tests/integration/storage-backend-swap.test.ts b/tests/integration/storage-backend-swap.test.ts new file mode 100644 index 00000000..af3029bb --- /dev/null +++ b/tests/integration/storage-backend-swap.test.ts @@ -0,0 +1,251 @@ +/** + * Initiative 4 (e2e testing) — storage backend swap. + * + * Exercises switching the deployment's storage backend between `s3` and + * `filesystem` through the canonical `getStorageBackend()` abstraction + * (`src/lib/storage/index.ts`), driven by the global + * `system_settings.storage_backend` row (CLAUDE.md "Storage" section). + * + * Two concerns are covered: + * + * 1. Factory selection + cache invalidation — flipping the global setting + * between `filesystem` and `s3` makes `getStorageBackend()` resolve the + * matching backend, and the per-process fingerprint cache re-resolves on + * change (never returns a stale backend). The two concrete backends are + * mocked at the `S3Backend.create` / `FilesystemBackend.create` boundary + * so no MinIO/network is required — we're testing the *swap wiring*, not + * the S3 wire protocol (the remote MinIO endpoint is non-deterministic in + * this env). + * + * 2. Blob durability across a swap — a blob written under one backend stays + * resolvable from that backend after the global setting flips and back, + * mirroring a real migration window where the old store still answers + * reads. A second block round-trips a real on-disk blob through the + * genuine `FilesystemBackend` so the "written under one, resolvable" + * guarantee is proven against real I/O, not just the in-memory fake. + * + * Per the abstraction contract we NEVER import the S3 SDK directly. + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { Readable } from 'node:stream'; + +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { and, eq, isNull } from 'drizzle-orm'; + +import type { PresignOpts, PutOpts, StorageBackend, StorageBackendName } from '@/lib/storage'; + +// ── In-memory fake backends, tagged by name. The factory under test picks +// one based on the global `storage_backend` setting; we record which +// `create` ran and keep the bytes so a post-swap read still resolves. ── +const blobsByBackend: Record> = { + s3: new Map(), + filesystem: new Map(), +}; +const createCalls: StorageBackendName[] = []; + +function makeFake(name: StorageBackendName): StorageBackend { + const store = blobsByBackend[name]; + return { + name, + async put(key: string, body: Buffer | NodeJS.ReadableStream, _opts: PutOpts) { + const buf = Buffer.isBuffer(body) ? body : Buffer.alloc(0); + store.set(key, buf); + return { key, sizeBytes: buf.length, sha256: 'fake'.padEnd(64, '0') }; + }, + async get(key: string) { + const buf = store.get(key); + if (!buf) throw new Error(`not found: ${key}`); + return Readable.from([buf]); + }, + async head(key: string) { + const buf = store.get(key); + return buf ? { sizeBytes: buf.length, contentType: 'application/octet-stream' } : null; + }, + async delete(key: string) { + store.delete(key); + }, + async presignUpload(_key: string, _opts: PresignOpts) { + return { url: `mem://${name}/upload`, method: 'PUT' as const }; + }, + async presignDownload(_key: string, _opts: PresignOpts) { + return { url: `mem://${name}/download`, expiresAt: new Date(Date.now() + 1000) }; + }, + async listByPrefix(prefix: string) { + return [...store.keys()].filter((k) => k.startsWith(prefix)); + }, + }; +} + +// Replace the two concrete backend constructors with fakes. The real +// `getStorageBackend()` factory (selection + fingerprint cache) is left +// untouched and is the actual subject under test. +vi.mock('@/lib/storage/s3', () => ({ + S3Backend: { + create: vi.fn(async () => { + createCalls.push('s3'); + return makeFake('s3'); + }), + }, +})); +vi.mock('@/lib/storage/filesystem', async (importOriginal) => { + // Keep the real path-validation + HMAC exports intact; only override the + // backend constructor so the swap test stays network/disk-free here. The + // genuine FilesystemBackend is exercised separately below via a direct + // import in its own describe block (different module instance). + const real = await importOriginal(); + return { + ...real, + FilesystemBackend: { + create: vi.fn(async () => { + createCalls.push('filesystem'); + return makeFake('filesystem'); + }), + }, + }; +}); + +// Imported AFTER the mocks so the factory binds to the fakes. +const { getStorageBackend, resetStorageBackendCache } = await import('@/lib/storage'); +const { db } = await import('@/lib/db'); +const { systemSettings } = await import('@/lib/db/schema/system'); + +const STORAGE_KEYS = ['storage_backend', 'storage_filesystem_root'] as const; + +/** Snapshot of the pre-existing global storage rows so we can restore them. */ +let snapshot: { key: string; value: unknown }[] = []; + +async function setGlobal(key: string, value: unknown): Promise { + await db + .delete(systemSettings) + .where(and(eq(systemSettings.key, key), isNull(systemSettings.portId))); + await db.insert(systemSettings).values({ key, value, portId: null }); + // The settings-write hook normally calls this; do it explicitly so the + // factory re-resolves on the next getStorageBackend(). + resetStorageBackendCache(); +} + +beforeAll(async () => { + snapshot = await db + .select({ key: systemSettings.key, value: systemSettings.value }) + .from(systemSettings) + .where(and(isNull(systemSettings.portId))); + snapshot = snapshot.filter((r) => (STORAGE_KEYS as readonly string[]).includes(r.key)); +}); + +afterEach(() => { + vi.clearAllMocks(); + createCalls.length = 0; +}); + +afterAll(async () => { + // Restore the global storage settings exactly as we found them. + for (const key of STORAGE_KEYS) { + await db + .delete(systemSettings) + .where(and(eq(systemSettings.key, key), isNull(systemSettings.portId))); + const original = snapshot.find((r) => r.key === key); + if (original) { + await db.insert(systemSettings).values({ key, value: original.value, portId: null }); + } + } + resetStorageBackendCache(); + blobsByBackend.s3.clear(); + blobsByBackend.filesystem.clear(); +}); + +describe('getStorageBackend · backend swap selection', () => { + it('resolves filesystem then s3 as the global setting flips', async () => { + await setGlobal('storage_backend', 'filesystem'); + const fs = await getStorageBackend(); + expect(fs.name).toBe('filesystem'); + + await setGlobal('storage_backend', 's3'); + const s3 = await getStorageBackend(); + expect(s3.name).toBe('s3'); + + // Both concrete constructors ran exactly once across the two resolutions. + expect(createCalls.filter((c) => c === 'filesystem')).toHaveLength(1); + expect(createCalls.filter((c) => c === 's3')).toHaveLength(1); + }); + + it('any non-"filesystem" value falls back to s3 (factory default)', async () => { + await setGlobal('storage_backend', 'totally-unknown-value'); + const backend = await getStorageBackend(); + expect(backend.name).toBe('s3'); + }); + + it('caches within a config and re-resolves only after a change', async () => { + await setGlobal('storage_backend', 'filesystem'); + await getStorageBackend(); + await getStorageBackend(); + await getStorageBackend(); + // Cache hit: the constructor ran once despite three resolutions. + expect(createCalls.filter((c) => c === 'filesystem')).toHaveLength(1); + + // Changing the setting (here: the fs root) invalidates the fingerprint. + await setGlobal('storage_filesystem_root', '/tmp/pn-swap-test-root'); + await getStorageBackend(); + expect(createCalls.filter((c) => c === 'filesystem')).toHaveLength(2); + }); + + it('a blob written under one backend stays resolvable after a swap and back', async () => { + // Write under filesystem. + await setGlobal('storage_backend', 'filesystem'); + const fsBackend = await getStorageBackend(); + const key = 'test-port/swap/blob.bin'; + const payload = Buffer.from('swap-durability-payload'); + await fsBackend.put(key, payload, { contentType: 'application/octet-stream' }); + + // Swap to s3 — the filesystem store still holds the bytes (old store keeps + // answering reads during a migration window). + await setGlobal('storage_backend', 's3'); + const s3Backend = await getStorageBackend(); + expect(s3Backend.name).toBe('s3'); + expect(blobsByBackend.s3.has(key)).toBe(false); // not migrated by a mere flip + + // Swap back to filesystem and confirm the original blob round-trips. + await setGlobal('storage_backend', 'filesystem'); + const fsAgain = await getStorageBackend(); + const stream = await fsAgain.get(key); + const chunks: Buffer[] = []; + for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string)); + expect(Buffer.concat(chunks).equals(payload)).toBe(true); + }); +}); + +describe('FilesystemBackend · real on-disk round-trip (abstraction contract)', () => { + it('writes and resolves a real blob via the genuine filesystem backend', async () => { + // Use the REAL FilesystemBackend directly (separate module instance from + // the mocked one above — vi.mock is scoped to the factory's import graph). + // EMAIL_CREDENTIAL_KEY / BETTER_AUTH_SECRET are required by the key + // validator at module load; the vitest env provides them. + const { FilesystemBackend } = await vi.importActual( + '@/lib/storage/filesystem', + ); + + const root = await mkdtemp(path.join(tmpdir(), 'pn-swap-fs-')); + try { + const backend = await FilesystemBackend.create({ root, proxyHmacSecretEncrypted: null }); + expect(backend.name).toBe('filesystem'); + + const key = 'sub/dir/durable.bin'; + const payload = Buffer.from('real-on-disk-bytes'); + const put = await backend.put(key, payload, { contentType: 'application/octet-stream' }); + expect(put.sizeBytes).toBe(payload.length); + expect(put.sha256).toMatch(/^[0-9a-f]{64}$/); + + const head = await backend.head(key); + expect(head?.sizeBytes).toBe(payload.length); + + const stream = await backend.get(key); + const chunks: Buffer[] = []; + for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string)); + expect(Buffer.concat(chunks).equals(payload)).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +});