feat(reservations): service + validators + exclusivity tests

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)
This commit is contained in:
Matt Ciaccio
2026-04-24 12:15:22 +02:00
parent 15a79e7990
commit b1133c4e87
6 changed files with 995 additions and 0 deletions

View File

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