Files
calendar/apps/web/test/utils/bookingScenario/expects.ts
T
c28eb90928 chore: Upgrade prisma to 6.7.0 (#21264)
* chore: Upgrade prisma to 6.7.0

* Build fixes

* type fixes

Signed-off-by: Omar López <zomars@me.com>

* Update schema.prisma

* Patching

* Revert "Update schema.prisma"

This reverts commit 47d8618bf89ef4d007b30084df766f17281e21a1.

* Revert "Patching"

This reverts commit a1d2e3040e71690a44d4324db95d73b4d68c6adb.

* Revert schema changes

Signed-off-by: Omar López <zomars@me.com>

* WIP

Signed-off-by: Omar López <zomars@me.com>

* Update getPublicEvent.ts

* Update imports

Signed-off-by: Omar López <zomars@me.com>

* Update gitignore

Signed-off-by: Omar López <zomars@me.com>

* update remaining imports

Signed-off-by: Omar López <zomars@me.com>

* Delete .cursor/config.json

* Discard changes to packages/features/eventtypes/lib/getPublicEvent.ts

* Update _get.ts

* Update user.ts

* Update .gitignore

* update

* Update WorkflowStepContainer.tsx

* Update next-auth-custom-adapter.ts

* Update getPublicEvent.ts

* Update workflow.ts

* Update next-auth-custom-adapter.ts

* Update next-auth-options.ts

* Update bookingScenario.ts

* fix missing imports

* upgrades prismock

Signed-off-by: Omar López <zomars@me.com>

* patches prismock

Signed-off-by: Omar López <zomars@me.com>

* Update reschedule.test.ts

* Update prisma.ts

* patch prismock

Signed-off-by: Omar López <zomars@me.com>

* fix enums imports

Signed-off-by: Omar López <zomars@me.com>

* Revert "Update prisma.ts"

This reverts commit 64edcf8db54171ff4456c209d563b5d431d99619.

* Revert "patch prismock"

This reverts commit e95819113dc9d88e7130947aa120cd42710977c8.

* fix patch

* Fix test that overrun the boundary, it shouldn't test too much

* Move prisma import to changeSMSLockState

* Bring back broken test without illegal imports

* Merge with main and fix filter hosts by same round robin host

* Fixed buildDryRunBooking fn tests

* Fix and move ooo create or update handler test

* Fix packages/features/eventtypes/lib/isCurrentlyAvailable.test.ts

* Fix packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts

* Mock @calcom/prisma

* Fix: verify-email.test.ts

* fix: Moved WebhookService test and fixed default import mock

* Fix: Added missing prisma mock, handleNewBooking uses that of course

* We're not testing createContext here

* fix: Prisma mock fix for listMembers.test.ts

* More fixes to broken testcases

* Forgot to remove borked test

* Prevent the need to mock a lot of dependencies by moving out buildBaseWhereCondition to its own file

* Temporarily skip getCalendarEvents, needs a rewrite

* Fix: turns out you can access protected in testcases

* fix further mocks

* Added packages/features/insights/server/buildBaseWhereCondition.ts, types

* Always great to have a mock and then not use it

* And one less again.

* fix: confirm.handler.test, didn't mock prisma

* fix: Address minor nit by @eunjae & fix ImpersonationProvider test

* Updated isPrismaAvailableCheck that doesn't crash on import

* fix: Get Prisma directly from the client, it usually involves the Validator and does not need 'local' inclusion

* Add zod-prisma-types without the generator enabled (commented out)

* Uncomment and see what happens

* Change method of import as imports did not work in Input Schemas

* Remove custom 'zod' booking model, it does not belong with Prisma

* Fix all other global Model imports

* Rewrite most schema includes AND remove barrel file

* Add bookingCreateBodySchema to features/bookings

* Flurry of type fixes for compatibility with new zod gen

* Refactor out the custom prisma type createEventTypeInput

* Work around nullable eventTypeLocations

* HandlePayment type fix

* More fixes, final fix remaining is CompleteEventType

* Should fix a bunch more booking related type errors

* Missed one

* Some props missing from BookingCreateBodySchema

* Fix location type in handleChildrenEventTypes

* Little bit hacky imo but it works

* Final type error \o/

* Forgot to include Prisma

* Do not include zod-utils in booker/types

* Oops, was already including Booker/types

* Fix membership type, also disallow updating createdAt/updatedAt, make part of patch/post

* Fix api v1 type errors

* Fix EventTypeDescription typings

* Remove getParserWithGeneric, use userBodySchema with UserSchema

* use centralized timeZoneSchema

* Implement feedback by @zomars

* Couple of WIP pushes

* Fix tests

* Type fixes in `handleChildrenEventTypes` test

* Try and parse metadata before use

* Change zod-prisma-types configuration for optimal performance

* Fix prisma validator error in `prisma/selects/credential`

* Disable seperate relations model, hits a bug

* Import absolute - this makes rollup work in @platform/libraries

* Attempt at removing resolutions override

* Refactor using `Prisma.validator` to `satisfies`

* Build atoms using @calcom/prisma/client

* Build atoms using @calcom/prisma/client

* fixes

* Update eventTypeSelect.ts

* Adjust `eventTypeMetaDataSchemaWithUntypedApps` from `unknown` to `record(any)`

* `EventTypeDescription` rely on `descriptionAsSafeHTML` instead of `description`

* Add `seatsPerTimeSlot` to event type public select

* Fix typing in `users-public-view` getServerSide props

* Add missing `schedulingType` to prop

* chore: bump platform libraries

* Function return type is illegal, not sure how this passed eslint (#21567)

* Merged with main

* Update updateTokenObject.ts

* Update handleResponse.ts

* Update index.ts

* Update handleChildrenEventTypes.ts

* Update booking-idempotency-key.ts

* Update WebhookService.test.ts

* Update events.test.ts

* Update queued-response.test.ts

* Update events.test.ts

* Update getRoutedUrl.test.ts

* fix: type checks

Signed-off-by: Omar López <zomars@me.com>

* fixes

Signed-off-by: Omar López <zomars@me.com>

* chore: bump platform libraries

* Update yarn.lock

* more fixes

Signed-off-by: Omar López <zomars@me.com>

* fixes

Signed-off-by: Omar López <zomars@me.com>

* biuld fixes

* chore: bump platform libraries

* Update conferencing.repository.ts

* Update conferencing.repository.ts

* Update getCalendarsEvents.test.ts

* Update vite.config.js

* chore: bump platform libraries

* Update users.ts

* Discard changes to docs/api-reference/v2/openapi.json

* Update vite.config.ts

* updated platform libraries

* Update get.handler.test.ts

* Update get.handler.test.ts

* Update schema.prisma

* Discard changes to docs/api-reference/v2/openapi.json

* Update next-auth-custom-adapter.ts

* Update team.ts

* Flurry of type fixes

* Fix majority of insight related type errors

* Type fixes for unlink of account

* Make user nullable again

* Fixed a bunch of unit tests and one type error

* Attempted mock fix

* Attempted fix for Attribute type

* Ensure default import becomes prisma, but not direct usage

* Import default as prisma in prisma.module

* Add attributeOption to attribute type

* Fix calcom/prisma mock

* Refactor Prisma client imports to @calcom/prisma/client

Updated all imports from '@prisma/client' to '@calcom/prisma/client' across tests and repository files for consistency and to use the correct Prisma client package. This change improves maintainability and ensures the correct client is referenced throughout the codebase.

* Undo removal of max-warnings=0 to get main to merge

* Remove unit tests for e2e fixtures, provide new prisma mock

* Mock @calcom/prisma in event manager

* Mock @calcom/prisma in event manager

* Add correct format even with --no-verify

* Mock prisma in CalendarManager

* Add mock for permission-check.service

* Better injection in PrismaApiKeyRepository imports

* More mock fixes :)

* Fix listMembers.handler.test

* Fix User import

* Appropriately adjust all types to be imported as types, there were a lot of types imported as normal deps

* Why was this a thing?

* Strictly speaking; Not using prismock anymore

* Ditched patch file for prismock

* Fix output.service.ts platform type imports, need concrete for plainToClass

* Better typing and tests for unlinkConnectedAccount.handler

* Small type fix

* Disable calendar cache tests as they are dependent on prismock

* chore: bump platform lib

* getRoutedUrl test remove of unused import

* Extract select to external const on getEventTypesFromDB

* Direct select of userSelect from selects/user

* fix type error from merging 23653

* Fixed integration tests by removing hardcoded values that were possible due to mocking, but as its now directly hitting the db no longer

* fix: vite config atoms prisma client type location

* revert: example app prisma client

* revert: example app prisma client

* bump platform libs

* fix: use class instead of type for DI of PlatformBookingsService

* update platform libs

* remove unused variable

* chore: generate prisma client for api v2

* fix: api v2 e2e

* fix: atoms e2e

* fix: atoms e2e

* fix: atoms e2e

* fix: api v2 e2e

* fix: tsconfig apiv2 enums

* publish libraries

* Simplify check for existence teamId

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-09-11 15:27:50 +01:00

1433 lines
40 KiB
TypeScript

import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { InputEventType, getOrganizer, CalendarServiceMethodMock } from "./bookingScenario";
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 type { Tracking } from "@calcom/features/bookings/lib/handleNewBooking/types";
import { WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type {
WebhookTriggerEvents,
Booking,
BookingReference,
DestinationCalendar,
} from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import type { AppsStatus } from "@calcom/types/Calendar";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
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, subject?: string): R;
}
}
}
expect.extend({
toHaveEmail(emails: Fixtures["emails"], expectedEmail: ExpectedEmail, to: string, subject?: string) {
const { isNot } = this;
const testEmail = emails.get().find((email) => {
const filterConditions = [email.to.includes(to)];
if (subject) {
filterConditions.push(email.subject === subject);
}
return filterConditions.every((condition) => condition);
});
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.`,
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 async function expectBookingTrackingToBeInDatabase(tracking: Tracking, uid?: string) {
const actualBooking = await prismaMock.booking.findUnique({
where: {
uid,
},
select: {
tracking: true,
},
});
expect(actualBooking?.tracking).toEqual(expect.objectContaining(tracking));
}
export function expectSMSToBeTriggered({ sms, toNumber }: { sms: Fixtures["sms"]; toNumber: string }) {
expect(sms.get()).toEqual(
expect.arrayContaining([
expect.objectContaining({
to: toNumber,
}),
])
);
}
export function expectSuccessfulBookingCreationEmails({
emails,
organizer,
booker,
guests,
otherTeamMembers,
iCalUID,
recurrence,
bookingTimeRange,
booking,
destinationEmail,
calendarType,
}: {
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;
calendarType?: 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&cancelledBy=${encodeURIComponent(
destinationEmail ?? organizer.email
)}`,
text: "cancel",
},
]
: [
{
href: `${bookingUrlOrigin}/reschedule/${booking.uid}?rescheduledBy=${encodeURIComponent(
destinationEmail ?? organizer.email
)}`,
text: "reschedule",
},
],
...(bookingTimeRange
? {
bookingTimeRange: {
...bookingTimeRange,
timeZone: organizer.timeZone,
},
}
: null),
to: `${destinationEmail ?? organizer.email}`,
...(calendarType !== "office365_calendar"
? {
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&cancelledBy=${encodeURIComponent(booker.email)}`,
text: "cancel",
},
]
: [
{
href: `${bookingUrlOrigin}/reschedule/${booking.uid}?rescheduledBy=${encodeURIComponent(
booker.email
)}`,
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}?rescheduledBy=${encodeURIComponent(
otherTeamMember.email
)}`,
text: "reschedule",
},
{
href: `${bookingUrlOrigin}/booking/${
booking.uid
}?cancel=true&allRemainingBookings=false&cancelledBy=${encodeURIComponent(
otherTeamMember.email
)}`,
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,
bookerReschedule,
}: {
emails: Fixtures["emails"];
newOrganizer: { email: string; name: string };
prevOrganizer: { email: string; name: string };
bookerReschedule?: boolean;
}) {
if (newOrganizer !== prevOrganizer) {
vi.waitFor(() => {
// new organizer should receive scheduling emails
expect(emails).toHaveEmail(
{
heading: "new_event_scheduled",
to: `${newOrganizer.email}`,
},
`${newOrganizer.email}`
);
});
vi.waitFor(() => {
// old organizer should receive cancelled emails
expect(emails).toHaveEmail(
{
heading: "event_request_cancelled",
to: `${prevOrganizer.email}`,
},
`${prevOrganizer.email}`
);
});
// if booking is rescheduled by booker, old organizer should recieve reassigned emails
if (bookerReschedule) {
vi.waitFor(() => {
expect(emails).toHaveEmail(
{
heading: "event_request_reassigned",
to: `${prevOrganizer.email}`,
},
`${prevOrganizer.email}`
);
});
}
} else {
vi.waitFor(() => {
// organizer should receive 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 expectSuccesfulLocationChangeEmails({
emails,
organizer,
location,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
location: {
href: string;
linkText: string;
};
}) {
expect(emails).toHaveEmail(
{
titleTag: "location_changed_event_type_subject",
links: [
{
href: location.href,
text: location.linkText,
},
],
to: `${organizer.email}`,
},
`${organizer.email}`
);
}
export function expectAwaitingPaymentEmails({
emails,
booker,
subject,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
subject?: string;
}) {
expect(emails).toHaveEmail(
{
titleTag: "awaiting_payment_subject",
to: `${booker.name} <${booker.email}>`,
noIcs: true,
},
`${booker.email}`,
subject
);
}
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}`
);
if (booker) {
expect(emails).toHaveEmail(
{
titleTag: "booking_submitted_subject",
to: `${booker.email}`,
noIcs: true,
},
`${booker.email}`
);
}
}
export function expectBookingRequestRescheduledEmails({
emails,
loggedInUser,
booker,
booking,
}: {
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}/reschedule/${booking.uid}?allowRescheduleForCancelledBooking=true`,
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,
isEmailHidden = false,
isAttendeePhoneNumberHidden = false,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string; attendeePhoneNumber?: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
eventType: InputEventType;
isEmailHidden?: boolean;
isAttendeePhoneNumberHidden?: boolean;
}) {
// 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: isEmailHidden,
},
...(booker.attendeePhoneNumber
? {
attendeePhoneNumber: {
label: "phone_number",
value: booker.attendeePhoneNumber,
isHidden: isAttendeePhoneNumberHidden,
},
}
: null),
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 },
...(booker.attendeePhoneNumber
? {
attendeePhoneNumber: {
label: "phone_number",
value: booker.attendeePhoneNumber,
},
}
: null),
location: {
label: "location",
value: { optionValue: "", value: location },
},
},
},
});
}
}
export function expectBookingCreatedWebhookToHaveBeenFired({
booker,
location,
subscriberUrl,
paidEvent,
videoCallUrl,
isEmailHidden = false,
isAttendeePhoneNumberHidden = false,
}: {
organizer: { email: string; name: string };
booker: { email: string; name: string; attendeePhoneNumber?: string };
subscriberUrl: string;
location: string;
paidEvent?: boolean;
videoCallUrl?: string | null;
isEmailHidden?: boolean;
isAttendeePhoneNumberHidden?: boolean;
}) {
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: isEmailHidden },
...(booker.attendeePhoneNumber
? {
attendeePhoneNumber: {
label: "phone_number",
value: booker.attendeePhoneNumber,
isHidden: isAttendeePhoneNumberHidden,
},
}
: null),
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,
},
...(booker.attendeePhoneNumber
? {
attendeePhoneNumber: {
label: "phone_number",
value: booker.attendeePhoneNumber,
},
}
: null),
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,
},
},
},
});
}
type ExpectedForSuccessfulCalendarEventCreationInCalendar = {
calendarId?: string | null;
calendarIdUsingFallbackOfFirstCalendarCredential?: string | null;
/**
* explciityl set to null if you don't want to match on videoCallUrl
*/
videoCallUrl: string | null;
destinationCalendars?: Partial<DestinationCalendar>[];
credential?: Partial<CredentialForCalendarService>;
};
export function expectSuccessfulCalendarEventCreationInCalendar(
calendarMock: CalendarServiceMethodMock,
expected:
| ExpectedForSuccessfulCalendarEventCreationInCalendar
| ExpectedForSuccessfulCalendarEventCreationInCalendar[]
) {
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 createEventCall = calendarMock.createEventCalls[i];
const { credential } = createEventCall.calendarServiceConstructorArgs;
const { calEvent } = createEventCall.args;
if (expected.credential) {
expect(credential).toEqual(expect.objectContaining(expected.credential));
}
if (expected.calendarId) {
expect(calEvent).toEqual(
expect.objectContaining({
destinationCalendar: [
expect.objectContaining({
externalId: expected.calendarId,
}),
],
})
);
} else if (expected.destinationCalendars) {
expect(calEvent).toEqual(
expect.objectContaining({
destinationCalendar: expected.destinationCalendars
? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal)))
: null,
})
);
} else if (expected.calendarIdUsingFallbackOfFirstCalendarCredential) {
// In case of fallback, there is no destinationCalendar set
expect(calEvent).toEqual(
expect.objectContaining({
destinationCalendar: null,
})
);
} else {
expect(calEvent).toEqual(
expect.objectContaining({
destinationCalendar: null,
})
);
}
if (expected.videoCallUrl !== null) {
expect(calEvent).toEqual(
expect.objectContaining({
videoCallData: expect.objectContaining({
url: expected.videoCallUrl,
}),
})
);
}
}
}
export function expectSuccessfulCalendarEventUpdationInCalendar(
calendarMock: CalendarServiceMethodMock,
expected: {
externalCalendarId: string;
calEvent: Partial<CalendarEvent>;
uid: string;
}
) {
expect(calendarMock.updateEventCalls.length).toBe(1);
const call = calendarMock.updateEventCalls[0];
const { uid, event, externalCalendarId } = call.args;
expect(uid).toBe(expected.uid);
expect(event).toEqual(expect.objectContaining(expected.calEvent));
expect(externalCalendarId).toBe(expected.externalCalendarId);
}
export function expectSuccessfulCalendarEventDeletionInCalendar(
calendarMock: CalendarServiceMethodMock,
expected: {
externalCalendarId: string;
calEvent: Partial<CalendarEvent>;
uid: string;
}
) {
expect(calendarMock.deleteEventCalls.length).toBe(1);
const call = calendarMock.deleteEventCalls[0];
const { uid, event, externalCalendarId } = call.args;
expect(uid).toBe(expected.uid);
expect(event).toEqual(expect.objectContaining(expected.calEvent));
expect(externalCalendarId).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;
}
export async function expectBookingToNotHaveReference(
booking: Pick<Booking, "uid">,
reference: Partial<BookingReference>
) {
const actualBooking = await prismaMock.booking.findUnique({
where: {
uid: booking.uid,
},
include: {
references: true,
},
});
expect(actualBooking?.references).not.toEqual(expect.arrayContaining([expect.objectContaining(reference)]));
}
export function expectNoAttemptToCreateCalendarEvent(calendarMock: CalendarServiceMethodMock) {
expect(calendarMock.createEventCalls.length).toBe(0);
}
export function expectNoAttemptToGetAvailability(calendarMock: CalendarServiceMethodMock) {
expect(calendarMock.getAvailabilityCalls.length).toBe(0);
}