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