fix: request reschedule link in email for an Org event (#12125)
This commit is contained in:
@@ -66,6 +66,7 @@ type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
|
||||
id: number;
|
||||
defaultScheduleId?: number | null;
|
||||
credentials?: InputCredential[];
|
||||
organizationId?: number | null;
|
||||
selectedCalendars?: InputSelectedCalendar[];
|
||||
schedules: {
|
||||
// Allows giving id in the input directly so that it can be referenced somewhere else as well
|
||||
@@ -264,8 +265,21 @@ async function addBookingsToDb(
|
||||
})[]
|
||||
) {
|
||||
log.silly("TestData: Creating Bookings", JSON.stringify(bookings));
|
||||
|
||||
function getDateObj(time: string | Date) {
|
||||
return time instanceof Date ? time : new Date(time);
|
||||
}
|
||||
|
||||
// Make sure that we store the date in Date object always. This is to ensure consistency which Prisma does but not prismock
|
||||
log.silly("Handling Prismock bug-3");
|
||||
const fixedBookings = bookings.map((booking) => {
|
||||
const startTime = getDateObj(booking.startTime);
|
||||
const endTime = getDateObj(booking.endTime);
|
||||
return { ...booking, startTime, endTime };
|
||||
});
|
||||
|
||||
await prismock.booking.createMany({
|
||||
data: bookings,
|
||||
data: fixedBookings,
|
||||
});
|
||||
log.silly(
|
||||
"TestData: Bookings as in DB",
|
||||
@@ -406,6 +420,7 @@ async function addUsers(users: InputUser[]) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return newUser;
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -446,6 +461,16 @@ export async function createBookingScenario(data: ScenarioData) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOrganization(orgData: { name: string; slug: string }) {
|
||||
const org = await prismock.team.create({
|
||||
data: {
|
||||
name: orgData.name,
|
||||
slug: orgData.slug,
|
||||
},
|
||||
});
|
||||
return org;
|
||||
}
|
||||
|
||||
// async function addPaymentsToDb(payments: Prisma.PaymentCreateInput[]) {
|
||||
// await prismaMock.payment.createMany({
|
||||
// data: payments,
|
||||
@@ -722,6 +747,7 @@ export function getOrganizer({
|
||||
}) {
|
||||
return {
|
||||
...TestData.users.example,
|
||||
organizationId: null as null | number,
|
||||
name,
|
||||
email,
|
||||
id,
|
||||
@@ -733,24 +759,33 @@ export function getOrganizer({
|
||||
};
|
||||
}
|
||||
|
||||
export function getScenarioData({
|
||||
organizer,
|
||||
eventTypes,
|
||||
usersApartFromOrganizer = [],
|
||||
apps = [],
|
||||
webhooks,
|
||||
bookings,
|
||||
}: // hosts = [],
|
||||
{
|
||||
organizer: ReturnType<typeof getOrganizer>;
|
||||
eventTypes: ScenarioData["eventTypes"];
|
||||
apps?: ScenarioData["apps"];
|
||||
usersApartFromOrganizer?: ScenarioData["users"];
|
||||
webhooks?: ScenarioData["webhooks"];
|
||||
bookings?: ScenarioData["bookings"];
|
||||
// hosts?: ScenarioData["hosts"];
|
||||
}) {
|
||||
export function getScenarioData(
|
||||
{
|
||||
organizer,
|
||||
eventTypes,
|
||||
usersApartFromOrganizer = [],
|
||||
apps = [],
|
||||
webhooks,
|
||||
bookings,
|
||||
}: // hosts = [],
|
||||
{
|
||||
organizer: ReturnType<typeof getOrganizer>;
|
||||
eventTypes: ScenarioData["eventTypes"];
|
||||
apps?: ScenarioData["apps"];
|
||||
usersApartFromOrganizer?: ScenarioData["users"];
|
||||
webhooks?: ScenarioData["webhooks"];
|
||||
bookings?: ScenarioData["bookings"];
|
||||
// hosts?: ScenarioData["hosts"];
|
||||
},
|
||||
org?: { id: number | null } | undefined | null
|
||||
) {
|
||||
const users = [organizer, ...usersApartFromOrganizer];
|
||||
if (org) {
|
||||
users.forEach((user) => {
|
||||
user.organizationId = org.id;
|
||||
});
|
||||
}
|
||||
|
||||
eventTypes.forEach((eventType) => {
|
||||
if (
|
||||
eventType.users?.filter((eventTypeUser) => {
|
||||
@@ -897,6 +932,7 @@ export function mockCalendar(
|
||||
url: "https://UNUSED_URL",
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
deleteEvent: async (...rest: any[]) => {
|
||||
log.silly("mockCalendar.deleteEvent", JSON.stringify({ rest }));
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
@@ -1021,6 +1057,7 @@ export function mockVideoApp({
|
||||
...videoMeetingData,
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
deleteMeeting: async (...rest: any[]) => {
|
||||
log.silly("MockVideoApiAdapter.deleteMeeting", JSON.stringify(rest));
|
||||
deleteMeetingCalls.push({
|
||||
@@ -1153,7 +1190,6 @@ export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { exte
|
||||
await handleStripePaymentSuccess(getMockedStripePaymentEvent({ paymentIntentId: externalId }));
|
||||
} catch (e) {
|
||||
log.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
|
||||
|
||||
webhookResponse = e as HttpError;
|
||||
}
|
||||
return { webhookResponse };
|
||||
|
||||
@@ -2,10 +2,14 @@ import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client";
|
||||
import { parse } from "node-html-parser";
|
||||
import type { VEvent } from "node-ical";
|
||||
import ical from "node-ical";
|
||||
import { expect } from "vitest";
|
||||
import "vitest-fetch-mock";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DEFAULT_TIMEZONE_BOOKER } from "@calcom/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -15,42 +19,73 @@ import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
|
||||
|
||||
import type { InputEventType } from "./bookingScenario";
|
||||
|
||||
// This is too complex at the moment, I really need to simplify this.
|
||||
// Maybe we can replace the exact match with a partial match approach that would be easier to maintain but we would still need Dayjs to do the timezone conversion
|
||||
// Alternative could be that we use some other library to do the timezone conversion?
|
||||
function formatDateToWhenFormat({ start, end }: { start: Date; end: Date }, timeZone: string) {
|
||||
const startTime = dayjs(start).tz(timeZone);
|
||||
return `${startTime.format(`dddd, LL`)} | ${startTime.format("h:mma")} - ${dayjs(end)
|
||||
.tz(timeZone)
|
||||
.format("h:mma")} (${timeZone})`;
|
||||
}
|
||||
|
||||
type Recurrence = {
|
||||
freq: number;
|
||||
interval: number;
|
||||
count: number;
|
||||
};
|
||||
type ExpectedEmail = {
|
||||
/**
|
||||
* Checks the main heading of the email - Also referred to as title in code at some places
|
||||
*/
|
||||
heading?: string;
|
||||
links?: { text: string; href: string }[];
|
||||
/**
|
||||
* Checks the sub heading of the email - Also referred to as subTitle in code
|
||||
*/
|
||||
subHeading?: string;
|
||||
/**
|
||||
* Checks the <title> tag - Not sure what's the use of it, as it is not shown in UI it seems.
|
||||
*/
|
||||
titleTag?: string;
|
||||
to: string;
|
||||
bookingTimeRange?: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
timeZone: string;
|
||||
};
|
||||
// TODO: Implement these and more
|
||||
// what?: string;
|
||||
// when?: string;
|
||||
// who?: string;
|
||||
// where?: string;
|
||||
// additionalNotes?: string;
|
||||
// footer?: {
|
||||
// rescheduleLink?: string;
|
||||
// cancelLink?: string;
|
||||
// };
|
||||
ics?: {
|
||||
filename: string;
|
||||
iCalUID: string;
|
||||
recurrence?: Recurrence;
|
||||
};
|
||||
/**
|
||||
* Checks that there is no
|
||||
*/
|
||||
noIcs?: true;
|
||||
appsStatus?: AppsStatus[];
|
||||
};
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace jest {
|
||||
interface Matchers<R> {
|
||||
toHaveEmail(
|
||||
expectedEmail: {
|
||||
title?: string;
|
||||
to: string;
|
||||
noIcs?: true;
|
||||
ics?: {
|
||||
filename: string;
|
||||
iCalUID: string;
|
||||
};
|
||||
appsStatus?: AppsStatus[];
|
||||
},
|
||||
to: string
|
||||
): R;
|
||||
toHaveEmail(expectedEmail: ExpectedEmail, to: string): R;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect.extend({
|
||||
toHaveEmail(
|
||||
emails: Fixtures["emails"],
|
||||
expectedEmail: {
|
||||
title?: string;
|
||||
to: string;
|
||||
ics: {
|
||||
filename: string;
|
||||
iCalUID: string;
|
||||
};
|
||||
noIcs: true;
|
||||
appsStatus: AppsStatus[];
|
||||
},
|
||||
to: string
|
||||
) {
|
||||
toHaveEmail(emails: Fixtures["emails"], expectedEmail: ExpectedEmail, to: string) {
|
||||
const { isNot } = this;
|
||||
const testEmail = emails.get().find((email) => email.to.includes(to));
|
||||
const emailsToLog = emails
|
||||
@@ -66,6 +101,7 @@ expect.extend({
|
||||
}
|
||||
const ics = testEmail.icalEvent;
|
||||
const icsObject = ics?.content ? ical.sync.parseICS(ics?.content) : null;
|
||||
const iCalUidData = icsObject ? icsObject[expectedEmail.ics?.iCalUID || ""] : null;
|
||||
|
||||
let isToAddressExpected = true;
|
||||
const isIcsFilenameExpected = expectedEmail.ics ? ics?.filename === expectedEmail.ics.filename : true;
|
||||
@@ -75,13 +111,18 @@ expect.extend({
|
||||
const emailDom = parse(testEmail.html);
|
||||
|
||||
const actualEmailContent = {
|
||||
title: emailDom.querySelector("title")?.innerText,
|
||||
subject: emailDom.querySelector("subject")?.innerText,
|
||||
titleTag: emailDom.querySelector("title")?.innerText,
|
||||
heading: emailDom.querySelector('[data-testid="heading"]')?.innerText,
|
||||
subHeading: emailDom.querySelector('[data-testid="subHeading"]')?.innerText,
|
||||
when: emailDom.querySelector('[data-testid="when"]')?.innerText,
|
||||
links: emailDom.querySelectorAll("a[href]").map((link) => ({
|
||||
text: link.innerText,
|
||||
href: link.getAttribute("href"),
|
||||
})),
|
||||
};
|
||||
|
||||
const expectedEmailContent = {
|
||||
title: expectedEmail.title,
|
||||
};
|
||||
const expectedEmailContent = getExpectedEmailContent(expectedEmail);
|
||||
assertHasRecurrence(expectedEmail.ics?.recurrence, (iCalUidData as VEvent)?.rrule?.toString() || "");
|
||||
|
||||
const isEmailContentMatched = this.equals(
|
||||
actualEmailContent,
|
||||
@@ -114,7 +155,7 @@ expect.extend({
|
||||
return {
|
||||
pass: false,
|
||||
actual: ics?.filename,
|
||||
expected: expectedEmail.ics.filename,
|
||||
expected: expectedEmail.ics?.filename,
|
||||
message: () => `ICS Filename ${isNot ? "is" : "is not"} matching`,
|
||||
};
|
||||
}
|
||||
@@ -123,11 +164,18 @@ expect.extend({
|
||||
return {
|
||||
pass: false,
|
||||
actual: JSON.stringify(icsObject),
|
||||
expected: expectedEmail.ics.iCalUID,
|
||||
expected: expectedEmail.ics?.iCalUID,
|
||||
message: () => `Expected ICS UID ${isNot ? "is" : "isn't"} present in actual`,
|
||||
};
|
||||
}
|
||||
|
||||
if (expectedEmail.noIcs && ics) {
|
||||
return {
|
||||
pass: false,
|
||||
message: () => `${isNot ? "" : "Not"} expected ics file`,
|
||||
};
|
||||
}
|
||||
|
||||
if (expectedEmail.appsStatus) {
|
||||
const actualAppsStatus = emailDom.querySelectorAll('[data-testid="appsStatus"] li').map((li) => {
|
||||
return li.innerText.trim();
|
||||
@@ -155,6 +203,50 @@ expect.extend({
|
||||
pass: true,
|
||||
message: () => `Email ${isNot ? "is" : "isn't"} correct`,
|
||||
};
|
||||
|
||||
function getExpectedEmailContent(expectedEmail: ExpectedEmail) {
|
||||
const bookingTimeRange = expectedEmail.bookingTimeRange;
|
||||
const when = bookingTimeRange
|
||||
? formatDateToWhenFormat(
|
||||
{
|
||||
start: bookingTimeRange.start,
|
||||
end: bookingTimeRange.end,
|
||||
},
|
||||
bookingTimeRange.timeZone
|
||||
)
|
||||
: null;
|
||||
|
||||
const expectedEmailContent = {
|
||||
titleTag: expectedEmail.titleTag,
|
||||
heading: expectedEmail.heading,
|
||||
subHeading: expectedEmail.subHeading,
|
||||
when: when ? (expectedEmail.ics?.recurrence ? `starting ${when}` : `${when}`) : undefined,
|
||||
links: expect.arrayContaining(expectedEmail.links || []),
|
||||
};
|
||||
// Remove undefined props so that they aren't matched, they are intentionally left undefined because we don't want to match them
|
||||
Object.keys(expectedEmailContent).filter((key) => {
|
||||
if (expectedEmailContent[key as keyof typeof expectedEmailContent] === undefined) {
|
||||
delete expectedEmailContent[key as keyof typeof expectedEmailContent];
|
||||
}
|
||||
});
|
||||
return expectedEmailContent;
|
||||
}
|
||||
|
||||
function assertHasRecurrence(expectedRecurrence: Recurrence | null | undefined, rrule: string) {
|
||||
if (!expectedRecurrence) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedRrule = `FREQ=${
|
||||
expectedRecurrence.freq === 0 ? "YEARLY" : expectedRecurrence.freq === 1 ? "MONTHLY" : "WEEKLY"
|
||||
};COUNT=${expectedRecurrence.count};INTERVAL=${expectedRecurrence.interval}`;
|
||||
|
||||
logger.silly({
|
||||
expectedRrule,
|
||||
rrule,
|
||||
});
|
||||
expect(rrule).toContain(expectedRrule);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -235,21 +327,50 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
guests,
|
||||
otherTeamMembers,
|
||||
iCalUID,
|
||||
recurrence,
|
||||
bookingTimeRange,
|
||||
booking,
|
||||
}: {
|
||||
emails: Fixtures["emails"];
|
||||
organizer: { email: string; name: string };
|
||||
booker: { email: string; name: string };
|
||||
guests?: { email: string; name: string }[];
|
||||
otherTeamMembers?: { email: string; name: string }[];
|
||||
organizer: { email: string; name: string; timeZone: string };
|
||||
booker: { email: string; name: string; timeZone?: string };
|
||||
guests?: { email: string; name: string; timeZone?: string }[];
|
||||
otherTeamMembers?: { email: string; name: string; timeZone?: string }[];
|
||||
iCalUID: string;
|
||||
recurrence?: Recurrence;
|
||||
eventDomain?: string;
|
||||
bookingTimeRange?: { start: Date; end: Date };
|
||||
booking: { uid: string; urlOrigin?: string };
|
||||
}) {
|
||||
const bookingUrlOrigin = booking.urlOrigin || WEBAPP_URL;
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "confirmed_event_type_subject",
|
||||
titleTag: "confirmed_event_type_subject",
|
||||
heading: recurrence ? "new_event_scheduled_recurring" : "new_event_scheduled",
|
||||
subHeading: "",
|
||||
links: [
|
||||
{
|
||||
href: `${bookingUrlOrigin}/reschedule/${booking.uid}`,
|
||||
text: "reschedule",
|
||||
},
|
||||
{
|
||||
href: `${bookingUrlOrigin}/booking/${booking.uid}?cancel=true&allRemainingBookings=false`,
|
||||
text: "cancel",
|
||||
},
|
||||
],
|
||||
...(bookingTimeRange
|
||||
? {
|
||||
bookingTimeRange: {
|
||||
...bookingTimeRange,
|
||||
timeZone: organizer.timeZone,
|
||||
},
|
||||
}
|
||||
: null),
|
||||
to: `${organizer.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID: iCalUID,
|
||||
iCalUID: `${iCalUID}`,
|
||||
recurrence,
|
||||
},
|
||||
},
|
||||
`${organizer.email}`
|
||||
@@ -257,12 +378,34 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "confirmed_event_type_subject",
|
||||
titleTag: "confirmed_event_type_subject",
|
||||
heading: recurrence ? "your_event_has_been_scheduled_recurring" : "your_event_has_been_scheduled",
|
||||
subHeading: "emailed_you_and_any_other_attendees",
|
||||
...(bookingTimeRange
|
||||
? {
|
||||
bookingTimeRange: {
|
||||
...bookingTimeRange,
|
||||
// Using the default timezone
|
||||
timeZone: booker.timeZone || DEFAULT_TIMEZONE_BOOKER,
|
||||
},
|
||||
}
|
||||
: null),
|
||||
to: `${booker.name} <${booker.email}>`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID: iCalUID,
|
||||
recurrence,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
href: `${bookingUrlOrigin}/reschedule/${booking.uid}`,
|
||||
text: "reschedule",
|
||||
},
|
||||
{
|
||||
href: `${bookingUrlOrigin}/booking/${booking.uid}?cancel=true&allRemainingBookings=false`,
|
||||
text: "cancel",
|
||||
},
|
||||
],
|
||||
},
|
||||
`${booker.name} <${booker.email}>`
|
||||
);
|
||||
@@ -271,13 +414,33 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
otherTeamMembers.forEach((otherTeamMember) => {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "confirmed_event_type_subject",
|
||||
titleTag: "confirmed_event_type_subject",
|
||||
heading: recurrence ? "new_event_scheduled_recurring" : "new_event_scheduled",
|
||||
subHeading: "",
|
||||
...(bookingTimeRange
|
||||
? {
|
||||
bookingTimeRange: {
|
||||
...bookingTimeRange,
|
||||
timeZone: otherTeamMember.timeZone || DEFAULT_TIMEZONE_BOOKER,
|
||||
},
|
||||
}
|
||||
: null),
|
||||
// Don't know why but organizer and team members of the eventType don'thave their name here like Booker
|
||||
to: `${otherTeamMember.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID: iCalUID,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
href: `${bookingUrlOrigin}/reschedule/${booking.uid}`,
|
||||
text: "reschedule",
|
||||
},
|
||||
{
|
||||
href: `${bookingUrlOrigin}/booking/${booking.uid}?cancel=true&allRemainingBookings=false`,
|
||||
text: "cancel",
|
||||
},
|
||||
],
|
||||
},
|
||||
`${otherTeamMember.email}`
|
||||
);
|
||||
@@ -288,7 +451,17 @@ export function expectSuccessfulBookingCreationEmails({
|
||||
guests.forEach((guest) => {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "confirmed_event_type_subject",
|
||||
titleTag: "confirmed_event_type_subject",
|
||||
heading: recurrence ? "your_event_has_been_scheduled_recurring" : "your_event_has_been_scheduled",
|
||||
subHeading: "emailed_you_and_any_other_attendees",
|
||||
...(bookingTimeRange
|
||||
? {
|
||||
bookingTimeRange: {
|
||||
...bookingTimeRange,
|
||||
timeZone: guest.timeZone || DEFAULT_TIMEZONE_BOOKER,
|
||||
},
|
||||
}
|
||||
: null),
|
||||
to: `${guest.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
@@ -311,7 +484,7 @@ export function expectBrokenIntegrationEmails({
|
||||
// Broken Integration email is only sent to the Organizer
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "broken_integration",
|
||||
titleTag: "broken_integration",
|
||||
to: `${organizer.email}`,
|
||||
// No ics goes in case of broken integration email it seems
|
||||
// ics: {
|
||||
@@ -344,7 +517,7 @@ export function expectCalendarEventCreationFailureEmails({
|
||||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "broken_integration",
|
||||
titleTag: "broken_integration",
|
||||
to: `${organizer.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
@@ -356,7 +529,7 @@ export function expectCalendarEventCreationFailureEmails({
|
||||
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "calendar_event_creation_failure_subject",
|
||||
titleTag: "calendar_event_creation_failure_subject",
|
||||
to: `${booker.name} <${booker.email}>`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
@@ -378,11 +551,11 @@ export function expectSuccessfulBookingRescheduledEmails({
|
||||
organizer: { email: string; name: string };
|
||||
booker: { email: string; name: string };
|
||||
iCalUID: string;
|
||||
appsStatus: AppsStatus[];
|
||||
appsStatus?: AppsStatus[];
|
||||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "event_type_has_been_rescheduled_on_time_date",
|
||||
titleTag: "event_type_has_been_rescheduled_on_time_date",
|
||||
to: `${organizer.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
@@ -395,7 +568,7 @@ export function expectSuccessfulBookingRescheduledEmails({
|
||||
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "event_type_has_been_rescheduled_on_time_date",
|
||||
titleTag: "event_type_has_been_rescheduled_on_time_date",
|
||||
to: `${booker.name} <${booker.email}>`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
@@ -415,7 +588,7 @@ export function expectAwaitingPaymentEmails({
|
||||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "awaiting_payment_subject",
|
||||
titleTag: "awaiting_payment_subject",
|
||||
to: `${booker.name} <${booker.email}>`,
|
||||
noIcs: true,
|
||||
},
|
||||
@@ -434,7 +607,7 @@ export function expectBookingRequestedEmails({
|
||||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "event_awaiting_approval_subject",
|
||||
titleTag: "event_awaiting_approval_subject",
|
||||
to: `${organizer.email}`,
|
||||
noIcs: true,
|
||||
},
|
||||
@@ -443,7 +616,7 @@ export function expectBookingRequestedEmails({
|
||||
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "booking_submitted_subject",
|
||||
titleTag: "booking_submitted_subject",
|
||||
to: `${booker.email}`,
|
||||
noIcs: true,
|
||||
},
|
||||
@@ -629,32 +802,42 @@ export function expectSuccessfulCalendarEventCreationInCalendar(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
updateEventCalls: any[];
|
||||
},
|
||||
expected: {
|
||||
calendarId?: string | null;
|
||||
videoCallUrl: string;
|
||||
destinationCalendars: Partial<DestinationCalendar>[];
|
||||
}
|
||||
expected:
|
||||
| {
|
||||
calendarId?: string | null;
|
||||
videoCallUrl: string;
|
||||
destinationCalendars?: Partial<DestinationCalendar>[];
|
||||
}
|
||||
| {
|
||||
calendarId?: string | null;
|
||||
videoCallUrl: string;
|
||||
destinationCalendars?: Partial<DestinationCalendar>[];
|
||||
}[]
|
||||
) {
|
||||
expect(calendarMock.createEventCalls.length).toBe(1);
|
||||
const call = calendarMock.createEventCalls[0];
|
||||
const calEvent = call[0];
|
||||
const expecteds = expected instanceof Array ? expected : [expected];
|
||||
expect(calendarMock.createEventCalls.length).toBe(expecteds.length);
|
||||
for (let i = 0; i < calendarMock.createEventCalls.length; i++) {
|
||||
const expected = expecteds[i];
|
||||
|
||||
expect(calEvent).toEqual(
|
||||
expect.objectContaining({
|
||||
destinationCalendar: expected.calendarId
|
||||
? [
|
||||
expect.objectContaining({
|
||||
externalId: expected.calendarId,
|
||||
}),
|
||||
]
|
||||
: expected.destinationCalendars
|
||||
? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal)))
|
||||
: null,
|
||||
videoCallData: expect.objectContaining({
|
||||
url: expected.videoCallUrl,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const calEvent = calendarMock.createEventCalls[i][0];
|
||||
|
||||
expect(calEvent).toEqual(
|
||||
expect.objectContaining({
|
||||
destinationCalendar: expected.calendarId
|
||||
? [
|
||||
expect.objectContaining({
|
||||
externalId: expected.calendarId,
|
||||
}),
|
||||
]
|
||||
: expected.destinationCalendars
|
||||
? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal)))
|
||||
: null,
|
||||
videoCallData: expect.objectContaining({
|
||||
url: expected.videoCallUrl,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function expectSuccessfulCalendarEventUpdationInCalendar(
|
||||
|
||||
@@ -255,7 +255,10 @@ export class CalendarEventBuilder implements ICalendarEventBuilder {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set("rescheduleUid", `${booking.uid}`);
|
||||
slug = `${slug}`;
|
||||
const rescheduleLink = `${WEBAPP_URL}/${slug}?${queryParams.toString()}`;
|
||||
|
||||
const rescheduleLink = `${
|
||||
this.calendarEvent.bookerUrl ?? WEBAPP_URL
|
||||
}/${slug}?${queryParams.toString()}`;
|
||||
this.rescheduleLink = rescheduleLink;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from "@calcom/types/Calendar";
|
||||
|
||||
class CalendarEventClass implements CalendarEvent {
|
||||
bookerUrl?: string | undefined;
|
||||
type!: string;
|
||||
title!: string;
|
||||
startTime!: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CSSProperties } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import EmailCommonDivider from "./EmailCommonDivider";
|
||||
|
||||
@@ -19,6 +19,7 @@ const EmailScheduledBodyHeaderContent = (props: {
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
<div
|
||||
data-testid="heading"
|
||||
style={{
|
||||
fontFamily: "Roboto, Helvetica, sans-serif",
|
||||
fontSize: 24,
|
||||
@@ -35,6 +36,7 @@ const EmailScheduledBodyHeaderContent = (props: {
|
||||
<tr>
|
||||
<td align="center" style={{ fontSize: 0, padding: "10px 25px", wordBreak: "break-word" }}>
|
||||
<div
|
||||
data-testid="subHeading"
|
||||
style={{
|
||||
fontFamily: "Roboto, Helvetica, sans-serif",
|
||||
fontSize: 16,
|
||||
|
||||
@@ -61,11 +61,11 @@ export function WhenInfo(props: {
|
||||
!!props.calEvent.cancellationReason && !props.calEvent.cancellationReason.includes("$RCH$")
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<span data-testid="when">
|
||||
{recurringEvent?.count ? `${t("starting")} ` : ""}
|
||||
{getRecipientStart(`dddd, LL | ${timeFormat}`)} - {getRecipientEnd(timeFormat)}{" "}
|
||||
<span style={{ color: "#4B5563" }}>({timeZone})</span>
|
||||
</>
|
||||
</span>
|
||||
}
|
||||
withSpacer
|
||||
/>
|
||||
|
||||
@@ -53,24 +53,26 @@ import {
|
||||
import { createMockNextJsRequest } from "./lib/createMockNextJsRequest";
|
||||
import { getMockRequestDataForBooking } from "./lib/getMockRequestDataForBooking";
|
||||
import { setupAndTeardown } from "./lib/setupAndTeardown";
|
||||
import { testWithAndWithoutOrg } from "./lib/test";
|
||||
|
||||
export type CustomNextApiRequest = NextApiRequest & Request;
|
||||
|
||||
export type CustomNextApiResponse = NextApiResponse & Response;
|
||||
// Local test runs sometime gets too slow
|
||||
const timeout = process.env.CI ? 5000 : 20000;
|
||||
|
||||
describe("handleNewBooking", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
describe("Fresh/New Booking:", () => {
|
||||
test(
|
||||
testWithAndWithoutOrg(
|
||||
`should create a successful booking with Cal Video(Daily Video) if no explicit location is provided
|
||||
1. Should create a booking in the database
|
||||
2. Should send emails to the booker as well as organizer
|
||||
3. Should create a booking in the event's destination calendar
|
||||
3. Should trigger BOOKING_CREATED webhook
|
||||
`,
|
||||
async ({ emails }) => {
|
||||
async ({ emails, org }) => {
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
@@ -89,37 +91,41 @@ describe("handleNewBooking", () => {
|
||||
externalId: "organizer@google-calendar.com",
|
||||
},
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
length: 45,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "event-type-1@google-calendar.com",
|
||||
getScenarioData(
|
||||
{
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
length: 45,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "event-type-1@google-calendar.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
},
|
||||
org?.organization
|
||||
)
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
@@ -195,6 +201,10 @@ describe("handleNewBooking", () => {
|
||||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
urlOrigin: org ? org.urlOrigin : WEBAPP_URL,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
@@ -343,6 +353,9 @@ describe("handleNewBooking", () => {
|
||||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
@@ -488,6 +501,9 @@ describe("handleNewBooking", () => {
|
||||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
@@ -749,6 +765,9 @@ describe("handleNewBooking", () => {
|
||||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
@@ -834,11 +853,14 @@ describe("handleNewBooking", () => {
|
||||
const createdBooking = await handleNewBooking(req);
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
// Because no calendar was involved, we don't have an ics UID
|
||||
iCalUID: createdBooking.uid,
|
||||
iCalUID: createdBooking.uid!,
|
||||
});
|
||||
|
||||
expectBookingCreatedWebhookToHaveBeenFired({
|
||||
@@ -1436,6 +1458,9 @@ describe("handleNewBooking", () => {
|
||||
expectWorkflowToBeTriggered();
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
@@ -1730,6 +1755,9 @@ describe("handleNewBooking", () => {
|
||||
expectWorkflowToBeTriggered();
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
|
||||
+4
-1
@@ -1,11 +1,12 @@
|
||||
import { getDate } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||
|
||||
export const DEFAULT_TIMEZONE_BOOKER = "Asia/Kolkata";
|
||||
export function getBasicMockRequestDataForBooking() {
|
||||
return {
|
||||
start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`,
|
||||
end: `${getDate({ dateIncrement: 1 }).dateString}T04:30:00.000Z`,
|
||||
eventTypeSlug: "no-confirmation",
|
||||
timeZone: "Asia/Calcutta",
|
||||
timeZone: DEFAULT_TIMEZONE_BOOKER,
|
||||
language: "en",
|
||||
user: "teampro",
|
||||
metadata: {},
|
||||
@@ -20,6 +21,8 @@ export function getMockRequestDataForBooking({
|
||||
eventTypeId: number;
|
||||
rescheduleUid?: string;
|
||||
bookingUid?: string;
|
||||
recurringEventId?: string;
|
||||
recurringCount?: number;
|
||||
responses: {
|
||||
email: string;
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { TestFunction } from "vitest";
|
||||
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
|
||||
import { createOrganization } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||
|
||||
const _testWithAndWithoutOrg = (
|
||||
description: Parameters<typeof testWithAndWithoutOrg>[0],
|
||||
fn: Parameters<typeof testWithAndWithoutOrg>[1],
|
||||
timeout: Parameters<typeof testWithAndWithoutOrg>[2],
|
||||
mode: "only" | "skip" | "run" = "run"
|
||||
) => {
|
||||
const t = mode === "only" ? test.only : mode === "skip" ? test.skip : test;
|
||||
t(
|
||||
`${description} - With org`,
|
||||
async ({ emails, meta, task, onTestFailed, expect, skip }) => {
|
||||
const org = await createOrganization({
|
||||
name: "Test Org",
|
||||
slug: "testorg",
|
||||
});
|
||||
|
||||
await fn({
|
||||
meta,
|
||||
task,
|
||||
onTestFailed,
|
||||
expect,
|
||||
emails,
|
||||
skip,
|
||||
org: {
|
||||
organization: org,
|
||||
urlOrigin: `http://${org.slug}.cal.local:3000`,
|
||||
},
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
t(
|
||||
`${description}`,
|
||||
async ({ emails, meta, task, onTestFailed, expect, skip }) => {
|
||||
await fn({
|
||||
emails,
|
||||
meta,
|
||||
task,
|
||||
onTestFailed,
|
||||
expect,
|
||||
skip,
|
||||
org: null,
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
};
|
||||
|
||||
export const testWithAndWithoutOrg = (
|
||||
description: string,
|
||||
fn: TestFunction<
|
||||
Fixtures & {
|
||||
org: {
|
||||
organization: { id: number | null };
|
||||
urlOrigin?: string;
|
||||
} | null;
|
||||
}
|
||||
>,
|
||||
timeout?: number
|
||||
) => {
|
||||
_testWithAndWithoutOrg(description, fn, timeout, "run");
|
||||
};
|
||||
|
||||
testWithAndWithoutOrg.only = ((description, fn) => {
|
||||
_testWithAndWithoutOrg(description, fn, "only");
|
||||
}) as typeof _testWithAndWithoutOrg;
|
||||
|
||||
testWithAndWithoutOrg.skip = ((description, fn) => {
|
||||
_testWithAndWithoutOrg(description, fn, "skip");
|
||||
}) as typeof _testWithAndWithoutOrg;
|
||||
+12
@@ -213,6 +213,9 @@ describe("handleNewBooking", () => {
|
||||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
otherTeamMembers,
|
||||
@@ -525,6 +528,9 @@ describe("handleNewBooking", () => {
|
||||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
otherTeamMembers,
|
||||
@@ -842,6 +848,9 @@ describe("handleNewBooking", () => {
|
||||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
otherTeamMembers,
|
||||
@@ -1056,6 +1065,9 @@ describe("handleNewBooking", () => {
|
||||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
otherTeamMembers,
|
||||
|
||||
@@ -96,7 +96,10 @@ export function subdomainSuffix() {
|
||||
|
||||
export function getOrgFullOrigin(slug: string, options: { protocol: boolean } = { protocol: true }) {
|
||||
if (!slug) return WEBAPP_URL;
|
||||
return `${options.protocol ? `${new URL(WEBAPP_URL).protocol}//` : ""}${slug}.${subdomainSuffix()}`;
|
||||
const orgFullOrigin = `${
|
||||
options.protocol ? `${new URL(WEBAPP_URL).protocol}//` : ""
|
||||
}${slug}.${subdomainSuffix()}`;
|
||||
return orgFullOrigin;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib";
|
||||
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import { getBookerUrl } from "@calcom/lib/server/getBookerUrl";
|
||||
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
@@ -167,6 +168,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
|
||||
const builder = new CalendarEventBuilder();
|
||||
builder.init({
|
||||
title: bookingToReschedule.title,
|
||||
bookerUrl: await getBookerUrl(user),
|
||||
type: event && event.title ? event.title : bookingToReschedule.title,
|
||||
startTime: bookingToReschedule.startTime.toISOString(),
|
||||
endTime: bookingToReschedule.endTime.toISOString(),
|
||||
|
||||
@@ -13,7 +13,6 @@ vi.mock("@calcom/prisma", () => ({
|
||||
const handlePrismockBugs = () => {
|
||||
const __updateBooking = prismock.booking.update;
|
||||
const __findManyWebhook = prismock.webhook.findMany;
|
||||
const __findManyBooking = prismock.booking.findMany;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prismock.booking.update = (...rest: any[]) => {
|
||||
// There is a bug in prismock where it considers `createMany` and `create` itself to have the data directly
|
||||
@@ -46,35 +45,6 @@ const handlePrismockBugs = () => {
|
||||
// @ts-ignore
|
||||
return __findManyWebhook(...rest);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prismock.booking.findMany = (...rest: any[]) => {
|
||||
// There is a bug in prismock where it considers `createMany` and `create` itself to have the data directly
|
||||
// In booking flows, we encounter such scenario, so let's fix that here directly till it's fixed in prismock
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const where = rest[0]?.where;
|
||||
if (where?.OR) {
|
||||
logger.silly("Fixed Prismock bug-3");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
where.OR.forEach((or: any) => {
|
||||
if (or.startTime?.gte) {
|
||||
or.startTime.gte = or.startTime.gte.toISOString ? or.startTime.gte.toISOString() : or.startTime.gte;
|
||||
}
|
||||
if (or.startTime?.lte) {
|
||||
or.startTime.lte = or.startTime.lte.toISOString ? or.startTime.lte.toISOString() : or.startTime.lte;
|
||||
}
|
||||
if (or.endTime?.gte) {
|
||||
or.endTime.lte = or.endTime.gte.toISOString ? or.endTime.gte.toISOString() : or.endTime.gte;
|
||||
}
|
||||
if (or.endTime?.lte) {
|
||||
or.endTime.lte = or.endTime.lte.toISOString ? or.endTime.lte.toISOString() : or.endTime.lte;
|
||||
}
|
||||
});
|
||||
}
|
||||
return __findManyBooking(...rest);
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
+9
-3
@@ -2,9 +2,6 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
process.env.INTEGRATION_TEST_MODE = "true";
|
||||
|
||||
// We can't set it during tests because it is used as soon as _metadata.ts is imported which happens before tests start running
|
||||
process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
@@ -13,3 +10,12 @@ export default defineConfig({
|
||||
testTimeout: 500000,
|
||||
},
|
||||
});
|
||||
|
||||
setEnvVariablesThatAreUsedBeforeSetup();
|
||||
|
||||
function setEnvVariablesThatAreUsedBeforeSetup() {
|
||||
// We can't set it during tests because it is used as soon as _metadata.ts is imported which happens before tests start running
|
||||
process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY";
|
||||
// With same env variable, we can test both non org and org booking scenarios
|
||||
process.env.NEXT_PUBLIC_WEBAPP_URL = "http://app.cal.local:3000";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user