import { describe, it, expect } from 'vitest'; import { eq } from 'drizzle-orm'; import { createHandler as createReservationHandler, listHandler as listReservationsHandler, } from '@/app/api/v1/berths/[id]/reservations/handlers'; import { getHandler as getReservationHandler, patchHandler as patchReservationHandler, deleteHandler as deleteReservationHandler, } from '@/app/api/v1/berth-reservations/[id]/handlers'; import { db } from '@/lib/db'; import { berthReservations } from '@/lib/db/schema/reservations'; import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester'; import { makeBerth, makeClient, makeFullPermissions, makePort, makeSalesAgentPermissions, makeYacht, } from '../../helpers/factories'; // ─── POST /api/v1/berths/[id]/reservations ─────────────────────────────────── describe('POST /api/v1/berths/[id]/reservations', () => { it('creates pending reservation (201)', async () => { const port = await makePort(); const berth = await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const req = makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, { body: { clientId: client.id, yachtId: yacht.id, startDate: new Date().toISOString(), }, }); const res = await createReservationHandler(req, ctx, { id: berth.id }); expect(res.status).toBe(201); const body = await res.json(); expect(body.data.berthId).toBe(berth.id); expect(body.data.clientId).toBe(client.id); expect(body.data.yachtId).toBe(yacht.id); expect(body.data.status).toBe('pending'); }); it('returns 400 when yacht does not belong to reservation client', async () => { const port = await makePort(); const berth = await makeBerth({ portId: port.id }); const ownerClient = await makeClient({ portId: port.id }); const otherClient = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: ownerClient.id, }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const req = makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, { body: { clientId: otherClient.id, yachtId: yacht.id, startDate: new Date().toISOString(), }, }); const res = await createReservationHandler(req, ctx, { id: berth.id }); expect(res.status).toBe(400); }); it('returns 404 when berth is cross-tenant', async () => { const portA = await makePort(); const portB = await makePort(); const berthA = await makeBerth({ portId: portA.id }); const client = await makeClient({ portId: portB.id }); const yacht = await makeYacht({ portId: portB.id, ownerType: 'client', ownerId: client.id, }); // Caller is scoped to portB but the URL berth lives in portA. const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); const req = makeMockRequest( 'POST', `http://localhost/api/v1/berths/${berthA.id}/reservations`, { body: { clientId: client.id, yachtId: yacht.id, startDate: new Date().toISOString(), }, }, ); const res = await createReservationHandler(req, ctxB, { id: berthA.id }); expect(res.status).toBe(404); }); it('ignores berthId from body, uses URL param instead', async () => { const port = await makePort(); const urlBerth = await makeBerth({ portId: port.id }); const bodyBerth = await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const req = makeMockRequest( 'POST', `http://localhost/api/v1/berths/${urlBerth.id}/reservations`, { body: { berthId: bodyBerth.id, clientId: client.id, yachtId: yacht.id, startDate: new Date().toISOString(), }, }, ); const res = await createReservationHandler(req, ctx, { id: urlBerth.id }); expect(res.status).toBe(201); const body = await res.json(); expect(body.data.berthId).toBe(urlBerth.id); expect(body.data.berthId).not.toBe(bodyBerth.id); }); }); // ─── GET /api/v1/berths/[id]/reservations ──────────────────────────────────── describe('GET /api/v1/berths/[id]/reservations', () => { it('returns reservations filtered by that berth', async () => { const port = await makePort(); const berthA = await makeBerth({ portId: port.id }); const berthB = await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); // Create a reservation for berthA. await createReservationHandler( makeMockRequest('POST', `http://localhost/api/v1/berths/${berthA.id}/reservations`, { body: { clientId: client.id, yachtId: yacht.id, startDate: new Date().toISOString(), }, }), ctx, { id: berthA.id }, ); // Create a reservation for berthB. await createReservationHandler( makeMockRequest('POST', `http://localhost/api/v1/berths/${berthB.id}/reservations`, { body: { clientId: client.id, yachtId: yacht.id, startDate: new Date().toISOString(), }, }), ctx, { id: berthB.id }, ); const res = await listReservationsHandler( makeMockRequest('GET', `http://localhost/api/v1/berths/${berthA.id}/reservations`), ctx, { id: berthA.id }, ); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.length).toBe(1); expect(body.data[0].berthId).toBe(berthA.id); }); }); // ─── GET /api/v1/berth-reservations/[id] ───────────────────────────────────── describe('GET /api/v1/berth-reservations/[id]', () => { it('returns the reservation', async () => { const port = await makePort(); const berth = await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const createRes = await createReservationHandler( makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, { body: { clientId: client.id, yachtId: yacht.id, startDate: new Date().toISOString(), }, }), ctx, { id: berth.id }, ); const reservation = (await createRes.json()).data; const res = await getReservationHandler( makeMockRequest('GET', `http://localhost/api/v1/berth-reservations/${reservation.id}`), ctx, { id: reservation.id }, ); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.id).toBe(reservation.id); }); it('returns 404 for cross-tenant', async () => { const portA = await makePort(); const portB = await makePort(); const berth = await makeBerth({ portId: portA.id }); const client = await makeClient({ portId: portA.id }); const yacht = await makeYacht({ portId: portA.id, ownerType: 'client', ownerId: client.id, }); const ctxA = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() }); const createRes = await createReservationHandler( makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, { body: { clientId: client.id, yachtId: yacht.id, startDate: new Date().toISOString(), }, }), ctxA, { id: berth.id }, ); const reservation = (await createRes.json()).data; const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); const res = await getReservationHandler( makeMockRequest('GET', `http://localhost/api/v1/berth-reservations/${reservation.id}`), ctxB, { id: reservation.id }, ); expect(res.status).toBe(404); }); }); // ─── PATCH /api/v1/berth-reservations/[id] ─────────────────────────────────── describe('PATCH /api/v1/berth-reservations/[id]', () => { async function seedReservation() { const port = await makePort(); const berth = await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const createRes = await createReservationHandler( makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, { body: { clientId: client.id, yachtId: yacht.id, startDate: new Date().toISOString(), }, }), ctx, { id: berth.id }, ); const reservation = (await createRes.json()).data; return { port, berth, client, yacht, ctx, reservation }; } it('activate: pending → active (200)', async () => { const { ctx, reservation } = await seedReservation(); const res = await patchReservationHandler( makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { body: { action: 'activate' }, }), ctx, { id: reservation.id }, ); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.status).toBe('active'); }); it('end: active → ended (200)', async () => { const { ctx, reservation } = await seedReservation(); // First activate. await patchReservationHandler( makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { body: { action: 'activate' }, }), ctx, { id: reservation.id }, ); // Then end. const endDate = new Date('2027-01-01T00:00:00.000Z'); const res = await patchReservationHandler( makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { body: { action: 'end', endDate: endDate.toISOString(), notes: 'tenant moved', }, }), ctx, { id: reservation.id }, ); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.status).toBe('ended'); expect(new Date(body.data.endDate).toISOString()).toBe(endDate.toISOString()); }); it('cancel: pending → cancelled (200)', async () => { const { ctx, reservation } = await seedReservation(); const res = await patchReservationHandler( makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { body: { action: 'cancel', reason: 'client changed mind' }, }), ctx, { id: reservation.id }, ); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.status).toBe('cancelled'); }); it('returns 400 on invalid transition (ended → activate)', async () => { const { ctx, reservation } = await seedReservation(); // pending → active. await patchReservationHandler( makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { body: { action: 'activate' }, }), ctx, { id: reservation.id }, ); // active → ended. await patchReservationHandler( makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { body: { action: 'end', endDate: new Date().toISOString(), }, }), ctx, { id: reservation.id }, ); // ended → activate should fail. const res = await patchReservationHandler( makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { body: { action: 'activate' }, }), ctx, { id: reservation.id }, ); expect(res.status).toBe(400); }); it('returns 400 on invalid body shape (action missing)', async () => { const { ctx, reservation } = await seedReservation(); const res = await patchReservationHandler( makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { body: { notes: 'noop' }, }), ctx, { id: reservation.id }, ); expect(res.status).toBe(400); }); it('returns 403 when caller lacks reservations.activate for activate action', async () => { const { port, reservation } = await seedReservation(); // Viewer-like permissions: no activate. const ctx = makeMockCtx({ portId: port.id, permissions: { ...makeSalesAgentPermissions(), reservations: { view: true, create: true, activate: false, cancel: true }, }, }); const res = await patchReservationHandler( makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { body: { action: 'activate' }, }), ctx, { id: reservation.id }, ); expect(res.status).toBe(403); }); it('returns 403 when caller lacks reservations.cancel for cancel action', async () => { const { port, reservation } = await seedReservation(); // Sales agent — has activate but NOT cancel. const ctx = makeMockCtx({ portId: port.id, permissions: makeSalesAgentPermissions(), }); const res = await patchReservationHandler( makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { body: { action: 'cancel', reason: 'test' }, }), ctx, { id: reservation.id }, ); expect(res.status).toBe(403); // But activate succeeds with the same permissions set. const activateRes = await patchReservationHandler( makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { body: { action: 'activate' }, }), ctx, { id: reservation.id }, ); expect(activateRes.status).toBe(200); }); }); // ─── DELETE /api/v1/berth-reservations/[id] ────────────────────────────────── describe('DELETE /api/v1/berth-reservations/[id]', () => { it('cancels the reservation (204)', async () => { const port = await makePort(); const berth = await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); const createRes = await createReservationHandler( makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, { body: { clientId: client.id, yachtId: yacht.id, startDate: new Date().toISOString(), }, }), ctx, { id: berth.id }, ); const reservation = (await createRes.json()).data; const delRes = await deleteReservationHandler( makeMockRequest('DELETE', `http://localhost/api/v1/berth-reservations/${reservation.id}`), ctx, { id: reservation.id }, ); expect(delRes.status).toBe(204); const [row] = await db .select() .from(berthReservations) .where(eq(berthReservations.id, reservation.id)); expect(row?.status).toBe('cancelled'); }); });