Files
pn-new-crm/tests/integration/interests-yacht.test.ts
Matt 0d9208a052 fix(audit): A1/A2/A4/A6/A8/A9/A16/A17/A19/A20 from 2026-05-15 sweep
Knocks out 10 of the 13 known issues from yesterday's Playwright audit.

A4 — Client form silently rejected submit when a contact row had an
empty value. The F19 filter ran in mutationFn after zod's
handleSubmit had already short-circuited on min(1). Now wraps the
onSubmit to prune empty rows BEFORE handleSubmit/zod sees them.

A16 — File upload to documents hub root 400'd because FormData.get
returns null for absent fields and zod's .optional() rejects null.
Route handler now coerces null/empty → undefined before parse.

A17 — Added /api/v1/me/ports endpoint that any authenticated user
can hit; client.ts now uses it as the bootstrap port-slug→port-id
resolver. Eliminates the wasteful 400s sales-reps and viewers were
firing on every page load against the super-admin-gated /admin/ports.

A1 — Filter permission_denied actions from the dashboard activity
feed. Still in the audit log; just not noise on the dashboard.

A2 — New LEGACY_STAGE_REMAP table + canonicalizeStage / stageLabelFor
helpers in lib/constants. Activity-feed maps legacy 9-stage enum
values (deposit_10pct, contract_sent, etc.) to their 7-stage labels
on the way out, so historical audit rows read as "Deposit Paid" not
"Deposit 10Pct".

A19 — Same-stage write now returns 204 No Content. Service returns
a STAGE_NOOP sentinel; the route handler translates it.

A9 — Catch-up wizard now derives stage from berth status (under_offer
→ EOI, sold → contract) with a stageOverride state for explicit
user picks. Avoids the set-state-in-effect rule violation.

A20 — OwnerPicker shows a "Client / Company" hint chip on the
trigger when no value is set, so users know the trigger opens a
two-tab picker instead of just a client list.

A8 — Migration 0066 normalizes legacy `statusOverrideMode = 'auto'`
to NULL so the column lives at strictly 3 states.

A6 — file-preview-dialog gets a screen-reader DialogDescription so
the Radix "Missing aria-describedby" warning stops firing on every
preview.

A18 closed as not-a-bug: /api/v1/users genuinely doesn't exist
(Next returns 404); /api/v1/admin/audit exists and 403s.

A5 (Socket.IO dev noise) + A3 (react-grab CSP) left for a separate
pass — both are dev-only cosmetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:12:20 +02:00

236 lines
7.6 KiB
TypeScript

