1248 lines
35 KiB
TypeScript
1248 lines
35 KiB
TypeScript
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
|
|
|
import type { InputEventType, getOrganizer } from "./bookingScenario";
|
|
|
|
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, vi } from "vitest";
|
|
import "vitest-fetch-mock";
|
|
|
|
import dayjs from "@calcom/dayjs";
|
|
import { WEBSITE_URL } from "@calcom/lib/constants";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import { BookingStatus } from "@calcom/prisma/enums";
|
|
import type { AppsStatus } from "@calcom/types/Calendar";
|
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
|
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
|
|
|
|
import { DEFAULT_TIMEZONE_BOOKER } from "./getMockRequestDataForBooking";
|
|
|
|
// 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;
|
|
method: string;
|
|
};
|
|
/**
|
|
* 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: ExpectedEmail, to: string): R;
|
|
}
|
|
}
|
|
}
|
|
|
|
expect.extend({
|
|
toHaveEmail(emails: Fixtures["emails"], expectedEmail: ExpectedEmail, to: string) {
|
|
const { isNot } = this;
|
|
const testEmail = emails.get().find((email) => email.to.includes(to));
|
|
const emailsToLog = emails
|
|
.get()
|
|
.map((email) => ({ to: email.to, html: email.html, ics: email.icalEvent }));
|
|
|
|
if (!testEmail) {
|
|
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
|
|
return {
|
|
pass: false,
|
|
message: () => `No email sent to ${to}. All emails are ${JSON.stringify(emailsToLog)}`,
|
|
};
|
|
}
|
|
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;
|
|
const isIcsUIDExpected =
|
|
expectedEmail.ics && expectedEmail.ics.iCalUID
|
|
? !!(icsObject ? icsObject[expectedEmail.ics.iCalUID] : null)
|
|
: true;
|
|
const emailDom = parse(testEmail.html);
|
|
|
|
const actualEmailContent = {
|
|
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 = getExpectedEmailContent(expectedEmail);
|
|
assertHasRecurrence(expectedEmail.ics?.recurrence, (iCalUidData as VEvent)?.rrule?.toString() || "");
|
|
|
|
const isEmailContentMatched = this.equals(
|
|
actualEmailContent,
|
|
expect.objectContaining(expectedEmailContent)
|
|
);
|
|
|
|
if (!isEmailContentMatched) {
|
|
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
|
|
|
|
return {
|
|
pass: false,
|
|
message: () => `Email content ${isNot ? "is" : "is not"} matching. ${JSON.stringify(emailsToLog)}`,
|
|
actual: actualEmailContent,
|
|
expected: expectedEmailContent,
|
|
};
|
|
}
|
|
|
|
isToAddressExpected = expectedEmail.to === testEmail.to;
|
|
if (!isToAddressExpected) {
|
|
logger.silly("All Emails", JSON.stringify({ numEmails: emailsToLog.length, emailsToLog }));
|
|
return {
|
|
pass: false,
|
|
message: () => `To address ${isNot ? "is" : "is not"} matching`,
|
|
actual: testEmail.to,
|
|
expected: expectedEmail.to,
|
|
};
|
|
}
|
|
|
|
if (!expectedEmail.noIcs && !isIcsFilenameExpected) {
|
|
return {
|
|
pass: false,
|
|
actual: ics?.filename,
|
|
expected: expectedEmail.ics?.filename,
|
|
message: () => `ICS Filename ${isNot ? "is" : "is not"} matching`,
|
|
};
|
|
}
|
|
|
|
if (!expectedEmail.noIcs && !isIcsUIDExpected) {
|
|
const icsObjectKeys = icsObject ? Object.keys(icsObject) : [];
|
|
const icsKey = icsObjectKeys.find((key) => key !== "vcalendar");
|
|
if (!icsKey) throw new Error("icsKey not found");
|
|
return {
|
|
pass: false,
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
//@ts-ignore
|
|
actual: icsObject[icsKey].uid!,
|
|
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 ${JSON.stringify(ics)}`,
|
|
};
|
|
}
|
|
|
|
if (expectedEmail.appsStatus) {
|
|
const actualAppsStatus = emailDom.querySelectorAll('[data-testid="appsStatus"] li').map((li) => {
|
|
return li.innerText.trim();
|
|
});
|
|
const expectedAppStatus = expectedEmail.appsStatus.map((appStatus) => {
|
|
if (appStatus.success && !appStatus.failures) {
|
|
return `${appStatus.appName} ✅`;
|
|
}
|
|
return `${appStatus.appName} ❌`;
|
|
});
|
|
|
|
const isAppsStatusCorrect = this.equals(actualAppsStatus, expectedAppStatus);
|
|
|
|
if (!isAppsStatusCorrect) {
|
|
return {
|
|
pass: false,
|
|
actual: actualAppsStatus,
|
|
expected: expectedAppStatus,
|
|
message: () => `AppsStatus ${isNot ? "is" : "isn't"} matching`,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
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);
|
|
}
|
|
},
|
|
});
|
|
|
|
export function expectWebhookToHaveBeenCalledWith(
|
|
subscriberUrl: string,
|
|
data: {
|
|
triggerEvent: WebhookTriggerEvents;
|
|
payload: Record<string, unknown> | null;
|
|
}
|
|
) {
|
|
const fetchCalls = fetchMock.mock.calls;
|
|
const webhooksToSubscriberUrl = fetchCalls.filter((call) => {
|
|
return call[0] === subscriberUrl;
|
|
});
|
|
logger.silly("Scanning fetchCalls for webhook", safeStringify(fetchCalls));
|
|
const webhookFetchCall = webhooksToSubscriberUrl.find((call) => {
|
|
const body = call[1]?.body;
|
|
const parsedBody = JSON.parse((body as string) || "{}");
|
|
return parsedBody.triggerEvent === data.triggerEvent;
|
|
});
|
|
|
|
if (!webhookFetchCall) {
|
|
throw new Error(
|
|
`Webhook not sent to ${subscriberUrl} for ${data.triggerEvent}. All webhooks: ${JSON.stringify(
|
|
webhooksToSubscriberUrl
|
|
)}`
|
|
);
|
|
}
|
|
expect(webhookFetchCall[0]).toBe(subscriberUrl);
|
|
const body = webhookFetchCall[1]?.body;
|
|
const parsedBody = JSON.parse((body as string) || "{}");
|
|
|
|
expect(parsedBody.triggerEvent).toBe(data.triggerEvent);
|
|
|
|
if (parsedBody.payload) {
|
|
if (data.payload) {
|
|
if (!!data.payload.metadata) {
|
|
expect(parsedBody.payload.metadata).toEqual(expect.objectContaining(data.payload.metadata));
|
|
}
|
|
if (!!data.payload.responses)
|
|
expect(parsedBody.payload.responses).toEqual(expect.objectContaining(data.payload.responses));
|
|
|
|
if (!!data.payload.organizer)
|
|
expect(parsedBody.payload.organizer).toEqual(expect.objectContaining(data.payload.organizer));
|
|
|
|
const { responses: _1, metadata: _2, organizer: _3, ...remainingPayload } = data.payload;
|
|
expect(parsedBody.payload).toEqual(expect.objectContaining(remainingPayload));
|
|
}
|
|
}
|
|
}
|
|
|
|
export function expectWorkflowToBeTriggered({
|
|
emails,
|
|
emailsToReceive,
|
|
}: {
|
|
emails: Fixtures["emails"];
|
|
emailsToReceive: string[];
|
|
}) {
|
|
const subjectPattern = /^Reminder: /i;
|
|
emailsToReceive.forEach((email) => {
|
|
expect(emails.get()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
subject: expect.stringMatching(subjectPattern),
|
|
to: email,
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
}
|
|
|
|
export function expectWorkflowToBeNotTriggered({
|
|
emails,
|
|
emailsToReceive,
|
|
}: {
|
|
emails: Fixtures["emails"];
|
|
emailsToReceive: string[];
|
|
}) {
|
|
const subjectPattern = /^Reminder: /i;
|
|
|
|
emailsToReceive.forEach((email) => {
|
|
expect(emails.get()).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
subject: expect.stringMatching(subjectPattern),
|
|
to: email,
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
}
|
|
|
|
export function expectSMSWorkflowToBeTriggered({
|
|
sms,
|
|
toNumber,
|
|
includedString,
|
|
}: {
|
|
sms: Fixtures["sms"];
|
|
toNumber: string;
|
|
includedString?: string;
|
|
}) {
|
|
const allSMS = sms.get();
|
|
if (includedString) {
|
|
const messageWithIncludedString = allSMS.find((sms) => sms.message.includes(includedString));
|
|
|
|
expect(messageWithIncludedString?.to).toBe(toNumber);
|
|
} else {
|
|
expect(allSMS).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
to: toNumber,
|
|
}),
|
|
])
|
|
);
|
|
}
|
|
}
|
|
|
|
export function expectSMSWorkflowToBeNotTriggered({
|
|
sms,
|
|
toNumber,
|
|
includedString,
|
|
}: {
|
|
sms: Fixtures["sms"];
|
|
toNumber: string;
|
|
includedString?: string;
|
|
}) {
|
|
const allSMS = sms.get();
|
|
|
|
if (includedString) {
|
|
const messageWithIncludedString = allSMS.find((sms) => sms.message.includes(includedString));
|
|
|
|
if (messageWithIncludedString) {
|
|
expect(messageWithIncludedString?.to).not.toBe(toNumber);
|
|
}
|
|
} else {
|
|
expect(allSMS).not.toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
to: toNumber,
|
|
}),
|
|
])
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function expectBookingToBeInDatabase(
|
|
booking: Partial<Booking> & Pick<Booking, "uid"> & { references?: Partial<BookingReference>[] }
|
|
) {
|
|
const actualBooking = await prismaMock.booking.findUnique({
|
|
where: {
|
|
uid: booking.uid,
|
|
},
|
|
include: {
|
|
references: true,
|
|
},
|
|
});
|
|
|
|
const { references, ...remainingBooking } = booking;
|
|
expect(actualBooking).toEqual(expect.objectContaining(remainingBooking));
|
|
expect(actualBooking?.references).toEqual(
|
|
expect.arrayContaining((references || []).map((reference) => expect.objectContaining(reference)))
|
|
);
|
|
}
|
|
|
|
export function expectSuccessfulBookingCreationEmails({
|
|
emails,
|
|
organizer,
|
|
booker,
|
|
guests,
|
|
otherTeamMembers,
|
|
iCalUID,
|
|
recurrence,
|
|
bookingTimeRange,
|
|
booking,
|
|
destinationEmail,
|
|
}: {
|
|
emails: Fixtures["emails"];
|
|
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 };
|
|
destinationEmail?: string;
|
|
}) {
|
|
const bookingUrlOrigin = booking.urlOrigin || WEBSITE_URL;
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
titleTag: "confirmed_event_type_subject",
|
|
heading: recurrence ? "new_event_scheduled_recurring" : "new_event_scheduled",
|
|
subHeading: "",
|
|
links: recurrence
|
|
? [
|
|
{
|
|
href: `${bookingUrlOrigin}/booking/${booking.uid}?cancel=true&allRemainingBookings=true`,
|
|
text: "cancel",
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
href: `${bookingUrlOrigin}/reschedule/${booking.uid}`,
|
|
text: "reschedule",
|
|
},
|
|
],
|
|
...(bookingTimeRange
|
|
? {
|
|
bookingTimeRange: {
|
|
...bookingTimeRange,
|
|
timeZone: organizer.timeZone,
|
|
},
|
|
}
|
|
: null),
|
|
to: `${destinationEmail ?? organizer.email}`,
|
|
ics: {
|
|
filename: "event.ics",
|
|
iCalUID: `${iCalUID}`,
|
|
recurrence,
|
|
method: "REQUEST",
|
|
},
|
|
},
|
|
`${destinationEmail ?? organizer.email}`
|
|
);
|
|
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
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,
|
|
method: "REQUEST",
|
|
},
|
|
links: recurrence
|
|
? [
|
|
{
|
|
href: `${bookingUrlOrigin}/booking/${booking.uid}?cancel=true&allRemainingBookings=true`,
|
|
text: "cancel",
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
href: `${bookingUrlOrigin}/reschedule/${booking.uid}`,
|
|
text: "reschedule",
|
|
},
|
|
],
|
|
},
|
|
`${booker.name} <${booker.email}>`
|
|
);
|
|
|
|
if (otherTeamMembers) {
|
|
otherTeamMembers.forEach((otherTeamMember) => {
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
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}`,
|
|
method: "REQUEST",
|
|
},
|
|
links: [
|
|
{
|
|
href: `${bookingUrlOrigin}/reschedule/${booking.uid}`,
|
|
text: "reschedule",
|
|
},
|
|
{
|
|
href: `${bookingUrlOrigin}/booking/${booking.uid}?cancel=true&allRemainingBookings=false`,
|
|
text: "cancel",
|
|
},
|
|
],
|
|
},
|
|
`${otherTeamMember.email}`
|
|
);
|
|
});
|
|
}
|
|
|
|
if (guests) {
|
|
guests.forEach((guest) => {
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
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",
|
|
iCalUID: `${iCalUID}`,
|
|
method: "REQUEST",
|
|
},
|
|
},
|
|
`${guest.name} <${guest.email}`
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
export function expectBrokenIntegrationEmails({
|
|
emails,
|
|
organizer,
|
|
}: {
|
|
emails: Fixtures["emails"];
|
|
organizer: { email: string; name: string };
|
|
}) {
|
|
// Broken Integration email is only sent to the Organizer
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
titleTag: "broken_integration",
|
|
to: `${organizer.email}`,
|
|
// No ics goes in case of broken integration email it seems
|
|
// ics: {
|
|
// filename: "event.ics",
|
|
// iCalUID: iCalUID,
|
|
// },
|
|
},
|
|
`${organizer.email}`
|
|
);
|
|
|
|
// expect(emails).toHaveEmail(
|
|
// {
|
|
// title: "confirmed_event_type_subject",
|
|
// to: `${booker.name} <${booker.email}>`,
|
|
// },
|
|
// `${booker.name} <${booker.email}>`
|
|
// );
|
|
}
|
|
|
|
export function expectCalendarEventCreationFailureEmails({
|
|
emails,
|
|
organizer,
|
|
booker,
|
|
iCalUID,
|
|
}: {
|
|
emails: Fixtures["emails"];
|
|
organizer: { email: string; name: string };
|
|
booker: { email: string; name: string };
|
|
iCalUID: string;
|
|
}) {
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
titleTag: "broken_integration",
|
|
to: `${organizer.email}`,
|
|
ics: {
|
|
filename: "event.ics",
|
|
iCalUID,
|
|
method: "REQUEST",
|
|
},
|
|
},
|
|
`${organizer.email}`
|
|
);
|
|
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
titleTag: "calendar_event_creation_failure_subject",
|
|
to: `${booker.name} <${booker.email}>`,
|
|
ics: {
|
|
filename: "event.ics",
|
|
iCalUID,
|
|
method: "REQUEST",
|
|
},
|
|
},
|
|
`${booker.name} <${booker.email}>`
|
|
);
|
|
}
|
|
|
|
export function expectSuccessfulRoundRobinReschedulingEmails({
|
|
emails,
|
|
newOrganizer,
|
|
prevOrganizer,
|
|
}: {
|
|
emails: Fixtures["emails"];
|
|
newOrganizer: { email: string; name: string };
|
|
prevOrganizer: { email: string; name: string };
|
|
}) {
|
|
if (newOrganizer !== prevOrganizer) {
|
|
vi.waitFor(() => {
|
|
// new organizer should recieve scheduling emails
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
heading: "new_event_scheduled",
|
|
to: `${newOrganizer.email}`,
|
|
},
|
|
`${newOrganizer.email}`
|
|
);
|
|
});
|
|
|
|
vi.waitFor(() => {
|
|
// old organizer should recieve cancelled emails
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
heading: "event_request_cancelled",
|
|
to: `${prevOrganizer.email}`,
|
|
},
|
|
`${prevOrganizer.email}`
|
|
);
|
|
});
|
|
} else {
|
|
vi.waitFor(() => {
|
|
// organizer should recieve rescheduled emails
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
heading: "event_has_been_rescheduled",
|
|
to: `${newOrganizer.email}`,
|
|
},
|
|
`${newOrganizer.email}`
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
export function expectSuccessfulBookingRescheduledEmails({
|
|
emails,
|
|
organizer,
|
|
booker,
|
|
iCalUID,
|
|
appsStatus,
|
|
}: {
|
|
emails: Fixtures["emails"];
|
|
organizer: { email: string; name: string };
|
|
booker: { email: string; name: string };
|
|
iCalUID: string;
|
|
appsStatus?: AppsStatus[];
|
|
}) {
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
titleTag: "event_type_has_been_rescheduled_on_time_date",
|
|
to: `${organizer.email}`,
|
|
ics: {
|
|
filename: "event.ics",
|
|
iCalUID,
|
|
method: "REQUEST",
|
|
},
|
|
appsStatus,
|
|
},
|
|
`${organizer.email}`
|
|
);
|
|
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
titleTag: "event_type_has_been_rescheduled_on_time_date",
|
|
to: `${booker.name} <${booker.email}>`,
|
|
ics: {
|
|
filename: "event.ics",
|
|
iCalUID,
|
|
method: "REQUEST",
|
|
},
|
|
},
|
|
`${booker.name} <${booker.email}>`
|
|
);
|
|
}
|
|
|
|
export function expectAwaitingPaymentEmails({
|
|
emails,
|
|
booker,
|
|
}: {
|
|
emails: Fixtures["emails"];
|
|
organizer: { email: string; name: string };
|
|
booker: { email: string; name: string };
|
|
}) {
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
titleTag: "awaiting_payment_subject",
|
|
to: `${booker.name} <${booker.email}>`,
|
|
noIcs: true,
|
|
},
|
|
`${booker.email}`
|
|
);
|
|
}
|
|
|
|
export function expectBookingRequestedEmails({
|
|
emails,
|
|
organizer,
|
|
booker,
|
|
}: {
|
|
emails: Fixtures["emails"];
|
|
organizer: { email: string; name: string };
|
|
booker: { email: string; name: string };
|
|
}) {
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
titleTag: "event_awaiting_approval_subject",
|
|
to: `${organizer.email}`,
|
|
noIcs: true,
|
|
},
|
|
`${organizer.email}`
|
|
);
|
|
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
titleTag: "booking_submitted_subject",
|
|
to: `${booker.email}`,
|
|
noIcs: true,
|
|
},
|
|
`${booker.email}`
|
|
);
|
|
}
|
|
|
|
export function expectBookingRequestRescheduledEmails({
|
|
emails,
|
|
loggedInUser,
|
|
booker,
|
|
booking,
|
|
bookNewTimePath,
|
|
organizer,
|
|
}: {
|
|
emails: Fixtures["emails"];
|
|
organizer: ReturnType<typeof getOrganizer>;
|
|
loggedInUser: {
|
|
email: string;
|
|
name: string;
|
|
};
|
|
booker: { email: string; name: string };
|
|
booking: { uid: string; urlOrigin?: string };
|
|
bookNewTimePath: string;
|
|
}) {
|
|
const bookingUrlOrigin = booking.urlOrigin || WEBSITE_URL;
|
|
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
titleTag: "rescheduled_event_type_subject",
|
|
heading: "request_reschedule_booking",
|
|
subHeading: "request_reschedule_subtitle",
|
|
links: [
|
|
{
|
|
href: `${bookingUrlOrigin}${bookNewTimePath}?rescheduleUid=${booking.uid}`,
|
|
text: "Book a new time",
|
|
},
|
|
],
|
|
to: `${booker.email}`,
|
|
ics: {
|
|
filename: "event.ics",
|
|
method: "REQUEST",
|
|
},
|
|
},
|
|
`${booker.email}`
|
|
);
|
|
|
|
expect(emails).toHaveEmail(
|
|
{
|
|
titleTag: "rescheduled_event_type_subject",
|
|
heading: "request_reschedule_title_organizer",
|
|
subHeading: "request_reschedule_subtitle_organizer",
|
|
to: `${loggedInUser.email}`,
|
|
ics: {
|
|
filename: "event.ics",
|
|
method: "REQUEST",
|
|
},
|
|
},
|
|
`${loggedInUser.email}`
|
|
);
|
|
}
|
|
|
|
export function expectBookingRequestedWebhookToHaveBeenFired({
|
|
booker,
|
|
location,
|
|
subscriberUrl,
|
|
paidEvent,
|
|
eventType,
|
|
}: {
|
|
organizer: { email: string; name: string };
|
|
booker: { email: string; name: string };
|
|
subscriberUrl: string;
|
|
location: string;
|
|
paidEvent?: boolean;
|
|
eventType: InputEventType;
|
|
}) {
|
|
// There is an inconsistency in the way we send the data to the webhook for paid events and unpaid events. Fix that and then remove this if statement.
|
|
if (!paidEvent) {
|
|
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
|
|
triggerEvent: "BOOKING_REQUESTED",
|
|
payload: {
|
|
eventTitle: eventType.title,
|
|
eventDescription: eventType.description,
|
|
metadata: {
|
|
// In a Pending Booking Request, we don't send the video call url
|
|
},
|
|
responses: {
|
|
name: {
|
|
label: "your_name",
|
|
value: booker.name,
|
|
isHidden: false,
|
|
},
|
|
email: {
|
|
label: "email_address",
|
|
value: booker.email,
|
|
isHidden: false,
|
|
},
|
|
location: {
|
|
label: "location",
|
|
value: { optionValue: "", value: location },
|
|
isHidden: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
} else {
|
|
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
|
|
triggerEvent: "BOOKING_REQUESTED",
|
|
payload: {
|
|
eventTitle: eventType.title,
|
|
eventDescription: eventType.description,
|
|
metadata: {
|
|
// In a Pending Booking Request, we don't send the video call url
|
|
},
|
|
responses: {
|
|
name: { label: "name", value: booker.name },
|
|
email: { label: "email", value: booker.email },
|
|
location: {
|
|
label: "location",
|
|
value: { optionValue: "", value: location },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
export function expectBookingCreatedWebhookToHaveBeenFired({
|
|
booker,
|
|
location,
|
|
subscriberUrl,
|
|
paidEvent,
|
|
videoCallUrl,
|
|
}: {
|
|
organizer: { email: string; name: string };
|
|
booker: { email: string; name: string };
|
|
subscriberUrl: string;
|
|
location: string;
|
|
paidEvent?: boolean;
|
|
videoCallUrl?: string | null;
|
|
}) {
|
|
if (!paidEvent) {
|
|
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
|
|
triggerEvent: "BOOKING_CREATED",
|
|
payload: {
|
|
metadata: {
|
|
...(videoCallUrl ? { videoCallUrl } : null),
|
|
},
|
|
responses: {
|
|
name: { label: "your_name", value: booker.name, isHidden: false },
|
|
email: { label: "email_address", value: booker.email, isHidden: false },
|
|
location: {
|
|
label: "location",
|
|
value: { optionValue: "", value: location },
|
|
isHidden: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
} else {
|
|
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
|
|
triggerEvent: "BOOKING_CREATED",
|
|
payload: {
|
|
// FIXME: File this bug and link ticket here. This is a bug in the code. metadata must be sent here like other BOOKING_CREATED webhook
|
|
metadata: null,
|
|
responses: {
|
|
name: {
|
|
label: "name",
|
|
value: booker.name,
|
|
},
|
|
email: {
|
|
label: "email",
|
|
value: booker.email,
|
|
},
|
|
location: {
|
|
label: "location",
|
|
value: { optionValue: "", value: location },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
export function expectBookingRescheduledWebhookToHaveBeenFired({
|
|
booker,
|
|
location,
|
|
subscriberUrl,
|
|
videoCallUrl,
|
|
payload,
|
|
}: {
|
|
organizer: { email: string; name: string };
|
|
booker: { email: string; name: string };
|
|
subscriberUrl: string;
|
|
location: string;
|
|
paidEvent?: boolean;
|
|
videoCallUrl?: string;
|
|
payload?: Record<string, unknown>;
|
|
}) {
|
|
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
|
|
triggerEvent: "BOOKING_RESCHEDULED",
|
|
payload: {
|
|
...payload,
|
|
metadata: {
|
|
...(videoCallUrl ? { videoCallUrl } : null),
|
|
},
|
|
responses: {
|
|
name: { label: "your_name", value: booker.name, isHidden: false },
|
|
email: { label: "email_address", value: booker.email, isHidden: false },
|
|
location: {
|
|
label: "location",
|
|
value: { optionValue: "", value: location },
|
|
isHidden: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export function expectBookingCancelledWebhookToHaveBeenFired({
|
|
booker,
|
|
location,
|
|
subscriberUrl,
|
|
payload,
|
|
}: {
|
|
organizer: { email: string; name: string };
|
|
booker: { email: string; name: string };
|
|
subscriberUrl: string;
|
|
location: string;
|
|
payload?: Record<string, unknown>;
|
|
}) {
|
|
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
|
|
triggerEvent: "BOOKING_CANCELLED",
|
|
payload: {
|
|
...payload,
|
|
metadata: null,
|
|
responses: {
|
|
name: {
|
|
label: "name",
|
|
value: booker.name,
|
|
},
|
|
email: {
|
|
label: "email",
|
|
value: booker.email,
|
|
},
|
|
location: {
|
|
label: "location",
|
|
value: { optionValue: "", value: location },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export function expectBookingPaymentIntiatedWebhookToHaveBeenFired({
|
|
booker,
|
|
location,
|
|
subscriberUrl,
|
|
paymentId,
|
|
}: {
|
|
organizer: { email: string; name: string };
|
|
booker: { email: string; name: string };
|
|
subscriberUrl: string;
|
|
location: string;
|
|
paymentId: number;
|
|
}) {
|
|
expectWebhookToHaveBeenCalledWith(subscriberUrl, {
|
|
triggerEvent: "BOOKING_PAYMENT_INITIATED",
|
|
payload: {
|
|
paymentId: paymentId,
|
|
metadata: {
|
|
// In a Pending Booking Request, we don't send the video call url
|
|
},
|
|
responses: {
|
|
name: { label: "your_name", value: booker.name, isHidden: false },
|
|
email: { label: "email_address", value: booker.email, isHidden: false },
|
|
location: {
|
|
label: "location",
|
|
value: { optionValue: "", value: location },
|
|
isHidden: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
export function expectSuccessfulCalendarEventCreationInCalendar(
|
|
calendarMock: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
createEventCalls: any[];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
updateEventCalls: any[];
|
|
},
|
|
expected:
|
|
| {
|
|
calendarId?: string | null;
|
|
videoCallUrl: string;
|
|
destinationCalendars?: Partial<DestinationCalendar>[];
|
|
}
|
|
| {
|
|
calendarId?: string | null;
|
|
videoCallUrl: string;
|
|
destinationCalendars?: Partial<DestinationCalendar>[];
|
|
}[]
|
|
) {
|
|
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];
|
|
|
|
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(
|
|
calendarMock: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
createEventCalls: any[];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
updateEventCalls: any[];
|
|
},
|
|
expected: {
|
|
externalCalendarId: string;
|
|
calEvent: Partial<CalendarEvent>;
|
|
uid: string;
|
|
}
|
|
) {
|
|
expect(calendarMock.updateEventCalls.length).toBe(1);
|
|
const call = calendarMock.updateEventCalls[0];
|
|
const uid = call[0];
|
|
const calendarEvent = call[1];
|
|
const externalId = call[2];
|
|
expect(uid).toBe(expected.uid);
|
|
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
|
|
expect(externalId).toBe(expected.externalCalendarId);
|
|
}
|
|
|
|
export function expectSuccessfulCalendarEventDeletionInCalendar(
|
|
calendarMock: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
createEventCalls: any[];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
updateEventCalls: any[];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
deleteEventCalls: any[];
|
|
},
|
|
expected: {
|
|
externalCalendarId: string;
|
|
calEvent: Partial<CalendarEvent>;
|
|
uid: string;
|
|
}
|
|
) {
|
|
expect(calendarMock.deleteEventCalls.length).toBe(1);
|
|
const call = calendarMock.deleteEventCalls[0];
|
|
const uid = call[0];
|
|
const calendarEvent = call[1];
|
|
const externalId = call[2];
|
|
expect(uid).toBe(expected.uid);
|
|
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
|
|
expect(externalId).toBe(expected.externalCalendarId);
|
|
}
|
|
|
|
export function expectSuccessfulVideoMeetingCreation(
|
|
videoMock: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
createMeetingCalls: any[];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
updateMeetingCalls: any[];
|
|
},
|
|
expected: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
credential: any;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
calEvent: any;
|
|
}
|
|
) {
|
|
expect(videoMock.createMeetingCalls.length).toBe(1);
|
|
const call = videoMock.createMeetingCalls[0];
|
|
const callArgs = call.args;
|
|
const calEvent = callArgs[0];
|
|
const credential = call.credential;
|
|
|
|
expect(credential).toEqual(expected.credential);
|
|
expect(calEvent).toEqual(expected.calEvent);
|
|
}
|
|
|
|
export function expectSuccessfulVideoMeetingUpdationInCalendar(
|
|
videoMock: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
createMeetingCalls: any[];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
updateMeetingCalls: any[];
|
|
},
|
|
expected: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
bookingRef: any;
|
|
calEvent: Partial<CalendarEvent>;
|
|
}
|
|
) {
|
|
expect(videoMock.updateMeetingCalls.length).toBe(1);
|
|
const call = videoMock.updateMeetingCalls[0];
|
|
const bookingRef = call.args[0];
|
|
const calendarEvent = call.args[1];
|
|
expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef));
|
|
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
|
|
}
|
|
|
|
export function expectSuccessfulVideoMeetingDeletionInCalendar(
|
|
videoMock: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
createMeetingCalls: any[];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
updateMeetingCalls: any[];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
deleteMeetingCalls: any[];
|
|
},
|
|
expected: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
bookingRef: any;
|
|
}
|
|
) {
|
|
expect(videoMock.deleteMeetingCalls.length).toBe(1);
|
|
const call = videoMock.deleteMeetingCalls[0];
|
|
const bookingRefUid = call.args[0];
|
|
expect(bookingRefUid).toEqual(expected.bookingRef.uid);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { from: any; to: any }) {
|
|
// Expect previous booking to be cancelled
|
|
await expectBookingToBeInDatabase({
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
...from,
|
|
status: BookingStatus.CANCELLED,
|
|
});
|
|
|
|
// Expect new booking to be created but status would depend on whether the new booking requires confirmation or not.
|
|
await expectBookingToBeInDatabase({
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
...to,
|
|
});
|
|
}
|
|
|
|
export function expectICalUIDAsString(iCalUID: string | undefined | null) {
|
|
if (typeof iCalUID !== "string") {
|
|
throw new Error("iCalUID is not a string");
|
|
}
|
|
|
|
return iCalUID;
|
|
}
|