diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 5f95c6afdd3c5e..2420851ae8f84d 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -66,6 +66,7 @@ type InputUser = Omit & { 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; - 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; + 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 }; diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 48a0165417c8f5..d7b3689af65b7d 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -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 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]; - - 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 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( diff --git a/packages/core/builders/CalendarEvent/builder.ts b/packages/core/builders/CalendarEvent/builder.ts index 80a75ba2b14f05..c3e0e7501294f7 100644 --- a/packages/core/builders/CalendarEvent/builder.ts +++ b/packages/core/builders/CalendarEvent/builder.ts @@ -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) { diff --git a/packages/core/builders/CalendarEvent/class.ts b/packages/core/builders/CalendarEvent/class.ts index 2f069c3425a06f..2b33d7223db71c 100644 --- a/packages/core/builders/CalendarEvent/class.ts +++ b/packages/core/builders/CalendarEvent/class.ts @@ -9,6 +9,7 @@ import type { } from "@calcom/types/Calendar"; class CalendarEventClass implements CalendarEvent { + bookerUrl?: string | undefined; type!: string; title!: string; startTime!: string; diff --git a/packages/emails/src/components/EmailScheduledBodyHeaderContent.tsx b/packages/emails/src/components/EmailScheduledBodyHeaderContent.tsx index 64e4372d0bc13f..b66085a6a0ee58 100644 --- a/packages/emails/src/components/EmailScheduledBodyHeaderContent.tsx +++ b/packages/emails/src/components/EmailScheduledBodyHeaderContent.tsx @@ -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, diff --git a/packages/emails/src/components/WhenInfo.tsx b/packages/emails/src/components/WhenInfo.tsx index 10ba32d9de886c..5f27015d47fe1c 100644 --- a/packages/emails/src/components/WhenInfo.tsx +++ b/packages/emails/src/components/WhenInfo.tsx @@ -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 /> diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index 0d75bc4bfdc502..ea085f421a3bb7 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -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, + 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", }, - ], - destinationCalendar: { - integration: "google_calendar", - externalId: "event-type-1@google-calendar.com", }, - }, - ], - organizer, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }) + ], + 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, diff --git a/packages/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking.ts b/packages/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking.ts index 57ea353ee87018..34291e942e5a97 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking.ts @@ -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; diff --git a/packages/features/bookings/lib/handleNewBooking/test/lib/test.ts b/packages/features/bookings/lib/handleNewBooking/test/lib/test.ts new file mode 100644 index 00000000000000..f78009ef9f34fc --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/lib/test.ts @@ -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; diff --git a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts index 4eedf072bd690c..e5d7ce7191c333 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/collective-scheduling.test.ts @@ -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, diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index ee864c507f726c..d21bb8d8c107a0 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -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; } /** diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index ca282c9d4c47d4..21458ef291e4d6 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -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(), diff --git a/tests/libs/__mocks__/prisma.ts b/tests/libs/__mocks__/prisma.ts index 351fc230f13994..71803b4e04b221 100644 --- a/tests/libs/__mocks__/prisma.ts +++ b/tests/libs/__mocks__/prisma.ts @@ -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(() => { diff --git a/vitest.config.ts b/vitest.config.ts index d0817c64782f0d..2171c07a908eaf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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"; +}