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>
This commit is contained in:
183
tests/e2e/exhaustive/10-sales-journey.spec.ts
Normal file
183
tests/e2e/exhaustive/10-sales-journey.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
172
tests/e2e/exhaustive/11-multi-berth-eoi-range.spec.ts
Normal file
172
tests/e2e/exhaustive/11-multi-berth-eoi-range.spec.ts
Normal file
@@ -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]!);
|
||||
});
|
||||
});
|
||||
182
tests/e2e/exhaustive/12-eoi-pathway-parity.spec.ts
Normal file
182
tests/e2e/exhaustive/12-eoi-pathway-parity.spec.ts
Normal file
@@ -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 = "<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);
|
||||
});
|
||||
});
|
||||
101
tests/e2e/exhaustive/13-sales-journey-mobile.spec.ts
Normal file
101
tests/e2e/exhaustive/13-sales-journey-mobile.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
134
tests/e2e/fixtures/sales-journey.ts
Normal file
134
tests/e2e/fixtures/sales-journey.ts
Normal file
@@ -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<T> {
|
||||
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<SeededDeal> {
|
||||
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<string, string>,
|
||||
interestId: string,
|
||||
pipelineStage: string,
|
||||
): Promise<void> {
|
||||
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<string, string>,
|
||||
interestId: string,
|
||||
): Promise<string> {
|
||||
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;
|
||||
}
|
||||
133
tests/e2e/realapi/sales-journey-signing.spec.ts
Normal file
133
tests/e2e/realapi/sales-journey-signing.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
69
tests/e2e/visual/reports.spec.ts
Normal file
69
tests/e2e/visual/reports.spec.ts
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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<typeof import('@/lib/services/documenso-client')>();
|
||||
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<typeof import('@/lib/storage')>();
|
||||
const blobs = new Map<string, Buffer>();
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
251
tests/integration/storage-backend-swap.test.ts
Normal file
251
tests/integration/storage-backend-swap.test.ts
Normal file
@@ -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<StorageBackendName, Map<string, Buffer>> = {
|
||||
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<typeof import('@/lib/storage/filesystem')>();
|
||||
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<void> {
|
||||
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<typeof import('@/lib/storage/filesystem')>(
|
||||
'@/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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user