Skip to content

Commit a538ba6

Browse files
Add validation tests (calcom#23833)
1 parent b3bfad7 commit a538ba6

3 files changed

Lines changed: 370 additions & 3 deletions

File tree

apps/web/test/utils/bookingScenario/bookingScenario.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1534,6 +1534,7 @@ export function getOrganizer({
15341534
completedOnboarding,
15351535
username,
15361536
locked,
1537+
emailVerified,
15371538
}: {
15381539
name: string;
15391540
email: string;
@@ -1551,6 +1552,7 @@ export function getOrganizer({
15511552
completedOnboarding?: boolean;
15521553
username?: string;
15531554
locked?: boolean;
1555+
emailVerified?: Date | null;
15541556
}) {
15551557
username = username ?? TestData.users.example.username;
15561558
return {
@@ -1572,7 +1574,8 @@ export function getOrganizer({
15721574
smsLockState,
15731575
completedOnboarding,
15741576
locked,
1575-
};
1577+
emailVerified,
1578+
};
15761579
}
15771580

15781581
export function getScenarioData(
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
/**
2+
* Booking Validation Specifications
3+
* These specifications verify the business rules and validation behavior for booking creation
4+
*/
5+
import prismaMock from "../../../../../../tests/libs/__mocks__/prisma";
6+
import {
7+
createBookingScenario,
8+
TestData,
9+
getOrganizer,
10+
getBooker,
11+
getScenarioData,
12+
getGoogleCalendarCredential,
13+
mockCalendarToHaveNoBusySlots,
14+
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
15+
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
16+
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
17+
18+
import { afterEach, vi } from "vitest";
19+
import { describe, expect } from "vitest";
20+
21+
import { BookingStatus } from "@calcom/prisma/enums";
22+
import { test } from "@calcom/web/test/fixtures/fixtures";
23+
24+
import { getNewBookingHandler } from "./getNewBookingHandler";
25+
26+
function addToBlacklistedEmails(emails: string[]) {
27+
process.env.BLACKLISTED_GUEST_EMAILS = emails.join(",");
28+
}
29+
30+
function resetBlacklistedEmails() {
31+
delete process.env.BLACKLISTED_GUEST_EMAILS;
32+
}
33+
34+
afterEach(() => {
35+
resetBlacklistedEmails();
36+
});
37+
38+
describe("Booking Validation Specifications", () => {
39+
setupAndTeardown();
40+
41+
describe("Email Blacklist Validation", () => {
42+
test("when email is in BLACKLISTED_GUEST_EMAILS, allow the user to book only if they are logged in with that email", async () => {
43+
const handleNewBooking = getNewBookingHandler();
44+
const blockedEmail = "organizer@example.com"; // Use organizer's email as the blocked one
45+
46+
const booker = getBooker({
47+
email: blockedEmail,
48+
name: "Organizer",
49+
});
50+
51+
const organizer = getOrganizer({
52+
name: "Organizer",
53+
email: "organizer@example.com",
54+
id: 101,
55+
schedules: [TestData.schedules.IstWorkHours],
56+
credentials: [getGoogleCalendarCredential()],
57+
selectedCalendars: [TestData.selectedCalendars.google],
58+
emailVerified: new Date(),
59+
});
60+
61+
addToBlacklistedEmails(["organizer@example.com", "spam@test.com"]);
62+
63+
await createBookingScenario(
64+
getScenarioData({
65+
eventTypes: [
66+
{
67+
id: 1,
68+
slotInterval: 30,
69+
length: 30,
70+
users: [
71+
{
72+
id: 101,
73+
},
74+
],
75+
},
76+
],
77+
organizer,
78+
apps: [TestData.apps["google-calendar"]],
79+
})
80+
);
81+
82+
await mockCalendarToHaveNoBusySlots("googlecalendar", {});
83+
84+
const mockBookingData = getMockRequestDataForBooking({
85+
data: {
86+
eventTypeId: 1,
87+
responses: {
88+
email: booker.email,
89+
name: booker.name,
90+
location: { optionValue: "", value: "New York" },
91+
},
92+
},
93+
});
94+
95+
// Non logged in user should not be able to book
96+
await expect(handleNewBooking({
97+
bookingData: mockBookingData,
98+
})).rejects.toThrow(
99+
"Attendee email has been blocked. Make sure to login as organizer@example.com to use this email for creating a booking."
100+
);
101+
102+
// Should allow booking when the user who owns the blacklisted email is logged in
103+
const createdBooking = await handleNewBooking({
104+
bookingData: mockBookingData,
105+
userId: 101, // Same as organizer who owns the blacklisted email
106+
});
107+
108+
expect(createdBooking).toEqual(
109+
expect.objectContaining({
110+
id: expect.any(Number),
111+
uid: expect.any(String),
112+
status: BookingStatus.ACCEPTED,
113+
})
114+
);
115+
});
116+
117+
test("prevents booking when blacklisted email is not verified in the system", async () => {
118+
const handleNewBooking = getNewBookingHandler();
119+
const blockedEmail = "blocked@example.com";
120+
121+
const booker = getBooker({
122+
email: blockedEmail,
123+
name: "Unverified User",
124+
});
125+
126+
const organizer = getOrganizer({
127+
name: "Organizer",
128+
email: "organizer@example.com",
129+
id: 101,
130+
schedules: [TestData.schedules.IstWorkHours],
131+
credentials: [getGoogleCalendarCredential()],
132+
selectedCalendars: [TestData.selectedCalendars.google],
133+
emailVerified: null,
134+
});
135+
136+
// Mock environment variable for blacklisted emails
137+
addToBlacklistedEmails(["blocked@example.com"]);
138+
139+
await createBookingScenario(
140+
getScenarioData({
141+
eventTypes: [
142+
{
143+
id: 1,
144+
slotInterval: 30,
145+
length: 30,
146+
users: [
147+
{
148+
id: 101,
149+
},
150+
],
151+
},
152+
],
153+
organizer,
154+
apps: [TestData.apps["google-calendar"]],
155+
})
156+
);
157+
158+
await mockCalendarToHaveNoBusySlots("googlecalendar", {});
159+
160+
const mockBookingData = getMockRequestDataForBooking({
161+
data: {
162+
eventTypeId: 1,
163+
responses: {
164+
email: booker.email,
165+
name: booker.name,
166+
location: { optionValue: "", value: "New York" },
167+
},
168+
},
169+
});
170+
171+
// Should prevent booking when blacklisted email has no verified user in database
172+
await expect(handleNewBooking({
173+
bookingData: mockBookingData,
174+
})).rejects.toThrow("Cannot use this email to create the booking.");
175+
});
176+
});
177+
178+
describe("Active Bookings Limit Validation", () => {
179+
test("allows booking when user is under their active booking limit", async () => {
180+
vi.setSystemTime(new Date("2025-01-01"));
181+
const plus1DateString = "2025-01-02";
182+
183+
const handleNewBooking = getNewBookingHandler();
184+
185+
const booker = getBooker({
186+
email: "booker@example.com",
187+
name: "Booker",
188+
});
189+
190+
const organizer = getOrganizer({
191+
name: "Organizer",
192+
email: "organizer@example.com",
193+
id: 101,
194+
schedules: [TestData.schedules.IstWorkHours],
195+
credentials: [getGoogleCalendarCredential()],
196+
selectedCalendars: [TestData.selectedCalendars.google],
197+
});
198+
199+
// Create test scenario with event type that has maxActiveBookingsPerBooker limit
200+
await createBookingScenario(
201+
getScenarioData({
202+
eventTypes: [
203+
{
204+
id: 1,
205+
slotInterval: 30,
206+
length: 30,
207+
// Two bookings allowed for the booker
208+
maxActiveBookingsPerBooker: 2,
209+
users: [
210+
{
211+
id: 101,
212+
},
213+
],
214+
},
215+
],
216+
organizer,
217+
apps: [TestData.apps["google-calendar"]],
218+
bookings: [
219+
{
220+
uid: "existing-booking-1",
221+
eventTypeId: 1,
222+
userId: organizer.id,
223+
startTime: `${plus1DateString}T10:00:00.000Z`,
224+
endTime: `${plus1DateString}T10:30:00.000Z`,
225+
title: "Existing Booking",
226+
status: BookingStatus.ACCEPTED,
227+
// Booker already has a booking in future
228+
attendees: [{
229+
email: booker.email,
230+
}],
231+
},
232+
],
233+
})
234+
);
235+
236+
await mockCalendarToHaveNoBusySlots("googlecalendar", {});
237+
238+
239+
const mockBookingData = getMockRequestDataForBooking({
240+
data: {
241+
eventTypeId: 1,
242+
responses: {
243+
email: booker.email,
244+
name: booker.name,
245+
location: { optionValue: "", value: "New York" },
246+
},
247+
},
248+
});
249+
250+
// Should allow booking when user has not reached their limit (1 booking < 2 limit)
251+
const createdBooking = await handleNewBooking({
252+
bookingData: mockBookingData,
253+
});
254+
255+
expect(createdBooking).toEqual(
256+
expect.objectContaining({
257+
id: expect.any(Number),
258+
uid: expect.any(String),
259+
status: BookingStatus.ACCEPTED,
260+
})
261+
);
262+
263+
// Second booking should be rejected
264+
await expect(handleNewBooking({
265+
bookingData: mockBookingData,
266+
})).rejects.toThrow("booker_limit_exceeded_error");
267+
});
268+
269+
test("enforces booking limits with reschedule option when enabled", async () => {
270+
vi.setSystemTime(new Date("2025-01-01"));
271+
const plus1DateString = "2025-01-02";
272+
const plus2DateString = "2025-01-03";
273+
274+
const handleNewBooking = getNewBookingHandler();
275+
276+
const booker = getBooker({
277+
email: "booker@example.com",
278+
name: "Booker",
279+
});
280+
281+
const organizer = getOrganizer({
282+
name: "Organizer",
283+
email: "organizer@example.com",
284+
id: 101,
285+
schedules: [TestData.schedules.IstWorkHours],
286+
credentials: [getGoogleCalendarCredential()],
287+
selectedCalendars: [TestData.selectedCalendars.google],
288+
});
289+
290+
// Create test scenario with event type that has reschedule option enabled
291+
await createBookingScenario(
292+
getScenarioData({
293+
eventTypes: [
294+
{
295+
id: 1,
296+
slotInterval: 30,
297+
length: 30,
298+
maxActiveBookingsPerBooker: 2,
299+
maxActiveBookingPerBookerOfferReschedule: true,
300+
users: [
301+
{
302+
id: 101,
303+
},
304+
],
305+
},
306+
],
307+
organizer,
308+
apps: [TestData.apps["google-calendar"]],
309+
bookings: [
310+
{
311+
uid: "existing-booking-1",
312+
eventTypeId: 1,
313+
userId: organizer.id,
314+
startTime: `${plus1DateString}T10:00:00.000Z`,
315+
endTime: `${plus1DateString}T10:30:00.000Z`,
316+
title: "Existing Booking",
317+
status: BookingStatus.ACCEPTED,
318+
attendees: [{
319+
email: booker.email,
320+
}],
321+
},
322+
{
323+
uid: "existing-booking-2",
324+
eventTypeId: 1,
325+
userId: organizer.id,
326+
startTime: `${plus2DateString}T10:00:00.000Z`,
327+
endTime: `${plus2DateString}T10:30:00.000Z`,
328+
title: "Existing Booking",
329+
status: BookingStatus.ACCEPTED,
330+
attendees: [{
331+
email: booker.email,
332+
}],
333+
},
334+
],
335+
})
336+
);
337+
338+
await mockCalendarToHaveNoBusySlots("googlecalendar", {});
339+
340+
const mockBookingData = getMockRequestDataForBooking({
341+
data: {
342+
eventTypeId: 1,
343+
responses: {
344+
email: booker.email,
345+
name: booker.name,
346+
location: { optionValue: "", value: "New York" },
347+
},
348+
},
349+
});
350+
351+
try {
352+
await handleNewBooking({
353+
bookingData: mockBookingData,
354+
});
355+
} catch (error) {
356+
expect(error.message).toEqual("booker_limit_exceeded_error_reschedule");
357+
expect(error.data).toEqual(
358+
expect.objectContaining({
359+
rescheduleUid: "existing-booking-1",
360+
})
361+
);
362+
}
363+
});
364+
});
365+
});

0 commit comments

Comments
 (0)