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:
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]!);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user