- Implemented rescheduleAdminBooking service to allow users to change court and time for confirmed bookings. - Added validation for court availability, maintenance status, and overlapping bookings. - Created reschedule-admin-booking handler to process rescheduling requests and send confirmation emails. - Updated booking email service to include rescheduling notifications. - Enhanced frontend components to support booking rescheduling, including a new dialog for selecting new court and time. - Added tests for rescheduling logic, covering various scenarios including validation errors and successful reschedules. - Updated Prisma schema to log previous court and time for audit purposes.
318 lines
9.5 KiB
TypeScript
318 lines
9.5 KiB
TypeScript
import { beforeEach, expect, test } from 'bun:test';
|
|
|
|
import { prismaMock, transactionMock, uuidV7Mock } from '../support/prisma.mock';
|
|
|
|
const { AdminBookingServiceError, rescheduleAdminBooking } = await import(
|
|
'@/modules/admin-booking/services/admin-booking.service'
|
|
);
|
|
|
|
function makeBooking(overrides?: Record<string, unknown>) {
|
|
return {
|
|
id: 'booking-1',
|
|
bookingCode: 'ABC123',
|
|
courtId: 'court-1',
|
|
bookingDate: new Date('2026-07-15T00:00:00.000Z'),
|
|
startTime: '10:00',
|
|
endTime: '11:00',
|
|
customerName: 'Juan Perez',
|
|
customerPhone: '1144444444',
|
|
customerEmail: 'juan@example.com',
|
|
status: 'CONFIRMED',
|
|
recurringGroupId: null,
|
|
createdAt: new Date('2026-07-14T00:00:00.000Z'),
|
|
updatedAt: new Date('2026-07-14T00:00:00.000Z'),
|
|
court: {
|
|
id: 'court-1',
|
|
name: 'Cancha 1',
|
|
complexId: 'complex-1',
|
|
slotDurationMinutes: 60,
|
|
sport: { id: 'sport-1', name: 'Tenis', slug: 'tenis' },
|
|
complex: { id: 'complex-1', complexName: 'Complejo Test' },
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// July 15, 2026 is a Wednesday
|
|
const WEDNESDAY = 'WEDNESDAY';
|
|
|
|
function makeCourt(overrides?: Record<string, unknown>) {
|
|
return {
|
|
id: 'court-2',
|
|
name: 'Cancha 2',
|
|
complexId: 'complex-1',
|
|
slotDurationMinutes: 60,
|
|
basePrice: 15000,
|
|
isUnderMaintenance: false,
|
|
availabilities: [{ id: 'avail-1', dayOfWeek: WEDNESDAY, startTime: '08:00', endTime: '18:00' }],
|
|
priceRules: [],
|
|
sport: { id: 'sport-1', name: 'Tenis', slug: 'tenis' },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
prismaMock._reset();
|
|
transactionMock.mockClear();
|
|
uuidV7Mock.mockReset();
|
|
uuidV7Mock.mockReturnValue('00000000-0000-4000-8000-000000000001');
|
|
});
|
|
|
|
test('reschedules a booking changing both court and time', async () => {
|
|
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
|
|
prismaMock.court.findFirst.mockResolvedValue(makeCourt() as never);
|
|
|
|
prismaMock.courtBooking.findFirst
|
|
.mockResolvedValueOnce(makeBooking() as never) // initial fetch
|
|
.mockResolvedValueOnce(null as never); // overlap check -> no overlap
|
|
|
|
transactionMock.mockImplementation(async <T>(fn: (tx: typeof prismaMock) => Promise<T>) =>
|
|
fn(prismaMock)
|
|
);
|
|
|
|
prismaMock.courtBookingLog.create.mockResolvedValue({} as never);
|
|
prismaMock.courtBooking.update.mockResolvedValue(
|
|
makeBooking({
|
|
courtId: 'court-2',
|
|
startTime: '14:00',
|
|
endTime: '15:00',
|
|
}) as never
|
|
);
|
|
|
|
const result = await rescheduleAdminBooking('user-1', 'booking-1', {
|
|
courtId: 'court-2',
|
|
startTime: '14:00',
|
|
});
|
|
|
|
expect(result.courtId).toBe('court-2');
|
|
expect(result.startTime).toBe('14:00');
|
|
expect(result.courtName).toBe('Cancha 2');
|
|
expect(prismaMock.courtBookingLog.create).toHaveBeenCalledTimes(1);
|
|
expect(prismaMock.courtBooking.update).toHaveBeenCalledWith({
|
|
where: { id: 'booking-1' },
|
|
data: {
|
|
courtId: 'court-2',
|
|
startTime: '14:00',
|
|
endTime: '15:00',
|
|
},
|
|
});
|
|
|
|
const logArgs = prismaMock.courtBookingLog.create.mock.calls[0]?.[0] as {
|
|
data: { previousCourtId: string; previousStartTime: string; previousEndTime: string };
|
|
};
|
|
expect(logArgs.data.previousCourtId).toBe('court-1');
|
|
expect(logArgs.data.previousStartTime).toBe('10:00');
|
|
expect(logArgs.data.previousEndTime).toBe('11:00');
|
|
});
|
|
|
|
test('reschedules a booking changing only the time', async () => {
|
|
prismaMock.courtBooking.findFirst
|
|
.mockResolvedValueOnce(makeBooking() as never) // initial fetch
|
|
.mockResolvedValueOnce(null as never); // overlap check
|
|
|
|
// Same court, different time
|
|
prismaMock.court.findFirst.mockResolvedValue(
|
|
makeCourt({
|
|
id: 'court-1',
|
|
name: 'Cancha 1',
|
|
availabilities: [
|
|
{ id: 'avail-1', dayOfWeek: WEDNESDAY, startTime: '08:00', endTime: '18:00' },
|
|
],
|
|
}) as never
|
|
);
|
|
|
|
transactionMock.mockImplementation(async <T>(fn: (tx: typeof prismaMock) => Promise<T>) =>
|
|
fn(prismaMock)
|
|
);
|
|
|
|
prismaMock.courtBookingLog.create.mockResolvedValue({} as never);
|
|
prismaMock.courtBooking.update.mockResolvedValue(
|
|
makeBooking({
|
|
startTime: '15:00',
|
|
endTime: '16:00',
|
|
}) as never
|
|
);
|
|
|
|
const result = await rescheduleAdminBooking('user-1', 'booking-1', {
|
|
startTime: '15:00',
|
|
});
|
|
|
|
expect(result.startTime).toBe('15:00');
|
|
expect(result.courtId).toBe('court-1');
|
|
expect(prismaMock.courtBookingLog.create).toHaveBeenCalledTimes(1);
|
|
expect(prismaMock.courtBooking.update).toHaveBeenCalledWith({
|
|
where: { id: 'booking-1' },
|
|
data: {
|
|
courtId: 'court-1',
|
|
startTime: '15:00',
|
|
endTime: '16:00',
|
|
},
|
|
});
|
|
});
|
|
|
|
test('rejects when booking is not found', async () => {
|
|
prismaMock.courtBooking.findFirst.mockResolvedValue(null as never);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await rescheduleAdminBooking('user-1', 'non-existent', { startTime: '14:00' });
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(404);
|
|
expect(transactionMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects when booking status is not CONFIRMED', async () => {
|
|
prismaMock.courtBooking.findFirst.mockResolvedValue(
|
|
makeBooking({ status: 'COMPLETED' }) as never
|
|
);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await rescheduleAdminBooking('user-1', 'booking-1', { startTime: '14:00' });
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
|
|
expect(transactionMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects when slot is not available in target court', async () => {
|
|
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
|
|
// Court with no availabilities that match the requested time
|
|
prismaMock.court.findFirst.mockResolvedValue(
|
|
makeCourt({
|
|
id: 'court-2',
|
|
availabilities: [
|
|
{ id: 'avail-1', dayOfWeek: WEDNESDAY, startTime: '08:00', endTime: '09:00' },
|
|
],
|
|
}) as never
|
|
);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await rescheduleAdminBooking('user-1', 'booking-1', {
|
|
courtId: 'court-2',
|
|
startTime: '14:00',
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
|
|
});
|
|
|
|
test('rejects when target court has a different sport', async () => {
|
|
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
|
|
prismaMock.court.findFirst.mockResolvedValue(
|
|
makeCourt({
|
|
sport: { id: 'sport-2', name: 'Fútbol', slug: 'futbol' },
|
|
}) as never
|
|
);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await rescheduleAdminBooking('user-1', 'booking-1', {
|
|
courtId: 'court-2',
|
|
startTime: '14:00',
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
|
|
expect(transactionMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects when target court is under maintenance', async () => {
|
|
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
|
|
prismaMock.court.findFirst.mockResolvedValue(
|
|
makeCourt({
|
|
isUnderMaintenance: true,
|
|
}) as never
|
|
);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await rescheduleAdminBooking('user-1', 'booking-1', {
|
|
courtId: 'court-2',
|
|
startTime: '14:00',
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
|
|
});
|
|
|
|
test('rejects when there is an overlapping booking', async () => {
|
|
prismaMock.courtBooking.findFirst
|
|
.mockResolvedValueOnce(makeBooking() as never) // initial fetch
|
|
.mockResolvedValueOnce({ id: 'overlap-1' } as never); // overlap found
|
|
|
|
prismaMock.court.findFirst.mockResolvedValue(
|
|
makeCourt({
|
|
availabilities: [
|
|
{ id: 'avail-1', dayOfWeek: WEDNESDAY, startTime: '08:00', endTime: '18:00' },
|
|
],
|
|
}) as never
|
|
);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await rescheduleAdminBooking('user-1', 'booking-1', {
|
|
courtId: 'court-2',
|
|
startTime: '14:00',
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(409);
|
|
expect(transactionMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects when neither courtId nor startTime changes', async () => {
|
|
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await rescheduleAdminBooking('user-1', 'booking-1', {});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(400);
|
|
expect(transactionMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects when target court does not belong to the same complex', async () => {
|
|
prismaMock.courtBooking.findFirst.mockResolvedValue(makeBooking() as never);
|
|
// Court belongs to a different complex
|
|
prismaMock.court.findFirst.mockResolvedValue(null as never);
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await rescheduleAdminBooking('user-1', 'booking-1', {
|
|
courtId: 'other-complex-court',
|
|
startTime: '14:00',
|
|
});
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
|
|
expect(caught).toBeInstanceOf(AdminBookingServiceError);
|
|
expect((caught as InstanceType<typeof AdminBookingServiceError>).status).toBe(404);
|
|
expect(transactionMock).not.toHaveBeenCalled();
|
|
});
|