mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
3908cda372
* perf: optimize getBusyTimesForLimitChecks with batched parallel queries and raw SQL Co-Authored-By: [email protected] <[email protected]> * test: add unit tests for getBusyTimesForLimitChecks batched query optimization Co-Authored-By: [email protected] <[email protected]> * refactor: use Prisma findMany instead of raw SQL for better testability The batching (50 user chunks) and parallel execution (Promise.all) provide the main performance optimization. Using findMany instead of $queryRaw maintains the same performance characteristics while being compatible with prismock for testing. Co-Authored-By: [email protected] <[email protected]> * fix: type error * refactor: add integration test * chore: remove log * chore: remove log * fix: avoid logging PII (userId) in busyTimes debug log Replace JSON.stringify(busyTimes) with count to avoid exposing userId in logs. Addresses Cubic AI review feedback (confidence 9/10). Co-Authored-By: unknown <> * chore: undo --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import { prisma } from "@calcom/prisma/__mocks__/prisma";
|
|
import dayjs from "@calcom/dayjs";
|
|
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@calcom/prisma", () => ({
|
|
default: prisma,
|
|
prisma,
|
|
}));
|
|
|
|
const startOfTomorrow = dayjs().add(1, "day").startOf("day");
|
|
const tomorrowDate = startOfTomorrow.format("YYYY-MM-DD");
|
|
|
|
const mockBookings = ({
|
|
beforeEventBuffer = 0,
|
|
afterEventBuffer = 0,
|
|
seatsPerTimeSlot,
|
|
}: {
|
|
beforeEventBuffer?: number;
|
|
afterEventBuffer?: number;
|
|
seatsPerTimeSlot?: number;
|
|
}) => [
|
|
{
|
|
id: 1,
|
|
startTime: startOfTomorrow.set("hour", 10).toDate(),
|
|
endTime: startOfTomorrow.set("hour", 11).toDate(),
|
|
title: "Booking Between X and Y",
|
|
userId: 1,
|
|
uid: "xxxx1",
|
|
eventType: {
|
|
id: 1,
|
|
beforeEventBuffer,
|
|
afterEventBuffer,
|
|
seatsPerTimeSlot: seatsPerTimeSlot ?? null,
|
|
},
|
|
_count: {
|
|
seatsReferences: 1,
|
|
},
|
|
},
|
|
{
|
|
id: 2,
|
|
startTime: startOfTomorrow.set("hour", 14).toDate(),
|
|
endTime: startOfTomorrow.set("hour", 15).toDate(),
|
|
title: "Booking Between X and Y",
|
|
userId: 1,
|
|
uid: "xxxx2",
|
|
eventType: {
|
|
id: 1,
|
|
beforeEventBuffer,
|
|
afterEventBuffer,
|
|
seatsPerTimeSlot: seatsPerTimeSlot ?? null,
|
|
},
|
|
...(seatsPerTimeSlot
|
|
? {
|
|
_count: {
|
|
seatsReferences: 1,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
];
|
|
|
|
describe("getBusyTimes", () => {
|
|
it("blocks a regular time slot", async () => {
|
|
const busyTimesService = getBusyTimesService();
|
|
const busyTimes = await busyTimesService.getBusyTimes({
|
|
credentials: [],
|
|
userId: 1,
|
|
userEmail: "[email protected]",
|
|
username: "exampleuser1",
|
|
bypassBusyCalendarTimes: false,
|
|
selectedCalendars: [],
|
|
startTime: startOfTomorrow.format(),
|
|
endTime: startOfTomorrow.endOf("day").format(),
|
|
currentBookings: mockBookings({}),
|
|
});
|
|
expect(busyTimes).toEqual([
|
|
expect.objectContaining({
|
|
start: dayjs(`${tomorrowDate}`).startOf("day").set("hour", 10).toDate(),
|
|
end: dayjs(`${tomorrowDate}`).startOf("day").set("hour", 11).toDate(),
|
|
source: "eventType-1-booking-1",
|
|
title: "Booking Between X and Y",
|
|
}),
|
|
expect.objectContaining({
|
|
start: dayjs(`${tomorrowDate}`).startOf("day").set("hour", 14).toDate(),
|
|
end: dayjs(`${tomorrowDate}`).startOf("day").set("hour", 15).toDate(),
|
|
source: "eventType-1-booking-2",
|
|
title: "Booking Between X and Y",
|
|
}),
|
|
]);
|
|
});
|
|
it("should block before and after buffer times", async () => {
|
|
const busyTimesService = getBusyTimesService();
|
|
const busyTimes = await busyTimesService.getBusyTimes({
|
|
credentials: [],
|
|
userId: 1,
|
|
userEmail: "[email protected]",
|
|
username: "exampleuser1",
|
|
bypassBusyCalendarTimes: false,
|
|
selectedCalendars: [],
|
|
startTime: startOfTomorrow.format(),
|
|
endTime: startOfTomorrow.endOf("day").format(),
|
|
currentBookings: [mockBookings({ beforeEventBuffer: 10 })[0]],
|
|
});
|
|
expect(busyTimes).toEqual([
|
|
expect.objectContaining({
|
|
start: dayjs(`${tomorrowDate}`).startOf("day").set("hour", 9).set("minute", 50).toDate(),
|
|
end: dayjs(`${tomorrowDate}`).startOf("day").set("hour", 11).toDate(),
|
|
source: "eventType-1-booking-1",
|
|
title: "Booking Between X and Y",
|
|
}),
|
|
]);
|
|
});
|
|
it("should have busy times only if seated with remaining seats when buffers exist", async () => {
|
|
const busyTimesService = getBusyTimesService();
|
|
const busyTimes = await busyTimesService.getBusyTimes({
|
|
credentials: [],
|
|
userId: 1,
|
|
eventTypeId: 1,
|
|
userEmail: "[email protected]",
|
|
username: "exampleuser1",
|
|
bypassBusyCalendarTimes: false,
|
|
selectedCalendars: [],
|
|
startTime: startOfTomorrow.format(),
|
|
endTime: startOfTomorrow.endOf("day").format(),
|
|
currentBookings: [mockBookings({ beforeEventBuffer: 10, seatsPerTimeSlot: 10 })[0]],
|
|
});
|
|
expect(busyTimes).toEqual([
|
|
expect.objectContaining({
|
|
start: dayjs(`${tomorrowDate}`).startOf("day").set("hour", 9).set("minute", 50).toDate(),
|
|
end: dayjs(`${tomorrowDate}`).startOf("day").set("hour", 10).toDate(),
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("getBusyTimesForLimitChecks", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
const createMockBookingResult = (
|
|
overrides: Partial<{
|
|
id: number;
|
|
startTime: Date;
|
|
endTime: Date;
|
|
eventTypeId: number | null;
|
|
title: string;
|
|
userId: number | null;
|
|
}> = {}
|
|
) => ({
|
|
id: 1,
|
|
startTime: startOfTomorrow.set("hour", 10).toDate(),
|
|
endTime: startOfTomorrow.set("hour", 11).toDate(),
|
|
eventTypeId: 1,
|
|
title: "Test Booking",
|
|
userId: 1,
|
|
...overrides,
|
|
});
|
|
|
|
it("should return empty array when no booking or duration limits are provided", async () => {
|
|
const busyTimesService = getBusyTimesService();
|
|
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
|
|
userIds: [1, 2, 3],
|
|
eventTypeId: 1,
|
|
startDate: startOfTomorrow.format(),
|
|
endDate: startOfTomorrow.endOf("day").format(),
|
|
bookingLimits: null,
|
|
durationLimits: null,
|
|
});
|
|
|
|
expect(busyTimes).toEqual([]);
|
|
expect(prisma.booking.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should return empty array when userIds is empty", async () => {
|
|
const busyTimesService = getBusyTimesService();
|
|
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
|
|
userIds: [],
|
|
eventTypeId: 1,
|
|
startDate: startOfTomorrow.format(),
|
|
endDate: startOfTomorrow.endOf("day").format(),
|
|
bookingLimits: { PER_DAY: 5 },
|
|
});
|
|
|
|
expect(busyTimes).toEqual([]);
|
|
});
|
|
|
|
it("should fetch bookings for a single user with booking limits", async () => {
|
|
const mockBooking = createMockBookingResult();
|
|
vi.mocked(prisma.booking.findMany).mockResolvedValue([mockBooking]);
|
|
|
|
const busyTimesService = getBusyTimesService();
|
|
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
|
|
userIds: [1],
|
|
eventTypeId: 1,
|
|
startDate: startOfTomorrow.format(),
|
|
endDate: startOfTomorrow.endOf("day").format(),
|
|
bookingLimits: { PER_DAY: 5 },
|
|
});
|
|
|
|
expect(busyTimes).toHaveLength(1);
|
|
expect(busyTimes[0]).toEqual(
|
|
expect.objectContaining({
|
|
start: mockBooking.startTime,
|
|
end: mockBooking.endTime,
|
|
title: mockBooking.title,
|
|
source: `eventType-${mockBooking.eventTypeId}-booking-${mockBooking.id}`,
|
|
userId: mockBooking.userId,
|
|
})
|
|
);
|
|
expect(prisma.booking.findMany).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should fetch bookings for multiple users with duration limits", async () => {
|
|
const mockBookings = [
|
|
createMockBookingResult({ id: 1, userId: 1 }),
|
|
createMockBookingResult({
|
|
id: 2,
|
|
userId: 2,
|
|
startTime: startOfTomorrow.set("hour", 14).toDate(),
|
|
endTime: startOfTomorrow.set("hour", 15).toDate(),
|
|
}),
|
|
];
|
|
vi.mocked(prisma.booking.findMany).mockResolvedValue(mockBookings);
|
|
|
|
const busyTimesService = getBusyTimesService();
|
|
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
|
|
userIds: [1, 2],
|
|
eventTypeId: 1,
|
|
startDate: startOfTomorrow.format(),
|
|
endDate: startOfTomorrow.endOf("day").format(),
|
|
durationLimits: { PER_DAY: 120 },
|
|
});
|
|
|
|
expect(busyTimes).toHaveLength(2);
|
|
expect(busyTimes[0].userId).toBe(1);
|
|
expect(busyTimes[1].userId).toBe(2);
|
|
});
|
|
|
|
it("should batch queries when userIds exceeds batch size (50)", async () => {
|
|
const userIds = Array.from({ length: 75 }, (_, i) => i + 1);
|
|
const mockBookings = [createMockBookingResult()];
|
|
vi.mocked(prisma.booking.findMany).mockResolvedValue(mockBookings);
|
|
|
|
const busyTimesService = getBusyTimesService();
|
|
await busyTimesService.getBusyTimesForLimitChecks({
|
|
userIds,
|
|
eventTypeId: 1,
|
|
startDate: startOfTomorrow.format(),
|
|
endDate: startOfTomorrow.endOf("day").format(),
|
|
bookingLimits: { PER_DAY: 5 },
|
|
});
|
|
|
|
// Should be called twice: once for first 50 users, once for remaining 25
|
|
expect(prisma.booking.findMany).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("should batch queries correctly for exactly 100 users (2 full batches)", async () => {
|
|
const userIds = Array.from({ length: 100 }, (_, i) => i + 1);
|
|
vi.mocked(prisma.booking.findMany).mockResolvedValue([]);
|
|
|
|
const busyTimesService = getBusyTimesService();
|
|
await busyTimesService.getBusyTimesForLimitChecks({
|
|
userIds,
|
|
eventTypeId: 1,
|
|
startDate: startOfTomorrow.format(),
|
|
endDate: startOfTomorrow.endOf("day").format(),
|
|
bookingLimits: { PER_WEEK: 10 },
|
|
});
|
|
|
|
expect(prisma.booking.findMany).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("should batch queries correctly for 150 users (3 batches)", async () => {
|
|
const userIds = Array.from({ length: 150 }, (_, i) => i + 1);
|
|
vi.mocked(prisma.booking.findMany).mockResolvedValue([]);
|
|
|
|
const busyTimesService = getBusyTimesService();
|
|
await busyTimesService.getBusyTimesForLimitChecks({
|
|
userIds,
|
|
eventTypeId: 1,
|
|
startDate: startOfTomorrow.format(),
|
|
endDate: startOfTomorrow.endOf("day").format(),
|
|
bookingLimits: { PER_MONTH: 20 },
|
|
});
|
|
|
|
expect(prisma.booking.findMany).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("should merge results from multiple batches correctly", async () => {
|
|
const userIds = Array.from({ length: 75 }, (_, i) => i + 1);
|
|
|
|
// First batch returns 2 bookings, second batch returns 1 booking
|
|
vi.mocked(prisma.booking.findMany)
|
|
.mockResolvedValueOnce([
|
|
createMockBookingResult({ id: 1, userId: 1 }),
|
|
createMockBookingResult({ id: 2, userId: 25 }),
|
|
])
|
|
.mockResolvedValueOnce([createMockBookingResult({ id: 3, userId: 60 })]);
|
|
|
|
const busyTimesService = getBusyTimesService();
|
|
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
|
|
userIds,
|
|
eventTypeId: 1,
|
|
startDate: startOfTomorrow.format(),
|
|
endDate: startOfTomorrow.endOf("day").format(),
|
|
bookingLimits: { PER_DAY: 5 },
|
|
});
|
|
|
|
expect(busyTimes).toHaveLength(3);
|
|
expect(busyTimes.map((b) => b.userId)).toEqual([1, 25, 60]);
|
|
});
|
|
|
|
it("should exclude rescheduleUid from results", async () => {
|
|
const mockBooking = createMockBookingResult();
|
|
vi.mocked(prisma.booking.findMany).mockResolvedValue([mockBooking]);
|
|
|
|
const busyTimesService = getBusyTimesService();
|
|
await busyTimesService.getBusyTimesForLimitChecks({
|
|
userIds: [1],
|
|
eventTypeId: 1,
|
|
startDate: startOfTomorrow.format(),
|
|
endDate: startOfTomorrow.endOf("day").format(),
|
|
rescheduleUid: "existing-booking-uid",
|
|
bookingLimits: { PER_DAY: 5 },
|
|
});
|
|
|
|
// Verify the query was called (the actual exclusion happens in the Prisma query)
|
|
expect(prisma.booking.findMany).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should handle both booking and duration limits together", async () => {
|
|
const mockBooking = createMockBookingResult();
|
|
vi.mocked(prisma.booking.findMany).mockResolvedValue([mockBooking]);
|
|
|
|
const busyTimesService = getBusyTimesService();
|
|
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
|
|
userIds: [1],
|
|
eventTypeId: 1,
|
|
startDate: startOfTomorrow.format(),
|
|
endDate: startOfTomorrow.endOf("day").format(),
|
|
bookingLimits: { PER_DAY: 5 },
|
|
durationLimits: { PER_DAY: 120 },
|
|
});
|
|
|
|
expect(busyTimes).toHaveLength(1);
|
|
expect(prisma.booking.findMany).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should handle null eventTypeId in booking results", async () => {
|
|
const mockBooking = createMockBookingResult({ eventTypeId: null });
|
|
vi.mocked(prisma.booking.findMany).mockResolvedValue([mockBooking]);
|
|
|
|
const busyTimesService = getBusyTimesService();
|
|
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
|
|
userIds: [1],
|
|
eventTypeId: 1,
|
|
startDate: startOfTomorrow.format(),
|
|
endDate: startOfTomorrow.endOf("day").format(),
|
|
bookingLimits: { PER_DAY: 5 },
|
|
});
|
|
|
|
expect(busyTimes).toHaveLength(1);
|
|
expect(busyTimes[0].source).toBe(`eventType-null-booking-${mockBooking.id}`);
|
|
});
|
|
|
|
it("should handle null userId in booking results", async () => {
|
|
const mockBooking = createMockBookingResult({ userId: null });
|
|
vi.mocked(prisma.booking.findMany).mockResolvedValue([mockBooking]);
|
|
|
|
const busyTimesService = getBusyTimesService();
|
|
const busyTimes = await busyTimesService.getBusyTimesForLimitChecks({
|
|
userIds: [1],
|
|
eventTypeId: 1,
|
|
startDate: startOfTomorrow.format(),
|
|
endDate: startOfTomorrow.endOf("day").format(),
|
|
bookingLimits: { PER_DAY: 5 },
|
|
});
|
|
|
|
expect(busyTimes).toHaveLength(1);
|
|
expect(busyTimes[0].userId).toBeNull();
|
|
});
|
|
});
|