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); }); });