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)
94 lines
3.1 KiB
TypeScript
94 lines
3.1 KiB
TypeScript
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();
|
|
});
|
|
});
|