From b1133c4e87bab98221c57e414aabeea7756432fe Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 12:15:22 +0200 Subject: [PATCH] feat(reservations): service + validators + exclusivity tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the berth_reservations service covering the full lifecycle (pending -> active -> ended/cancelled) with tenant scoping, DB-enforced exclusivity on the idx_br_active partial unique index, and client-or-company-member cross-checks for yacht ownership. - validators: createPending / activate / end / cancel / list schemas - service: createPending, activate, endReservation, cancel, getById, listReservations — with narrow 23505/idx_br_active catch that re-queries the conflicting active reservation - socket events: berth_reservation:{created,activated,ended,cancelled} - tests: unit (lifecycle, tenant, membership cross-check), integration (concurrent-activate ConflictError + re-activate after end) --- .../services/berth-reservations.service.ts | 369 +++++++++++++++ src/lib/socket/events.ts | 6 + src/lib/validators/reservations.ts | 42 ++ .../reservation-exclusivity.test.ts | 93 ++++ .../unit/services/berth-reservations.test.ts | 438 ++++++++++++++++++ tests/unit/validators.test.ts | 47 ++ 6 files changed, 995 insertions(+) create mode 100644 src/lib/services/berth-reservations.service.ts create mode 100644 src/lib/validators/reservations.ts create mode 100644 tests/integration/reservation-exclusivity.test.ts create mode 100644 tests/unit/services/berth-reservations.test.ts diff --git a/src/lib/services/berth-reservations.service.ts b/src/lib/services/berth-reservations.service.ts new file mode 100644 index 0000000..1516ed0 --- /dev/null +++ b/src/lib/services/berth-reservations.service.ts @@ -0,0 +1,369 @@ +import { and, eq, isNull } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { berthReservations, type BerthReservation } from '@/lib/db/schema/reservations'; +import { berths } from '@/lib/db/schema/berths'; +import { clients } from '@/lib/db/schema/clients'; +import { yachts } from '@/lib/db/schema/yachts'; +import { companyMemberships } from '@/lib/db/schema/companies'; +import { buildListQuery } from '@/lib/db/query-builder'; +import { createAuditLog } from '@/lib/audit'; +import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; +import { emitToRoom } from '@/lib/socket/server'; +import type { z } from 'zod'; +import type { + createPendingSchema, + ActivateInput, + EndReservationInput, + CancelInput, + ListReservationsInput, +} from '@/lib/validators/reservations'; + +type CreatePendingInput = z.input; + +export type { BerthReservation }; + +interface AuditMeta { + userId: string; + portId: string; + ipAddress: string; + userAgent: string; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Returns true if the error is a Postgres unique-violation (SQLSTATE 23505) + * raised specifically on the `idx_br_active` partial unique index. Narrowing + * to this constraint name prevents us from swallowing unrelated unique + * violations. + */ +function isBerthActiveConflict(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const e = err as { + code?: unknown; + constraint_name?: unknown; + constraint?: unknown; + cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown }; + }; + const code = e.code ?? e.cause?.code; + if (code !== '23505') return false; + const constraint = + e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint; + return constraint === 'idx_br_active'; +} + +/** + * Cross-references the reservation's client against the yacht's current owner. + * Either the yacht is directly owned by the client, OR the client has an + * active (endDate IS NULL) company_membership on the owning company. + */ +async function assertClientOwnsOrRepresentsYacht( + yacht: { currentOwnerType: string; currentOwnerId: string }, + clientId: string, +): Promise { + if (yacht.currentOwnerType === 'client' && yacht.currentOwnerId === clientId) { + return; + } + + if (yacht.currentOwnerType === 'company') { + const membership = await db.query.companyMemberships.findFirst({ + where: and( + eq(companyMemberships.companyId, yacht.currentOwnerId), + eq(companyMemberships.clientId, clientId), + isNull(companyMemberships.endDate), + ), + }); + if (membership) return; + } + + throw new ValidationError('yacht does not belong to reservation client'); +} + +async function loadScoped(id: string, portId: string): Promise { + const row = await db.query.berthReservations.findFirst({ + where: and(eq(berthReservations.id, id), eq(berthReservations.portId, portId)), + }); + if (!row) throw new NotFoundError('Reservation'); + return row; +} + +// ─── Create (pending) ──────────────────────────────────────────────────────── + +export async function createPending( + portId: string, + data: CreatePendingInput, + meta: AuditMeta, +): Promise { + // Tenant-scoped existence checks (berth, client, yacht). + const berth = await db.query.berths.findFirst({ + where: and(eq(berths.id, data.berthId), eq(berths.portId, portId)), + }); + if (!berth) throw new ValidationError('berth not found'); + + const client = await db.query.clients.findFirst({ + where: and(eq(clients.id, data.clientId), eq(clients.portId, portId)), + }); + if (!client) throw new ValidationError('client not found'); + + const yacht = await db.query.yachts.findFirst({ + where: and(eq(yachts.id, data.yachtId), eq(yachts.portId, portId)), + }); + if (!yacht) throw new ValidationError('yacht not found'); + + // Client must own the yacht directly OR be an active member of the owning company. + await assertClientOwnsOrRepresentsYacht( + { currentOwnerType: yacht.currentOwnerType, currentOwnerId: yacht.currentOwnerId }, + data.clientId, + ); + + const [reservation] = await db + .insert(berthReservations) + .values({ + portId, + berthId: data.berthId, + clientId: data.clientId, + yachtId: data.yachtId, + interestId: data.interestId ?? null, + status: 'pending', + startDate: data.startDate, + tenureType: data.tenureType ?? 'permanent', + notes: data.notes ?? null, + createdBy: meta.userId, + }) + .returning(); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'create', + entityType: 'berth_reservation', + entityId: reservation!.id, + newValue: { + berthId: reservation!.berthId, + clientId: reservation!.clientId, + yachtId: reservation!.yachtId, + status: reservation!.status, + startDate: reservation!.startDate, + }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'berth_reservation:created', { + reservationId: reservation!.id, + berthId: reservation!.berthId, + }); + + return reservation!; +} + +// ─── Activate (pending → active) ───────────────────────────────────────────── + +export async function activate( + reservationId: string, + portId: string, + data: ActivateInput, + meta: AuditMeta, +): Promise { + const existing = await loadScoped(reservationId, portId); + + if (existing.status !== 'pending') { + throw new ValidationError(`invalid transition: ${existing.status} → active`); + } + + const patch: Partial = { + status: 'active', + updatedAt: new Date(), + }; + if (data.contractFileId !== undefined) { + patch.contractFileId = data.contractFileId; + } + if (data.effectiveDate !== undefined) { + patch.startDate = data.effectiveDate; + } + + let updated: BerthReservation | undefined; + try { + const rows = await db + .update(berthReservations) + .set(patch) + .where(and(eq(berthReservations.id, reservationId), eq(berthReservations.portId, portId))) + .returning(); + updated = rows[0]; + } catch (err) { + if (isBerthActiveConflict(err)) { + const conflicting = await db.query.berthReservations.findFirst({ + where: and( + eq(berthReservations.berthId, existing.berthId), + eq(berthReservations.status, 'active'), + eq(berthReservations.portId, portId), + ), + }); + throw new ConflictError( + conflicting + ? `berth already has active reservation (conflictingReservationId: ${conflicting.id})` + : 'berth already has active reservation', + ); + } + throw err; + } + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'update', + entityType: 'berth_reservation', + entityId: reservationId, + oldValue: { status: existing.status }, + newValue: { status: 'active', contractFileId: updated!.contractFileId }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'berth_reservation:activated', { + reservationId, + berthId: updated!.berthId, + }); + + return updated!; +} + +// ─── End (active → ended) ──────────────────────────────────────────────────── + +export async function endReservation( + reservationId: string, + portId: string, + data: EndReservationInput, + meta: AuditMeta, +): Promise { + const existing = await loadScoped(reservationId, portId); + + if (existing.status !== 'active') { + throw new ValidationError(`invalid transition: ${existing.status} → ended`); + } + + const rows = await db + .update(berthReservations) + .set({ + status: 'ended', + endDate: data.endDate, + notes: data.notes ?? existing.notes, + updatedAt: new Date(), + }) + .where(and(eq(berthReservations.id, reservationId), eq(berthReservations.portId, portId))) + .returning(); + + const updated = rows[0]!; + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'update', + entityType: 'berth_reservation', + entityId: reservationId, + oldValue: { status: existing.status }, + newValue: { status: 'ended', endDate: updated.endDate }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'berth_reservation:ended', { + reservationId, + berthId: updated.berthId, + }); + + return updated; +} + +// ─── Cancel (pending|active → cancelled) ───────────────────────────────────── + +export async function cancel( + reservationId: string, + portId: string, + data: CancelInput, + meta: AuditMeta, +): Promise { + const existing = await loadScoped(reservationId, portId); + + if (existing.status !== 'pending' && existing.status !== 'active') { + throw new ValidationError(`invalid transition: ${existing.status} → cancelled`); + } + + const rows = await db + .update(berthReservations) + .set({ + status: 'cancelled', + notes: data.reason + ? `${existing.notes ? `${existing.notes}\n` : ''}Cancelled: ${data.reason}` + : existing.notes, + updatedAt: new Date(), + }) + .where(and(eq(berthReservations.id, reservationId), eq(berthReservations.portId, portId))) + .returning(); + + const updated = rows[0]!; + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'update', + entityType: 'berth_reservation', + entityId: reservationId, + oldValue: { status: existing.status }, + newValue: { status: 'cancelled', reason: data.reason ?? null }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'berth_reservation:cancelled', { + reservationId, + berthId: updated.berthId, + }); + + return updated; +} + +// ─── Get ───────────────────────────────────────────────────────────────────── + +export async function getById(id: string, portId: string): Promise { + return loadScoped(id, portId); +} + +// ─── List ──────────────────────────────────────────────────────────────────── + +export async function listReservations( + portId: string, + query: ListReservationsInput, +): Promise<{ data: BerthReservation[]; total: number }> { + const { page, limit, sort, order, search, status, berthId, clientId, yachtId } = query; + + const filters = []; + if (status) filters.push(eq(berthReservations.status, status)); + if (berthId) filters.push(eq(berthReservations.berthId, berthId)); + if (clientId) filters.push(eq(berthReservations.clientId, clientId)); + if (yachtId) filters.push(eq(berthReservations.yachtId, yachtId)); + + let sortColumn: + | typeof berthReservations.startDate + | typeof berthReservations.createdAt + | typeof berthReservations.updatedAt = berthReservations.updatedAt; + if (sort === 'startDate') sortColumn = berthReservations.startDate; + else if (sort === 'createdAt') sortColumn = berthReservations.createdAt; + + const result = await buildListQuery({ + table: berthReservations, + portIdColumn: berthReservations.portId, + portId, + idColumn: berthReservations.id, + updatedAtColumn: berthReservations.updatedAt, + searchColumns: search ? [berthReservations.notes] : [], + searchTerm: search, + filters, + sort: sort ? { column: sortColumn, direction: order } : undefined, + page, + pageSize: limit, + includeArchived: true, + }); + + return result; +} diff --git a/src/lib/socket/events.ts b/src/lib/socket/events.ts index 36fcf59..e806e73 100644 --- a/src/lib/socket/events.ts +++ b/src/lib/socket/events.ts @@ -107,6 +107,12 @@ export interface ServerToClientEvents { clientId: string; }) => void; + // Berth reservation events + 'berth_reservation:created': (payload: { reservationId: string; berthId: string }) => void; + 'berth_reservation:activated': (payload: { reservationId: string; berthId: string }) => void; + 'berth_reservation:ended': (payload: { reservationId: string; berthId: string }) => void; + 'berth_reservation:cancelled': (payload: { reservationId: string; berthId: string }) => void; + // Document events 'document:created': (payload: { documentId: string; type?: string; interestId?: string }) => void; 'document:updated': (payload: { documentId: string; changedFields?: string[] }) => void; diff --git a/src/lib/validators/reservations.ts b/src/lib/validators/reservations.ts new file mode 100644 index 0000000..9e5b188 --- /dev/null +++ b/src/lib/validators/reservations.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; +import { baseListQuerySchema } from '@/lib/api/route-helpers'; + +export const RESERVATION_STATUSES = ['pending', 'active', 'ended', 'cancelled'] as const; +export const TENURE_TYPES = ['permanent', 'fixed_term', 'seasonal'] as const; + +export const createPendingSchema = z.object({ + berthId: z.string().min(1), + clientId: z.string().min(1), + yachtId: z.string().min(1), + interestId: z.string().optional(), + startDate: z.coerce.date(), + tenureType: z.enum(TENURE_TYPES).optional().default('permanent'), + notes: z.string().optional(), +}); + +export const activateSchema = z.object({ + contractFileId: z.string().optional(), + effectiveDate: z.coerce.date().optional(), +}); + +export const endReservationSchema = z.object({ + endDate: z.coerce.date(), + notes: z.string().optional(), +}); + +export const cancelSchema = z.object({ + reason: z.string().optional(), +}); + +export const listReservationsSchema = baseListQuerySchema.extend({ + status: z.enum(RESERVATION_STATUSES).optional(), + berthId: z.string().optional(), + clientId: z.string().optional(), + yachtId: z.string().optional(), +}); + +export type CreatePendingInput = z.infer; +export type ActivateInput = z.infer; +export type EndReservationInput = z.infer; +export type CancelInput = z.infer; +export type ListReservationsInput = z.infer; diff --git a/tests/integration/reservation-exclusivity.test.ts b/tests/integration/reservation-exclusivity.test.ts new file mode 100644 index 0000000..565186e --- /dev/null +++ b/tests/integration/reservation-exclusivity.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { createPending, activate, endReservation } from '@/lib/services/berth-reservations.service'; +import { makeBerth, makeClient, makePort, makeYacht, makeAuditMeta } from '../helpers/factories'; +import { ConflictError } from '@/lib/errors'; + +describe('reservation exclusivity', () => { + it('two concurrent activates on same berth: one succeeds, one throws ConflictError', async () => { + const port = await makePort(); + const berth = await makeBerth({ portId: port.id }); + const clientA = await makeClient({ portId: port.id }); + const clientB = await makeClient({ portId: port.id }); + const yachtA = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: clientA.id, + }); + const yachtB = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: clientB.id, + }); + + const resA = await createPending( + port.id, + { + berthId: berth.id, + clientId: clientA.id, + yachtId: yachtA.id, + startDate: new Date(), + }, + makeAuditMeta({ portId: port.id }), + ); + const resB = await createPending( + port.id, + { + berthId: berth.id, + clientId: clientB.id, + yachtId: yachtB.id, + startDate: new Date(), + }, + makeAuditMeta({ portId: port.id }), + ); + + const results = await Promise.allSettled([ + activate(resA.id, port.id, {}, makeAuditMeta({ portId: port.id })), + activate(resB.id, port.id, {}, makeAuditMeta({ portId: port.id })), + ]); + const successes = results.filter((r) => r.status === 'fulfilled'); + const failures = results.filter((r) => r.status === 'rejected'); + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(1); + expect((failures[0] as PromiseRejectedResult).reason).toBeInstanceOf(ConflictError); + }); + + it('activating a second reservation after first is ended succeeds', async () => { + const port = await makePort(); + const berth = await makeBerth({ portId: port.id }); + const clientA = await makeClient({ portId: port.id }); + const clientB = await makeClient({ portId: port.id }); + const yachtA = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: clientA.id, + }); + const yachtB = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: clientB.id, + }); + + const resA = await createPending( + port.id, + { berthId: berth.id, clientId: clientA.id, yachtId: yachtA.id, startDate: new Date() }, + makeAuditMeta({ portId: port.id }), + ); + await activate(resA.id, port.id, {}, makeAuditMeta({ portId: port.id })); + await endReservation( + resA.id, + port.id, + { endDate: new Date() }, + makeAuditMeta({ portId: port.id }), + ); + + const resB = await createPending( + port.id, + { berthId: berth.id, clientId: clientB.id, yachtId: yachtB.id, startDate: new Date() }, + makeAuditMeta({ portId: port.id }), + ); + await expect( + activate(resB.id, port.id, {}, makeAuditMeta({ portId: port.id })), + ).resolves.toBeDefined(); + }); +}); diff --git a/tests/unit/services/berth-reservations.test.ts b/tests/unit/services/berth-reservations.test.ts new file mode 100644 index 0000000..bf46d07 --- /dev/null +++ b/tests/unit/services/berth-reservations.test.ts @@ -0,0 +1,438 @@ +import { describe, it, expect } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { + createPending, + activate, + endReservation, + cancel, + listReservations, +} from '@/lib/services/berth-reservations.service'; +import { + makePort, + makeClient, + makeBerth, + makeYacht, + makeCompany, + makeAuditMeta, +} from '../../helpers/factories'; +import { db } from '@/lib/db'; +import { companyMemberships } from '@/lib/db/schema/companies'; + +// ─── createPending ─────────────────────────────────────────────────────────── + +describe('berth-reservations.service — createPending', () => { + it('creates pending reservation for client-owned yacht', 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 reservation = await createPending( + port.id, + { + berthId: berth.id, + clientId: client.id, + yachtId: yacht.id, + startDate: new Date(), + tenureType: 'permanent', + }, + makeAuditMeta({ portId: port.id }), + ); + + expect(reservation.status).toBe('pending'); + expect(reservation.berthId).toBe(berth.id); + expect(reservation.clientId).toBe(client.id); + expect(reservation.yachtId).toBe(yacht.id); + expect(reservation.portId).toBe(port.id); + expect(reservation.tenureType).toBe('permanent'); + }); + + it('creates pending for company-owned yacht when client is a company member', async () => { + const port = await makePort(); + const berth = await makeBerth({ portId: port.id }); + const company = await makeCompany({ portId: port.id }); + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'company', + ownerId: company.id, + }); + + // Active membership (no endDate). + await db.insert(companyMemberships).values({ + companyId: company.id, + clientId: client.id, + role: 'director', + startDate: new Date(), + }); + + const reservation = await createPending( + port.id, + { + berthId: berth.id, + clientId: client.id, + yachtId: yacht.id, + startDate: new Date(), + tenureType: 'permanent', + }, + makeAuditMeta({ portId: port.id }), + ); + + expect(reservation.status).toBe('pending'); + expect(reservation.clientId).toBe(client.id); + }); + + it('rejects when yacht does not belong to client (no membership)', 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, + }); + + await expect( + createPending( + port.id, + { + berthId: berth.id, + clientId: otherClient.id, + yachtId: yacht.id, + startDate: new Date(), + tenureType: 'permanent', + }, + makeAuditMeta({ portId: port.id }), + ), + ).rejects.toThrow(/yacht does not belong/i); + }); + + it('rejects when company-owned yacht client has an ended membership', async () => { + const port = await makePort(); + const berth = await makeBerth({ portId: port.id }); + const company = await makeCompany({ portId: port.id }); + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'company', + ownerId: company.id, + }); + + // Membership ended → should not authorise. + await db.insert(companyMemberships).values({ + companyId: company.id, + clientId: client.id, + role: 'director', + startDate: new Date('2024-01-01'), + endDate: new Date('2024-12-31'), + }); + + await expect( + createPending( + port.id, + { + berthId: berth.id, + clientId: client.id, + yachtId: yacht.id, + startDate: new Date(), + tenureType: 'permanent', + }, + makeAuditMeta({ portId: port.id }), + ), + ).rejects.toThrow(/yacht does not belong/i); + }); + + it('rejects berth from a different port (tenant)', async () => { + const portA = await makePort(); + const portB = await makePort(); + const berthInB = await makeBerth({ portId: portB.id }); + const clientInA = await makeClient({ portId: portA.id }); + const yachtInA = await makeYacht({ + portId: portA.id, + ownerType: 'client', + ownerId: clientInA.id, + }); + + await expect( + createPending( + portA.id, + { + berthId: berthInB.id, + clientId: clientInA.id, + yachtId: yachtInA.id, + startDate: new Date(), + tenureType: 'permanent', + }, + makeAuditMeta({ portId: portA.id }), + ), + ).rejects.toThrow(/berth not found/i); + }); + + it('rejects yacht from a different port (tenant)', async () => { + const portA = await makePort(); + const portB = await makePort(); + const berthInA = await makeBerth({ portId: portA.id }); + const clientInA = await makeClient({ portId: portA.id }); + const clientInB = await makeClient({ portId: portB.id }); + const yachtInB = await makeYacht({ + portId: portB.id, + ownerType: 'client', + ownerId: clientInB.id, + }); + + await expect( + createPending( + portA.id, + { + berthId: berthInA.id, + clientId: clientInA.id, + yachtId: yachtInB.id, + startDate: new Date(), + tenureType: 'permanent', + }, + makeAuditMeta({ portId: portA.id }), + ), + ).rejects.toThrow(/yacht not found/i); + }); +}); + +// ─── Lifecycle transitions ─────────────────────────────────────────────────── + +describe('berth-reservations.service — lifecycle transitions', () => { + async function setup() { + 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 reservation = await createPending( + port.id, + { + berthId: berth.id, + clientId: client.id, + yachtId: yacht.id, + startDate: new Date(), + tenureType: 'permanent', + }, + makeAuditMeta({ portId: port.id }), + ); + return { port, berth, client, yacht, reservation }; + } + + it('pending → active (activate)', async () => { + const { port, reservation } = await setup(); + const activated = await activate( + reservation.id, + port.id, + {}, + makeAuditMeta({ portId: port.id }), + ); + expect(activated.status).toBe('active'); + }); + + it('active → ended (endReservation)', async () => { + const { port, reservation } = await setup(); + await activate(reservation.id, port.id, {}, makeAuditMeta({ portId: port.id })); + const ended = await endReservation( + reservation.id, + port.id, + { endDate: new Date() }, + makeAuditMeta({ portId: port.id }), + ); + expect(ended.status).toBe('ended'); + expect(ended.endDate).not.toBeNull(); + }); + + it('pending → cancelled (cancel)', async () => { + const { port, reservation } = await setup(); + const cancelled = await cancel( + reservation.id, + port.id, + { reason: 'client withdrew' }, + makeAuditMeta({ portId: port.id }), + ); + expect(cancelled.status).toBe('cancelled'); + }); + + it('active → cancelled (cancel)', async () => { + const { port, reservation } = await setup(); + await activate(reservation.id, port.id, {}, makeAuditMeta({ portId: port.id })); + const cancelled = await cancel( + reservation.id, + port.id, + { reason: 'contract terminated' }, + makeAuditMeta({ portId: port.id }), + ); + expect(cancelled.status).toBe('cancelled'); + }); + + it('rejects ended → active (invalid transition)', async () => { + const { port, reservation } = await setup(); + await activate(reservation.id, port.id, {}, makeAuditMeta({ portId: port.id })); + await endReservation( + reservation.id, + port.id, + { endDate: new Date() }, + makeAuditMeta({ portId: port.id }), + ); + await expect( + activate(reservation.id, port.id, {}, makeAuditMeta({ portId: port.id })), + ).rejects.toThrow(/invalid transition/i); + }); + + it('rejects cancelled → active (invalid transition)', async () => { + const { port, reservation } = await setup(); + await cancel( + reservation.id, + port.id, + { reason: 'changed mind' }, + makeAuditMeta({ portId: port.id }), + ); + await expect( + activate(reservation.id, port.id, {}, makeAuditMeta({ portId: port.id })), + ).rejects.toThrow(/invalid transition/i); + }); + + it('rejects cancel from ended state', async () => { + const { port, reservation } = await setup(); + await activate(reservation.id, port.id, {}, makeAuditMeta({ portId: port.id })); + await endReservation( + reservation.id, + port.id, + { endDate: new Date() }, + makeAuditMeta({ portId: port.id }), + ); + await expect( + cancel(reservation.id, port.id, {}, makeAuditMeta({ portId: port.id })), + ).rejects.toThrow(/invalid transition/i); + }); + + it('rejects endReservation on a pending reservation', async () => { + const { port, reservation } = await setup(); + await expect( + endReservation( + reservation.id, + port.id, + { endDate: new Date() }, + makeAuditMeta({ portId: port.id }), + ), + ).rejects.toThrow(/invalid transition/i); + }); +}); + +// ─── listReservations ──────────────────────────────────────────────────────── + +describe('berth-reservations.service — listReservations', () => { + async function makeReservation(portId: string, opts?: { berthId?: string }) { + const berth = opts?.berthId ? { id: opts.berthId } : await makeBerth({ portId }); + const client = await makeClient({ portId }); + const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id }); + return createPending( + portId, + { + berthId: berth.id, + clientId: client.id, + yachtId: yacht.id, + startDate: new Date(), + tenureType: 'permanent', + }, + makeAuditMeta({ portId }), + ); + } + + it('is tenant-scoped', async () => { + const portA = await makePort(); + const portB = await makePort(); + const resA = await makeReservation(portA.id); + await makeReservation(portB.id); + + const result = await listReservations(portA.id, { + page: 1, + limit: 50, + order: 'desc', + includeArchived: false, + }); + const ids = result.data.map((r) => r.id); + expect(ids).toContain(resA.id); + expect(result.data.every((r) => r.portId === portA.id)).toBe(true); + }); + + it('filters by status', async () => { + const port = await makePort(); + const resPending = await makeReservation(port.id); + const resActive = await makeReservation(port.id); + await activate(resActive.id, port.id, {}, makeAuditMeta({ portId: port.id })); + + const activeList = await listReservations(port.id, { + page: 1, + limit: 50, + order: 'desc', + includeArchived: false, + status: 'active', + }); + expect(activeList.data.map((r) => r.id)).toContain(resActive.id); + expect(activeList.data.map((r) => r.id)).not.toContain(resPending.id); + }); + + it('filters by berthId', async () => { + const port = await makePort(); + const berth1 = await makeBerth({ portId: port.id }); + const berth2 = await makeBerth({ portId: port.id }); + + const client1 = await makeClient({ portId: port.id }); + const yacht1 = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client1.id }); + const res1 = await createPending( + port.id, + { berthId: berth1.id, clientId: client1.id, yachtId: yacht1.id, startDate: new Date() }, + makeAuditMeta({ portId: port.id }), + ); + + const client2 = await makeClient({ portId: port.id }); + const yacht2 = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client2.id }); + const res2 = await createPending( + port.id, + { berthId: berth2.id, clientId: client2.id, yachtId: yacht2.id, startDate: new Date() }, + makeAuditMeta({ portId: port.id }), + ); + + const result = await listReservations(port.id, { + page: 1, + limit: 50, + order: 'desc', + includeArchived: false, + berthId: berth1.id, + }); + const ids = result.data.map((r) => r.id); + expect(ids).toContain(res1.id); + expect(ids).not.toContain(res2.id); + }); +}); + +// ─── Self-check: DB state is as expected after cancel ──────────────────────── + +describe('berth-reservations.service — DB state', () => { + it('cancel persists status=cancelled in the database', 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 res = await createPending( + port.id, + { berthId: berth.id, clientId: client.id, yachtId: yacht.id, startDate: new Date() }, + makeAuditMeta({ portId: port.id }), + ); + await cancel(res.id, port.id, {}, makeAuditMeta({ portId: port.id })); + + const { berthReservations } = await import('@/lib/db/schema'); + const [row] = await db.select().from(berthReservations).where(eq(berthReservations.id, res.id)); + expect(row!.status).toBe('cancelled'); + }); +}); diff --git a/tests/unit/validators.test.ts b/tests/unit/validators.test.ts index ef1c57f..9a60899 100644 --- a/tests/unit/validators.test.ts +++ b/tests/unit/validators.test.ts @@ -8,6 +8,7 @@ import { createFieldSchema, updateFieldSchema } from '@/lib/validators/custom-fi import { createYachtSchema, transferOwnershipSchema } from '@/lib/validators/yachts'; import { createCompanySchema } from '@/lib/validators/companies'; import { addMembershipSchema } from '@/lib/validators/company-memberships'; +import { createPendingSchema } from '@/lib/validators/reservations'; // ─── Client schemas ─────────────────────────────────────────────────────────── @@ -483,3 +484,49 @@ describe('addMembershipSchema', () => { expect(result.success).toBe(true); }); }); + +// ─── Reservation schemas ───────────────────────────────────────────────────── + +describe('createPendingSchema', () => { + const validInput = { + berthId: 'berth-1', + clientId: 'client-1', + yachtId: 'yacht-1', + startDate: '2026-05-01', + }; + + it('rejects missing berthId', () => { + const result = createPendingSchema.safeParse({ + clientId: validInput.clientId, + yachtId: validInput.yachtId, + startDate: validInput.startDate, + }); + expect(result.success).toBe(false); + }); + + it('rejects missing clientId', () => { + const result = createPendingSchema.safeParse({ + berthId: validInput.berthId, + yachtId: validInput.yachtId, + startDate: validInput.startDate, + }); + expect(result.success).toBe(false); + }); + + it('rejects missing yachtId', () => { + const result = createPendingSchema.safeParse({ + berthId: validInput.berthId, + clientId: validInput.clientId, + startDate: validInput.startDate, + }); + expect(result.success).toBe(false); + }); + + it('accepts minimal valid input with default tenureType', () => { + const result = createPendingSchema.safeParse(validInput); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tenureType).toBe('permanent'); + } + }); +});