Files
pn-new-crm/tests/integration/api/interest-berths.test.ts

428 lines
18 KiB
TypeScript
Raw Normal View History

feat(interests): linked berths list with role-flag toggles + EOI bypass Implements plan §5.5: a per-interest "Linked berths" panel mounted above the recommender on the interest detail Overview tab. Each junction row exposes the role-flag controls reps need to manage the M:M `interest_berths` link without the legacy single-berth flow. UI (`src/components/interests/linked-berths-list.tsx`) * Rows ordered with primary first; mooring number links to /berths/[id], with area + a status pill (available/under_offer/sold) and a "Primary" chip. * "Specifically pitching" Switch (writes `is_specific_interest`) with the consequence text from §1: "This berth will appear as under interest on the public map" / "This berth is hidden from the public map". * "Mark in EOI bundle" Switch (writes `is_in_eoi_bundle`). * "Set as primary" button when the row isn't primary - the existing `upsertInterestBerth` helper demotes the prior primary in the same tx. * "Bypass EOI for this berth" with reason textarea, ONLY rendered when the parent interest's `eoiStatus === 'signed'`. Writes the bypass triple (`eoi_bypass_reason`, `eoi_bypassed_by` = caller, `eoi_bypassed_at` = now); also supports clearing. * Remove-from-interest action gated by a confirmation dialog. API (`src/app/api/v1/interests/[id]/berths/...`) * `GET /` - list endpoint returning `listBerthsForInterest` plus the parent interest's `eoiStatus` in `meta.eoiStatus` so the UI can decide whether to show the bypass control. * `PATCH /[berthId]` - partial update of the junction row's flags + bypass fields. Server-side guard: rejects bypass writes when `eoiStatus !== 'signed'` (defence in depth - never trust the UI to gate this). * `DELETE /[berthId]` - calls `removeInterestBerth`. * The existing POST stays unchanged. All routes wrapped with `withAuth(withPermission('interests', view|edit, ...))`. portId from ctx; cross-port reads/writes return 404 for enumeration prevention (§14.10). Service changes (`src/lib/services/interest-berths.service.ts`) * `upsertInterestBerth` now accepts `eoiBypassReason` (tri-state: omit = no change, non-empty = record, null = clear) and `eoiBypassedBy`. The bypass triple moves as a unit, with `eoi_bypassed_at` stamped server-side. * `listBerthsForInterest` now returns berth detail (area, status, dimensions) alongside the junction row, typed as `InterestBerthWithDetails`. Socket: added `interest:berthLinkUpdated` event for live UI refreshes. Tests: 18 new integration tests in `tests/integration/api/interest-berths.test.ts` covering happy paths, primary-demotion in same tx, bypass write/clear, the "requires signed EOI" guard, cross-port 404s, missing-link 404s, empty-body 400, and viewer 403 through the permission gate.
2026-05-05 04:01:56 +02:00
import { describe, it, expect } from 'vitest';
import { eq, and } from 'drizzle-orm';
import { listHandler, addHandler } from '@/app/api/v1/interests/[id]/berths/handlers';
import { patchHandler, deleteHandler } from '@/app/api/v1/interests/[id]/berths/[berthId]/handlers';
import { withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { interestBerths, interests } from '@/lib/db/schema/interests';
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
import {
makePort,
makeClient,
makeBerth,
makeFullPermissions,
makeViewerPermissions,
} from '../../helpers/factories';
async function makeInterest(args: {
portId: string;
clientId: string;
eoiStatus?: 'waiting_for_signatures' | 'signed' | 'expired' | null;
}) {
const [row] = await db
.insert(interests)
.values({
portId: args.portId,
clientId: args.clientId,
pipelineStage: 'open',
eoiStatus: args.eoiStatus ?? null,
})
.returning();
return row!;
}
// ─── GET /api/v1/interests/[id]/berths ──────────────────────────────────────
describe('GET /api/v1/interests/[id]/berths (listHandler)', () => {
it('returns linked berths and surfaces eoiStatus in meta', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({
portId: port.id,
clientId: client.id,
eoiStatus: 'signed',
});
const berth = await makeBerth({ portId: port.id });
await db.insert(interestBerths).values({
interestId: interest.id,
berthId: berth.id,
isPrimary: true,
isSpecificInterest: true,
isInEoiBundle: true,
});
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const req = makeMockRequest('GET', `http://localhost/api/v1/interests/${interest.id}/berths`);
const res = await listHandler(req, ctx, { id: interest.id });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toHaveLength(1);
expect(body.data[0].berthId).toBe(berth.id);
expect(body.data[0].mooringNumber).toBe(berth.mooringNumber);
expect(body.meta.eoiStatus).toBe('signed');
});
it('returns 404 for a cross-port interest (tenant isolation)', async () => {
const portA = await makePort();
const portB = await makePort();
const client = await makeClient({ portId: portA.id });
const interest = await makeInterest({ portId: portA.id, clientId: client.id });
const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
const req = makeMockRequest('GET', `http://localhost/api/v1/interests/${interest.id}/berths`);
const res = await listHandler(req, ctx, { id: interest.id });
expect(res.status).toBe(404);
});
it('viewer with interests.view can list', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({ portId: port.id, clientId: client.id });
const ctx = makeMockCtx({ portId: port.id, permissions: makeViewerPermissions() });
const gated = withPermission('interests', 'view', listHandler);
const req = makeMockRequest('GET', `http://localhost/api/v1/interests/${interest.id}/berths`);
const res = await gated(req, ctx, { id: interest.id });
expect(res.status).toBe(200);
});
});
// ─── POST /api/v1/interests/[id]/berths ─────────────────────────────────────
describe('POST /api/v1/interests/[id]/berths (addHandler)', () => {
it('links a berth to an interest with isSpecificInterest', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({ portId: port.id, clientId: client.id });
const berth = await makeBerth({ portId: port.id });
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const req = makeMockRequest('POST', `http://localhost/api/v1/interests/${interest.id}/berths`, {
body: { berthId: berth.id, isSpecificInterest: true },
});
const res = await addHandler(req, ctx, { id: interest.id });
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data.berthId).toBe(berth.id);
expect(body.data.isSpecificInterest).toBe(true);
});
it('rejects a berth from a different port (404 on the interest scope)', async () => {
const portA = await makePort();
const portB = await makePort();
const clientA = await makeClient({ portId: portA.id });
const interestA = await makeInterest({ portId: portA.id, clientId: clientA.id });
const berthB = await makeBerth({ portId: portB.id });
const ctx = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() });
const req = makeMockRequest(
'POST',
`http://localhost/api/v1/interests/${interestA.id}/berths`,
{ body: { berthId: berthB.id, isSpecificInterest: true } },
);
const res = await addHandler(req, ctx, { id: interestA.id });
// berth doesn't belong to caller's port → 400 ValidationError
expect(res.status).toBe(400);
});
it('viewer (no interests.edit) receives 403 through the permission gate', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({ portId: port.id, clientId: client.id });
const berth = await makeBerth({ portId: port.id });
const gated = withPermission('interests', 'edit', addHandler);
const ctx = makeMockCtx({ portId: port.id, permissions: makeViewerPermissions() });
const req = makeMockRequest('POST', `http://localhost/api/v1/interests/${interest.id}/berths`, {
body: { berthId: berth.id, isSpecificInterest: true },
});
const res = await gated(req, ctx, { id: interest.id });
expect(res.status).toBe(403);
});
});
// ─── PATCH /api/v1/interests/[id]/berths/[berthId] ──────────────────────────
describe('PATCH /api/v1/interests/[id]/berths/[berthId] (patchHandler)', () => {
it('updates flags and returns the latest junction row', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({ portId: port.id, clientId: client.id });
const berth = await makeBerth({ portId: port.id });
await db.insert(interestBerths).values({
interestId: interest.id,
berthId: berth.id,
isPrimary: false,
isSpecificInterest: true,
isInEoiBundle: false,
});
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const req = makeMockRequest(
'PATCH',
`http://localhost/api/v1/interests/${interest.id}/berths/${berth.id}`,
{ body: { isInEoiBundle: true, isSpecificInterest: false } },
);
const res = await patchHandler(req, ctx, { id: interest.id, berthId: berth.id });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.isInEoiBundle).toBe(true);
expect(body.data.isSpecificInterest).toBe(false);
});
it('promoting one row to primary demotes the prior primary in the same interest', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({ portId: port.id, clientId: client.id });
const berthA = await makeBerth({ portId: port.id });
const berthB = await makeBerth({ portId: port.id });
await db.insert(interestBerths).values([
{ interestId: interest.id, berthId: berthA.id, isPrimary: true },
{ interestId: interest.id, berthId: berthB.id, isPrimary: false },
]);
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const req = makeMockRequest(
'PATCH',
`http://localhost/api/v1/interests/${interest.id}/berths/${berthB.id}`,
{ body: { isPrimary: true } },
);
const res = await patchHandler(req, ctx, { id: interest.id, berthId: berthB.id });
expect(res.status).toBe(200);
const rows = await db
.select()
.from(interestBerths)
.where(eq(interestBerths.interestId, interest.id));
const rowA = rows.find((r) => r.berthId === berthA.id);
const rowB = rows.find((r) => r.berthId === berthB.id);
expect(rowA?.isPrimary).toBe(false);
expect(rowB?.isPrimary).toBe(true);
});
it('records bypass fields when interest has signed primary EOI', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({
portId: port.id,
clientId: client.id,
eoiStatus: 'signed',
});
const berth = await makeBerth({ portId: port.id });
await db.insert(interestBerths).values({
interestId: interest.id,
berthId: berth.id,
isPrimary: true,
});
const ctx = makeMockCtx({
portId: port.id,
permissions: makeFullPermissions(),
userId: 'rep-1',
});
const req = makeMockRequest(
'PATCH',
`http://localhost/api/v1/interests/${interest.id}/berths/${berth.id}`,
{ body: { eoiBypassReason: 'covered under bundle EOI' } },
);
const res = await patchHandler(req, ctx, { id: interest.id, berthId: berth.id });
expect(res.status).toBe(200);
const [row] = await db
.select()
.from(interestBerths)
.where(and(eq(interestBerths.interestId, interest.id), eq(interestBerths.berthId, berth.id)));
expect(row?.eoiBypassReason).toBe('covered under bundle EOI');
expect(row?.eoiBypassedBy).toBe('rep-1');
expect(row?.eoiBypassedAt).toBeInstanceOf(Date);
});
it('rejects bypass when the interest does not have a signed EOI', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({
portId: port.id,
clientId: client.id,
eoiStatus: 'waiting_for_signatures',
});
const berth = await makeBerth({ portId: port.id });
await db.insert(interestBerths).values({
interestId: interest.id,
berthId: berth.id,
});
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const req = makeMockRequest(
'PATCH',
`http://localhost/api/v1/interests/${interest.id}/berths/${berth.id}`,
{ body: { eoiBypassReason: 'too early' } },
);
const res = await patchHandler(req, ctx, { id: interest.id, berthId: berth.id });
expect(res.status).toBe(400);
});
it('clears bypass when reason is null', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({
portId: port.id,
clientId: client.id,
eoiStatus: 'signed',
});
const berth = await makeBerth({ portId: port.id });
await db.insert(interestBerths).values({
interestId: interest.id,
berthId: berth.id,
eoiBypassReason: 'previously bypassed',
eoiBypassedBy: 'someone',
eoiBypassedAt: new Date(),
});
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const req = makeMockRequest(
'PATCH',
`http://localhost/api/v1/interests/${interest.id}/berths/${berth.id}`,
{ body: { eoiBypassReason: null } },
);
const res = await patchHandler(req, ctx, { id: interest.id, berthId: berth.id });
expect(res.status).toBe(200);
const [row] = await db
.select()
.from(interestBerths)
.where(and(eq(interestBerths.interestId, interest.id), eq(interestBerths.berthId, berth.id)));
expect(row?.eoiBypassReason).toBeNull();
expect(row?.eoiBypassedBy).toBeNull();
expect(row?.eoiBypassedAt).toBeNull();
});
it('returns 404 for a cross-port interest', async () => {
const portA = await makePort();
const portB = await makePort();
const client = await makeClient({ portId: portA.id });
const interest = await makeInterest({ portId: portA.id, clientId: client.id });
const berth = await makeBerth({ portId: portA.id });
await db.insert(interestBerths).values({ interestId: interest.id, berthId: berth.id });
const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
const req = makeMockRequest(
'PATCH',
`http://localhost/api/v1/interests/${interest.id}/berths/${berth.id}`,
{ body: { isPrimary: true } },
);
const res = await patchHandler(req, ctx, { id: interest.id, berthId: berth.id });
expect(res.status).toBe(404);
});
it('returns 404 when the junction row does not exist', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({ portId: port.id, clientId: client.id });
const berth = await makeBerth({ portId: port.id });
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const req = makeMockRequest(
'PATCH',
`http://localhost/api/v1/interests/${interest.id}/berths/${berth.id}`,
{ body: { isPrimary: true } },
);
const res = await patchHandler(req, ctx, { id: interest.id, berthId: berth.id });
expect(res.status).toBe(404);
});
it('returns 400 when the body is empty', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({ portId: port.id, clientId: client.id });
const berth = await makeBerth({ portId: port.id });
await db.insert(interestBerths).values({ interestId: interest.id, berthId: berth.id });
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const req = makeMockRequest(
'PATCH',
`http://localhost/api/v1/interests/${interest.id}/berths/${berth.id}`,
{ body: {} },
);
const res = await patchHandler(req, ctx, { id: interest.id, berthId: berth.id });
expect(res.status).toBe(400);
});
it('viewer (no interests.edit) receives 403 through the permission gate', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({ portId: port.id, clientId: client.id });
const berth = await makeBerth({ portId: port.id });
await db.insert(interestBerths).values({ interestId: interest.id, berthId: berth.id });
const gated = withPermission('interests', 'edit', patchHandler);
const ctx = makeMockCtx({ portId: port.id, permissions: makeViewerPermissions() });
const req = makeMockRequest(
'PATCH',
`http://localhost/api/v1/interests/${interest.id}/berths/${berth.id}`,
{ body: { isPrimary: true } },
);
const res = await gated(req, ctx, { id: interest.id, berthId: berth.id });
expect(res.status).toBe(403);
});
});
// ─── DELETE /api/v1/interests/[id]/berths/[berthId] ─────────────────────────
describe('DELETE /api/v1/interests/[id]/berths/[berthId] (deleteHandler)', () => {
it('removes the junction row and returns 204', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({ portId: port.id, clientId: client.id });
const berth = await makeBerth({ portId: port.id });
await db.insert(interestBerths).values({ interestId: interest.id, berthId: berth.id });
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
const req = makeMockRequest(
'DELETE',
`http://localhost/api/v1/interests/${interest.id}/berths/${berth.id}`,
);
const res = await deleteHandler(req, ctx, { id: interest.id, berthId: berth.id });
expect(res.status).toBe(204);
const rows = await db
.select()
.from(interestBerths)
.where(and(eq(interestBerths.interestId, interest.id), eq(interestBerths.berthId, berth.id)));
expect(rows).toHaveLength(0);
});
it('returns 404 for a cross-port interest', async () => {
const portA = await makePort();
const portB = await makePort();
const client = await makeClient({ portId: portA.id });
const interest = await makeInterest({ portId: portA.id, clientId: client.id });
const berth = await makeBerth({ portId: portA.id });
await db.insert(interestBerths).values({ interestId: interest.id, berthId: berth.id });
const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
const req = makeMockRequest(
'DELETE',
`http://localhost/api/v1/interests/${interest.id}/berths/${berth.id}`,
);
const res = await deleteHandler(req, ctx, { id: interest.id, berthId: berth.id });
expect(res.status).toBe(404);
});
it('viewer (no interests.edit) receives 403 through the permission gate', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
const interest = await makeInterest({ portId: port.id, clientId: client.id });
const berth = await makeBerth({ portId: port.id });
await db.insert(interestBerths).values({ interestId: interest.id, berthId: berth.id });
const gated = withPermission('interests', 'edit', deleteHandler);
const ctx = makeMockCtx({ portId: port.id, permissions: makeViewerPermissions() });
const req = makeMockRequest(
'DELETE',
`http://localhost/api/v1/interests/${interest.id}/berths/${berth.id}`,
);
const res = await gated(req, ctx, { id: interest.id, berthId: berth.id });
expect(res.status).toBe(403);
});
});