/**
* interests.service yacht-ownership validation integration tests.
*
* Covers:
* - createInterest with yachtId succeeds when yacht is owned by the client
* - createInterest with yachtId rejects when yacht belongs to a different client
* - createInterest with yachtId succeeds when client is member of owning company
* - createInterest without yachtId succeeds (stage=open is allowed)
* - changeInterestStage rejects moving out of "open" when yachtId is null
* - changeInterestStage succeeds when yachtId is set
* - updateInterest validates yacht ownership when changing yachtId
*
* Uses dynamic imports (PR 8 pattern) so env is loaded before service modules
* touch `db`.
*/
import { describe, it, expect, beforeAll } from 'vitest';
describe('interests.service — yacht ownership validation', () => {
let createInterest: typeof import('@/lib/services/interests.service').createInterest;
let updateInterest: typeof import('@/lib/services/interests.service').updateInterest;
let changeInterestStage: typeof import('@/lib/services/interests.service').changeInterestStage;
let makePort: typeof import('../helpers/factories').makePort;
let makeClient: typeof import('../helpers/factories').makeClient;
let makeYacht: typeof import('../helpers/factories').makeYacht;
let makeCompany: typeof import('../helpers/factories').makeCompany;
let makeMembership: typeof import('../helpers/factories').makeMembership;
let makeAuditMeta: typeof import('../helpers/factories').makeAuditMeta;
beforeAll(async () => {
const svc = await import('@/lib/services/interests.service');
createInterest = svc.createInterest;
updateInterest = svc.updateInterest;
changeInterestStage = svc.changeInterestStage;
const factories = await import('../helpers/factories');
makePort = factories.makePort;
makeClient = factories.makeClient;
makeYacht = factories.makeYacht;
makeCompany = factories.makeCompany;
makeMembership = factories.makeMembership;
makeAuditMeta = factories.makeAuditMeta;
});
it('createInterest with yachtId succeeds when yacht is owned by the client', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const yacht = await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: client.id,
});
const interest = await createInterest(
port.id,
{
clientId: client.id,
yachtId: yacht.id,
pipelineStage: 'enquiry',
tagIds: [],
reminderEnabled: false,
},
makeAuditMeta({ portId: port.id }),
);
expect(interest.yachtId).toBe(yacht.id);
expect(interest.clientId).toBe(client.id);
});
it('createInterest with yachtId rejects when yacht belongs to a different client', async () => {
const port = await makePort();
const clientA = await makeClient({ portId: port.id });
const clientB = await makeClient({ portId: port.id });
const yacht = await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: clientA.id,
});
await expect(
createInterest(
port.id,
{
clientId: clientB.id,
yachtId: yacht.id,
pipelineStage: 'enquiry',
tagIds: [],
reminderEnabled: false,
},
makeAuditMeta({ portId: port.id }),
),
).rejects.toThrow(/yacht does not belong to this client/);
});
it('createInterest with yachtId succeeds when client is member of owning company', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const company = await makeCompany({ portId: port.id });
await makeMembership({
companyId: company.id,
clientId: client.id,
role: 'director',
endDate: null,
});
const yacht = await makeYacht({
portId: port.id,
ownerType: 'company',
ownerId: company.id,
});
const interest = await createInterest(
port.id,
{
clientId: client.id,
yachtId: yacht.id,
pipelineStage: 'enquiry',
tagIds: [],
reminderEnabled: false,
},
makeAuditMeta({ portId: port.id }),
);
expect(interest.yachtId).toBe(yacht.id);
});
it('createInterest without yachtId succeeds (stage=open is allowed)', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await createInterest(
port.id,
{ clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false },
makeAuditMeta({ portId: port.id }),
);
expect(interest.yachtId).toBeNull();
expect(interest.pipelineStage).toBe('enquiry');
});
it('changeInterestStage rejects moving out of "enquiry" when yachtId is null', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await createInterest(
port.id,
{ clientId: client.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false },
makeAuditMeta({ portId: port.id }),
);
await expect(
changeInterestStage(
interest.id,
port.id,
{ pipelineStage: 'qualified' },
makeAuditMeta({ portId: port.id }),
),
).rejects.toThrow(/yacht must be linked before leaving the Enquiry stage/i);
});
it('changeInterestStage succeeds when yachtId is set', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const yacht = await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: client.id,
});
const interest = await createInterest(
port.id,
{
clientId: client.id,
yachtId: yacht.id,
pipelineStage: 'enquiry',
tagIds: [],
reminderEnabled: false,
},
makeAuditMeta({ portId: port.id }),
);
const updated = await changeInterestStage(
interest.id,
port.id,
{ pipelineStage: 'qualified' },
makeAuditMeta({ portId: port.id }),
);
// After A19: changeInterestStage returns the sentinel STAGE_NOOP only
// when target === current. Here the stage actually changes, so the
// result is the updated row.
if (typeof updated === 'symbol') throw new Error('unexpected no-op');
expect(updated.pipelineStage).toBe('qualified');
});
it('updateInterest validates yacht ownership when changing yachtId', async () => {
const port = await makePort();
const clientA = await makeClient({ portId: port.id });
const clientB = await makeClient({ portId: port.id });
// Interest is owned by clientA; yacht belongs to clientB.
const interest = await createInterest(
port.id,
{ clientId: clientA.id, pipelineStage: 'enquiry', tagIds: [], reminderEnabled: false },
makeAuditMeta({ portId: port.id }),
);
const yachtOfB = await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: clientB.id,
});
await expect(
updateInterest(
interest.id,
port.id,
{ yachtId: yachtOfB.id },
makeAuditMeta({ portId: port.id }),
),
).rejects.toThrow(/yacht does not belong to this client/);
// ... and succeeds when swapping in a yacht that clientA actually owns.
const yachtOfA = await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: clientA.id,
});
const updated = await updateInterest(
interest.id,
port.id,
{ yachtId: yachtOfA.id },
makeAuditMeta({ portId: port.id }),
);
expect(updated.yachtId).toBe(yachtOfA.id);
});
});