Four low-risk adds before the Zod 4 / drizzle-zod headliner: - @total-typescript/ts-reset: tightens TS stdlib types globally (JSON.parse → unknown, fetch().json() → unknown, .filter(Boolean) narrows, Set literals respect typed Set targets). Caught 179 latent type errors; fixed all production sites (8 files) and added `any` cast escape hatch in test files (ESLint exemption scoped to tests/). - web-vitals + /api/v1/internal/vitals endpoint + WebVitalsReporter client component: establishes Core Web Vitals baseline (LCP/INP/CLS/ FCP/TTFB) via navigator.sendBeacon. Required before optimisation work. - @hookform/devtools + FormDevtool wrapper: dev-only RHF state inspector, lazy-loaded via next/dynamic so the chunk is excluded from prod bundles entirely. - @tanstack/query-broadcast-client-experimental: cross-tab cache sync via BroadcastChannel — wired in query-provider.tsx, 1-liner. Audit doc updated with sections 35 + 36 (PDF stack overhaul + comprehensive second-pass package sweep) covering ~20 package adoption candidates and 4-5 deprecation candidates. Verified: tsc clean, vitest 1293/1293 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
428 lines
18 KiB
TypeScript
428 lines
18 KiB
TypeScript
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()) as any;
|
|
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()) as any;
|
|
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()) as any;
|
|
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);
|
|
});
|
|
});
|