mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
be4bb3fcde
* feat: TOUTOC in handleSeats * fix type check * refactor: replace Prisma include with select in integration tests Address Cubic AI review feedback (confidence 9/10) to use select instead of include in Prisma queries to limit returned fields and avoid pulling full related records. Co-Authored-By: unknown <> * fix seat count --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
502 lines
13 KiB
TypeScript
502 lines
13 KiB
TypeScript
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
|
|
|
import { prisma } from "@calcom/prisma";
|
|
import { BookingStatus } from "@calcom/prisma/enums";
|
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
|
|
|
import { addSeatToBooking } from "./createNewSeat";
|
|
|
|
// Track resources to clean up
|
|
const createdBookingIds: number[] = [];
|
|
let testUserId: number;
|
|
let testEventTypeId: number;
|
|
|
|
async function clearTestData() {
|
|
if (createdBookingIds.length > 0) {
|
|
await prisma.bookingSeat.deleteMany({
|
|
where: { bookingId: { in: createdBookingIds } },
|
|
});
|
|
await prisma.attendee.deleteMany({
|
|
where: { bookingId: { in: createdBookingIds } },
|
|
});
|
|
await prisma.booking.deleteMany({
|
|
where: { id: { in: createdBookingIds } },
|
|
});
|
|
createdBookingIds.length = 0;
|
|
}
|
|
}
|
|
|
|
describe("createNewSeat Race Condition Prevention (Integration)", () => {
|
|
beforeAll(async () => {
|
|
// Use existing seed user
|
|
const testUser = await prisma.user.findFirstOrThrow({
|
|
where: { email: "[email protected]" },
|
|
});
|
|
testUserId = testUser.id;
|
|
|
|
// Find or create a test event type with seats
|
|
let eventType = await prisma.eventType.findFirst({
|
|
where: {
|
|
userId: testUserId,
|
|
seatsPerTimeSlot: { not: null },
|
|
},
|
|
});
|
|
|
|
if (!eventType) {
|
|
eventType = await prisma.eventType.create({
|
|
data: {
|
|
title: "Seated Test Event",
|
|
slug: `seated-test-event-${Date.now()}`,
|
|
length: 30,
|
|
userId: testUserId,
|
|
seatsPerTimeSlot: 2,
|
|
seatsShowAttendees: false,
|
|
},
|
|
});
|
|
}
|
|
testEventTypeId = eventType.id;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await clearTestData();
|
|
});
|
|
|
|
it("should prevent overbooking when concurrent requests race for the last seat", async () => {
|
|
const bookingUid = `race-test-${Date.now()}`;
|
|
const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
|
|
const seatsPerTimeSlot = 2;
|
|
|
|
// Create a booking first
|
|
const booking = await prisma.booking.create({
|
|
data: {
|
|
uid: bookingUid,
|
|
userId: testUserId,
|
|
eventTypeId: testEventTypeId,
|
|
status: BookingStatus.ACCEPTED,
|
|
startTime,
|
|
endTime,
|
|
title: "Race Condition Test Booking",
|
|
},
|
|
});
|
|
|
|
createdBookingIds.push(booking.id);
|
|
|
|
// Create 1 existing seat (out of 2)
|
|
const attendee = await prisma.attendee.create({
|
|
data: {
|
|
email: "[email protected]",
|
|
name: "Existing Seat",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
bookingId: booking.id,
|
|
},
|
|
});
|
|
|
|
await prisma.bookingSeat.create({
|
|
data: {
|
|
referenceUid: `existing-seat-${Date.now()}`,
|
|
data: {},
|
|
bookingId: booking.id,
|
|
attendeeId: attendee.id,
|
|
},
|
|
});
|
|
|
|
// Fire 2 concurrent requests for the last seat using the actual addSeatToBooking function
|
|
const results = await Promise.allSettled([
|
|
addSeatToBooking({
|
|
bookingUid,
|
|
bookingId: booking.id,
|
|
bookingStatus: BookingStatus.ACCEPTED,
|
|
seatsPerTimeSlot,
|
|
attendee: {
|
|
email: "[email protected]",
|
|
name: "Racer 1",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
},
|
|
seatData: {},
|
|
}),
|
|
addSeatToBooking({
|
|
bookingUid,
|
|
bookingId: booking.id,
|
|
bookingStatus: BookingStatus.ACCEPTED,
|
|
seatsPerTimeSlot,
|
|
attendee: {
|
|
email: "[email protected]",
|
|
name: "Racer 2",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
},
|
|
seatData: {},
|
|
}),
|
|
]);
|
|
|
|
// Count successes and failures
|
|
const successes = results.filter((r) => r.status === "fulfilled");
|
|
const failures = results.filter(
|
|
(r) => r.status === "rejected" && (r.reason as Error).message === ErrorCode.BookingSeatsFull
|
|
);
|
|
|
|
// Exactly 1 should succeed, 1 should fail with BookingSeatsFull
|
|
expect(successes.length).toBe(1);
|
|
expect(failures.length).toBe(1);
|
|
|
|
// Verify final seat count is exactly 2 (1 existing + 1 new)
|
|
const finalBooking = await prisma.booking.findUnique({
|
|
where: { uid: bookingUid },
|
|
select: {
|
|
attendees: {
|
|
select: {
|
|
id: true,
|
|
bookingSeat: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const finalSeatCount = finalBooking?.attendees.filter((a) => !!a.bookingSeat).length;
|
|
expect(finalSeatCount).toBe(2);
|
|
});
|
|
|
|
it("should allow both bookings when enough seats are available", async () => {
|
|
const bookingUid = `multi-seat-test-${Date.now()}`;
|
|
const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
|
|
const seatsPerTimeSlot = 3; // 3 seats available
|
|
|
|
// Create a booking with no seats taken yet
|
|
const booking = await prisma.booking.create({
|
|
data: {
|
|
uid: bookingUid,
|
|
userId: testUserId,
|
|
eventTypeId: testEventTypeId,
|
|
status: BookingStatus.ACCEPTED,
|
|
startTime,
|
|
endTime,
|
|
title: "Multi Seat Test Booking",
|
|
},
|
|
});
|
|
|
|
createdBookingIds.push(booking.id);
|
|
|
|
// Fire 2 concurrent requests when there are 3 seats available
|
|
const results = await Promise.allSettled([
|
|
addSeatToBooking({
|
|
bookingUid,
|
|
bookingId: booking.id,
|
|
bookingStatus: BookingStatus.ACCEPTED,
|
|
seatsPerTimeSlot,
|
|
attendee: {
|
|
email: "[email protected]",
|
|
name: "Booker 1",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
},
|
|
seatData: {},
|
|
}),
|
|
addSeatToBooking({
|
|
bookingUid,
|
|
bookingId: booking.id,
|
|
bookingStatus: BookingStatus.ACCEPTED,
|
|
seatsPerTimeSlot,
|
|
attendee: {
|
|
email: "[email protected]",
|
|
name: "Booker 2",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
},
|
|
seatData: {},
|
|
}),
|
|
]);
|
|
|
|
// Both should succeed
|
|
const successes = results.filter((r) => r.status === "fulfilled");
|
|
expect(successes.length).toBe(2);
|
|
|
|
// Verify final seat count is 2
|
|
const finalBooking = await prisma.booking.findUnique({
|
|
where: { uid: bookingUid },
|
|
select: {
|
|
attendees: {
|
|
select: {
|
|
id: true,
|
|
bookingSeat: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const finalSeatCount = finalBooking?.attendees.filter((a) => !!a.bookingSeat).length;
|
|
expect(finalSeatCount).toBe(2);
|
|
});
|
|
|
|
it("should reject booking when event is already full", async () => {
|
|
const bookingUid = `full-event-test-${Date.now()}`;
|
|
const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
|
|
const seatsPerTimeSlot = 2;
|
|
|
|
// Create a booking first
|
|
const booking = await prisma.booking.create({
|
|
data: {
|
|
uid: bookingUid,
|
|
userId: testUserId,
|
|
eventTypeId: testEventTypeId,
|
|
status: BookingStatus.ACCEPTED,
|
|
startTime,
|
|
endTime,
|
|
title: "Full Event Test Booking",
|
|
},
|
|
});
|
|
|
|
createdBookingIds.push(booking.id);
|
|
|
|
// Create 2 seats (filling all available seats)
|
|
const attendee1 = await prisma.attendee.create({
|
|
data: {
|
|
email: "[email protected]",
|
|
name: "Seat 1",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
bookingId: booking.id,
|
|
},
|
|
});
|
|
|
|
await prisma.bookingSeat.create({
|
|
data: {
|
|
referenceUid: `seat1-${Date.now()}`,
|
|
data: {},
|
|
bookingId: booking.id,
|
|
attendeeId: attendee1.id,
|
|
},
|
|
});
|
|
|
|
const attendee2 = await prisma.attendee.create({
|
|
data: {
|
|
email: "[email protected]",
|
|
name: "Seat 2",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
bookingId: booking.id,
|
|
},
|
|
});
|
|
|
|
await prisma.bookingSeat.create({
|
|
data: {
|
|
referenceUid: `seat2-${Date.now()}`,
|
|
data: {},
|
|
bookingId: booking.id,
|
|
attendeeId: attendee2.id,
|
|
},
|
|
});
|
|
|
|
// Try to add another seat - should fail
|
|
await expect(
|
|
addSeatToBooking({
|
|
bookingUid,
|
|
bookingId: booking.id,
|
|
bookingStatus: BookingStatus.ACCEPTED,
|
|
seatsPerTimeSlot,
|
|
attendee: {
|
|
email: "[email protected]",
|
|
name: "Late Comer",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
},
|
|
seatData: {},
|
|
})
|
|
).rejects.toThrow(ErrorCode.BookingSeatsFull);
|
|
|
|
// Verify seat count is still 2
|
|
const finalBooking = await prisma.booking.findUnique({
|
|
where: { uid: bookingUid },
|
|
select: {
|
|
attendees: {
|
|
select: {
|
|
id: true,
|
|
bookingSeat: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const finalSeatCount = finalBooking?.attendees.filter((a) => !!a.bookingSeat).length;
|
|
expect(finalSeatCount).toBe(2);
|
|
});
|
|
|
|
it("should allow booking when seatsPerTimeSlot is 0 (falsy)", async () => {
|
|
// This tests the fix for the bug where null/undefined seatsPerTimeSlot
|
|
// would fallback to 0 and incorrectly reject all bookings because
|
|
// 0 <= currentSeatCount was always true
|
|
const bookingUid = `zero-seats-test-${Date.now()}`;
|
|
const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
|
|
const seatsPerTimeSlot = 0; // Simulates null/undefined fallback
|
|
|
|
// Create a booking
|
|
const booking = await prisma.booking.create({
|
|
data: {
|
|
uid: bookingUid,
|
|
userId: testUserId,
|
|
eventTypeId: testEventTypeId,
|
|
status: BookingStatus.ACCEPTED,
|
|
startTime,
|
|
endTime,
|
|
title: "Zero Seats Test Booking",
|
|
},
|
|
});
|
|
|
|
createdBookingIds.push(booking.id);
|
|
|
|
// Add a seat with seatsPerTimeSlot = 0 - should succeed
|
|
const result = await addSeatToBooking({
|
|
bookingUid,
|
|
bookingId: booking.id,
|
|
bookingStatus: BookingStatus.ACCEPTED,
|
|
seatsPerTimeSlot,
|
|
attendee: {
|
|
email: "[email protected]",
|
|
name: "Booker",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
},
|
|
seatData: {},
|
|
});
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result?.referenceUid).toBeDefined();
|
|
|
|
// Verify the seat was created
|
|
const finalBooking = await prisma.booking.findUnique({
|
|
where: { uid: bookingUid },
|
|
select: {
|
|
attendees: {
|
|
select: {
|
|
id: true,
|
|
bookingSeat: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const finalSeatCount = finalBooking?.attendees.filter((a) => !!a.bookingSeat).length;
|
|
expect(finalSeatCount).toBe(1);
|
|
});
|
|
|
|
it("should allow multiple bookings when seatsPerTimeSlot is 0 (no limit)", async () => {
|
|
const bookingUid = `zero-seats-multi-test-${Date.now()}`;
|
|
const startTime = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
|
|
const seatsPerTimeSlot = 0; // No limit enforced
|
|
|
|
// Create a booking
|
|
const booking = await prisma.booking.create({
|
|
data: {
|
|
uid: bookingUid,
|
|
userId: testUserId,
|
|
eventTypeId: testEventTypeId,
|
|
status: BookingStatus.ACCEPTED,
|
|
startTime,
|
|
endTime,
|
|
title: "Zero Seats Multi Test Booking",
|
|
},
|
|
});
|
|
|
|
createdBookingIds.push(booking.id);
|
|
|
|
// Create existing seats
|
|
const attendee1 = await prisma.attendee.create({
|
|
data: {
|
|
email: "[email protected]",
|
|
name: "Existing 1",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
bookingId: booking.id,
|
|
},
|
|
});
|
|
|
|
await prisma.bookingSeat.create({
|
|
data: {
|
|
referenceUid: `existing1-${Date.now()}`,
|
|
data: {},
|
|
bookingId: booking.id,
|
|
attendeeId: attendee1.id,
|
|
},
|
|
});
|
|
|
|
const attendee2 = await prisma.attendee.create({
|
|
data: {
|
|
email: "[email protected]",
|
|
name: "Existing 2",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
bookingId: booking.id,
|
|
},
|
|
});
|
|
|
|
await prisma.bookingSeat.create({
|
|
data: {
|
|
referenceUid: `existing2-${Date.now()}`,
|
|
data: {},
|
|
bookingId: booking.id,
|
|
attendeeId: attendee2.id,
|
|
},
|
|
});
|
|
|
|
// Add another seat with seatsPerTimeSlot = 0 - should succeed even with existing seats
|
|
const result = await addSeatToBooking({
|
|
bookingUid,
|
|
bookingId: booking.id,
|
|
bookingStatus: BookingStatus.ACCEPTED,
|
|
seatsPerTimeSlot,
|
|
attendee: {
|
|
email: "[email protected]",
|
|
name: "New Booker",
|
|
timeZone: "UTC",
|
|
locale: "en",
|
|
},
|
|
seatData: {},
|
|
});
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result?.referenceUid).toBeDefined();
|
|
|
|
// Verify total seat count is 3 (2 existing + 1 new)
|
|
const finalBooking = await prisma.booking.findUnique({
|
|
where: { uid: bookingUid },
|
|
select: {
|
|
attendees: {
|
|
select: {
|
|
id: true,
|
|
bookingSeat: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const finalSeatCount = finalBooking?.attendees.filter((a) => !!a.bookingSeat).length;
|
|
expect(finalSeatCount).toBe(3);
|
|
});
|
|
});
|