diff --git a/apps/web/test/utils/bookingScenario.ts b/apps/web/test/utils/bookingScenario.ts index 99dd05acb5..a056c81d4f 100644 --- a/apps/web/test/utils/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario.ts @@ -4,14 +4,18 @@ import type { Booking as PrismaBooking, App as PrismaApp, } from "@prisma/client"; +import type { WebhookTriggerEvents } from "@prisma/client"; import { v4 as uuidv4 } from "uuid"; +import { expect } from "vitest"; +import "vitest-fetch-mock"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import logger from "@calcom/lib/logger"; import type { SchedulingType } from "@calcom/prisma/enums"; import type { BookingStatus } from "@calcom/prisma/enums"; -import type CalendarManagerMock from "../../../../tests/libs/__mocks__/CalendarManager"; +import appStoreMock from "../../../../tests/libs/__mocks__/app-store"; +import i18nMock from "../../../../tests/libs/__mocks__/libServerI18n"; import prismaMock from "../../../../tests/libs/__mocks__/prisma"; type App = { @@ -19,6 +23,15 @@ type App = { dirName: string; }; +type InputWebhook = { + appId: string | null; + userId?: number; + teamId?: number; + eventTypeId?: number; + active: boolean; + eventTriggers: WebhookTriggerEvents[]; + subscriberUrl: string; +}; /** * Data to be mocked */ @@ -37,6 +50,7 @@ type ScenarioData = { */ apps?: App[]; bookings?: InputBooking[]; + webhooks?: InputWebhook[]; }; type InputCredential = typeof TestData.credentials.google; @@ -193,8 +207,18 @@ async function addBookings(bookings: InputBooking[], eventTypes: InputEventType[ }); } -async function addWebhooks() { - prismaMock.webhook.findMany.mockResolvedValue([]); +async function addWebhooks(webhooks: InputWebhook[]) { + prismaMock.webhook.findMany.mockResolvedValue( + webhooks.map((webhook) => { + return { + ...webhook, + payloadTemplate: null, + secret: null, + id: uuidv4(), + createdAt: new Date(), + }; + }) + ); } function addUsers(users: InputUser[]) { @@ -219,9 +243,8 @@ function addUsers(users: InputUser[]) { ); } -export function createBookingScenario(data: ScenarioData) { +export async function createBookingScenario(data: ScenarioData) { logger.silly("TestData: Creating Scenario", data); - addUsers(data.users); const eventType = addEventTypes(data.eventTypes, data.users); @@ -256,15 +279,15 @@ export function createBookingScenario(data: ScenarioData) { data.bookings = data.bookings || []; allowSuccessfulBookingCreation(); addBookings(data.bookings, data.eventTypes); - mockBusyCalendarTimes([]); - addWebhooks(); + // mockBusyCalendarTimes([]); + addWebhooks(data.webhooks || []); return { eventType, }; } /** - * This fn indents to dynamically compute day, month, year for the purpose of testing. + * This fn indents to /ally compute day, month, year for the purpose of testing. * We are not using DayJS because that's actually being tested by this code. * - `dateIncrement` adds the increment to current day * - `monthIncrement` adds the increment to current month @@ -450,12 +473,11 @@ function allowSuccessfulBookingCreation() { }); } -// FIXME: This has to be per user. -// Also, can we not mock Google Calendar Itself? -export function mockBusyCalendarTimes( - busyTimes: Awaited> -) { - // return CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue(busyTimes); +export class MockError extends Error { + constructor(message: string) { + super(message); + this.name = "MockError"; + } } export function getOrganizer({ @@ -489,12 +511,14 @@ export function getScenarioData({ eventTypes, usersApartFromOrganizer = [], apps = [], + webhooks, }: // hosts = [], { organizer: ReturnType; eventTypes: ScenarioData["eventTypes"]; apps: ScenarioData["apps"]; usersApartFromOrganizer?: ScenarioData["users"]; + webhooks?: ScenarioData["webhooks"]; // hosts?: ScenarioData["hosts"]; }) { const users = [organizer, ...usersApartFromOrganizer]; @@ -512,5 +536,135 @@ export function getScenarioData({ eventTypes: [...eventTypes], users, apps: [...apps], + webhooks, }; } + +export function mockEnableEmailFeature() { + prismaMock.feature.findMany.mockResolvedValue([ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + { + slug: "emails", + // It's a kill switch + enabled: false, + }, + ]); +} + +export function mockNoTranslations() { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + i18nMock.getTranslation.mockImplementation(() => { + return new Promise((resolve) => { + const identityFn = (key: string) => key; + resolve(identityFn); + }); + }); +} + +export function mockCalendarToHaveNoBusySlots(metadataLookupKey: string) { + appStoreMock.default[metadataLookupKey].mockResolvedValue({ + lib: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + CalendarService: function MockCalendarService() { + return { + createEvent: () => { + return Promise.resolve({ + type: "daily_video", + id: "dailyEventName", + password: "dailyvideopass", + url: "http://dailyvideo.example.com", + }); + }, + getAvailability: (...args): Promise => { + return new Promise((resolve) => { + resolve([]); + }); + }, + }; + }, + }, + }); +} + +export function mockSuccessfulVideoMeetingCreation({ + metadataLookupKey, + appStoreLookupKey, +}: { + metadataLookupKey: string; + appStoreLookupKey?: string; +}) { + appStoreLookupKey = appStoreLookupKey || metadataLookupKey; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => { + return new Promise((resolve) => { + resolve({ + lib: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + VideoApiAdapter: () => ({ + createMeeting: () => { + console.log("CALLING MOCKED DAILY"); + return Promise.resolve({ + type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-${metadataLookupKey}.example.com`, + }); + }, + }), + }, + }); + }); + }); +} + +export function mockErrorOnVideoMeetingCreation({ + metadataLookupKey, + appStoreLookupKey, +}: { + metadataLookupKey: string; + appStoreLookupKey?: string; +}) { + appStoreLookupKey = appStoreLookupKey || metadataLookupKey; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + appStoreMock.default[appStoreLookupKey].mockImplementation(() => { + return new Promise((resolve) => { + resolve({ + lib: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + VideoApiAdapter: () => ({ + createMeeting: () => { + throw new MockError("Error creating Video meeting"); + }, + }), + }, + }); + }); + }); +} + +export function expectWebhookToHaveBeenCalledWith( + subscriberUrl: string, + data: { metadata: any; responses: any } +) { + const fetchCalls = fetchMock.mock.calls; + const webhookFetchCall = fetchCalls.find((call) => call[0] === subscriberUrl); + if (!webhookFetchCall) { + throw new Error(`Webhook not called with ${subscriberUrl}`); + } + expect(webhookFetchCall[0]).toBe(subscriberUrl); + const body = webhookFetchCall[1]?.body; + const parsedBody = JSON.parse((body as string) || "{}"); + parsedBody.payload.metadata.videoCallUrl = parsedBody.payload.metadata.videoCallUrl.replace( + /\/video\/[a-zA-Z0-9]{22}/, + "/video/DYNAMIC_UID" + ); + expect(parsedBody.payload.metadata).toContain(data.metadata); + expect(parsedBody.payload.responses).toEqual(data.responses); +} diff --git a/package.json b/package.json index e4e8cccd43..badd51a1ea 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "tsc-absolute": "^1.0.0", "typescript": "^4.9.4", "vitest": "^0.34.3", + "vitest-fetch-mock": "^0.2.2", "vitest-mock-extended": "^1.1.3" }, "dependencies": { diff --git a/packages/features/bookings/lib/handleNewBooking.test.ts b/packages/features/bookings/lib/handleNewBooking.test.ts index 24d30536cf..8f0c02be61 100644 --- a/packages/features/bookings/lib/handleNewBooking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking.test.ts @@ -6,7 +6,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { createMocks } from "node-mocks-http"; import { describe, expect, beforeEach } from "vitest"; -import type { EventBusyDate } from "@calcom/types/Calendar"; +import { WEBAPP_URL } from "@calcom/lib/constants"; import { test } from "@calcom/web/test/fixtures/fixtures"; import { createBookingScenario, @@ -16,40 +16,27 @@ import { getOrganizer, getScenarioData, getZoomAppCredential, + mockEnableEmailFeature, + mockNoTranslations, + mockErrorOnVideoMeetingCreation, + mockSuccessfulVideoMeetingCreation, + mockCalendarToHaveNoBusySlots, + expectWebhookToHaveBeenCalledWith, + MockError, } from "@calcom/web/test/utils/bookingScenario"; -import appStoreMock from "../../../../tests/libs/__mocks__/app-store"; -import i18nMock from "../../../../tests/libs/__mocks__/libServerI18n"; -import prismaMock from "../../../../tests/libs/__mocks__/prisma"; - type CustomNextApiRequest = NextApiRequest & Request; type CustomNextApiResponse = NextApiResponse & Response; -class MockError extends Error { - constructor(message: string) { - super(message); - this.name = "MockError"; - } -} - -expect.extend({ - toHaveSentEmail(received, expected) { - const { isNot } = this; - return { - // do not alter your "pass" based on isNot. Vitest does it for you - pass: received === "foo", - message: () => `${received} is${isNot ? " not" : ""} foo`, - }; - }, -}); - -describe("handleNewBooking", () => { +describe.sequential("handleNewBooking", () => { beforeEach(() => { mockNoTranslations(); mockEnableEmailFeature(); + globalThis.testEmails = []; + fetchMock.resetMocks(); }); - describe("Frontend:", () => { + describe.sequential("Frontend:", () => { test(`should create a successful booking with Cal Video(Daily Video) if no explicit location is provided 1. Should send emails to the booker as well as organizer `, async ({ emails }) => { @@ -72,6 +59,7 @@ describe("handleNewBooking", () => { method: "POST", body: getMockRequestDataForBooking({ data: { + eventTypeId: 1, responses: { email: booker.email, name: booker.name, @@ -82,7 +70,16 @@ describe("handleNewBooking", () => { }); const scenarioData = { - hosts: [], + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], eventTypes: [ { id: 1, @@ -107,7 +104,11 @@ describe("handleNewBooking", () => { apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }; - mockDailyVideoToCreateSuccessfulMeeting(); + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + mockCalendarToHaveNoBusySlots("googlecalendar"); // const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); // mockBusyCalendarTimes([ @@ -164,6 +165,7 @@ describe("handleNewBooking", () => { method: "POST", body: getMockRequestDataForBooking({ data: { + eventTypeId: 1, responses: { email: booker.email, name: booker.name, @@ -199,18 +201,24 @@ describe("handleNewBooking", () => { apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }; - mockDailyVideoToErrorDuringMeetingCreation(); + mockErrorOnVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + mockCalendarToHaveNoBusySlots("googlecalendar"); + createBookingScenario(scenarioData); try { await handleNewBooking(req); } catch (e) { + console.log("TestRun1End"); expect(e).toBeInstanceOf(MockError); - expect(e.message).toBe("Error creating DailyVideo meeting"); + expect((e as { message: string }).message).toBe("Error creating Video meeting"); } - }); + }, 20000); - test.only(`should create a successful booking with Zoom if used`, async ({ emails }) => { + test(`should create a successful booking with Zoom if used`, async ({ emails }) => { + console.log("TestRun2"); const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; const booker = getBooker({ email: "booker@example.com", @@ -230,6 +238,7 @@ describe("handleNewBooking", () => { method: "POST", body: getMockRequestDataForBooking({ data: { + eventTypeId: 1, responses: { email: booker.email, name: booker.name, @@ -254,17 +263,29 @@ describe("handleNewBooking", () => { }, ], apps: [TestData.apps["daily-video"]], + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], }); createBookingScenario(bookingScenario); - mockDailyVideoToCreateSuccessfulMeeting(); - handleNewBooking(req); - const testEmails = emails.get(); + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "zoomvideo", + }); + await handleNewBooking(req); + console.log("TestRun2End"); + const testEmails = emails.get(); expect(testEmails[0]).toContain({ to: `${organizer.email}`, }); - // TODO: Get the email HTML as DOM, so that we can get the title directly expect(testEmails[0].html).toContain("confirmed_event_type_subject"); @@ -272,127 +293,27 @@ describe("handleNewBooking", () => { to: `${booker.name} <${booker.email}>`, }); expect(testEmails[1].html).toContain("confirmed_event_type_subject"); - }); + expectWebhookToHaveBeenCalledWith("http://my-webhook.example.com", { + metadata: { + videoCallUrl: "http://mock-zoomvideo.example.com", + }, + responses: { + name: { label: "your_name", value: "Booker" }, + email: { label: "email_address", value: "booker@example.com" }, + location: { + label: "location", + value: { optionValue: "", value: "integrations:zoom" }, + }, + title: { label: "what_is_this_meeting_about" }, + notes: { label: "additional_notes" }, + guests: { label: "additional_guests" }, + rescheduleReason: { label: "reason_for_reschedule" }, + }, + }); + }, 20000); }); }); -function mockNoTranslations() { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - i18nMock.getTranslation.mockImplementation(() => { - return new Promise((resolve) => { - const identityFn = (key: string) => key; - resolve(identityFn); - }); - }); -} - -function mockDailyVideoToCreateSuccessfulMeeting() { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - appStoreMock.default.dailyvideo.mockImplementation(() => { - return new Promise((resolve) => { - resolve({ - lib: { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - VideoApiAdapter: () => ({ - createMeeting: () => { - return Promise.resolve({ - type: "daily_video", - id: "dailyEventName", - password: "dailyvideopass", - url: "http://dailyvideo.example.com", - }); - }, - }), - }, - }); - }); - }); - - appStoreMock.default.googlecalendar.mockResolvedValue({ - lib: { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - CalendarService: function GoogleCalendarService() { - return { - createEvent: () => { - return Promise.resolve({ - type: "daily_video", - id: "dailyEventName", - password: "dailyvideopass", - url: "http://dailyvideo.example.com", - }); - }, - getAvailability: (...args): Promise => { - return new Promise((resolve) => { - resolve([]); - }); - }, - }; - }, - }, - }); -} - -function mockZoomVideoToCreateSuccessfulMeeting() { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - appStoreMock.default.zoomvideo.mockImplementation(() => { - return new Promise((resolve) => { - resolve({ - lib: { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - VideoApiAdapter: () => ({ - createMeeting: () => { - return Promise.resolve({ - type: "zoom_video", - id: "zoomEventName", - password: "dailyvideopass", - url: "http://dailyvideo.example.com", - }); - }, - }), - }, - }); - }); - }); -} - -function mockDailyVideoToErrorDuringMeetingCreation() { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - appStoreMock.default.dailyvideo.mockImplementation(() => { - return new Promise((resolve) => { - resolve({ - lib: { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - VideoApiAdapter: () => ({ - createMeeting: () => { - throw new MockError("Error creating DailyVideo meeting"); - }, - }), - }, - }); - }); - }); -} - -function mockEnableEmailFeature() { - prismaMock.feature.findMany.mockResolvedValue([ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - { - slug: "emails", - // It's a kill switch - enabled: false, - }, - ]); -} - function getBooker({ name, email }: { name: string; email: string }) { return { name, @@ -408,7 +329,6 @@ function getBasicMockRequestDataForBooking() { return { start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`, end: `${getDate({ dateIncrement: 1 }).dateString}T04:30:00.000Z`, - eventTypeId: 1, eventTypeSlug: "no-confirmation", timeZone: "Asia/Calcutta", language: "en", @@ -424,6 +344,7 @@ function getMockRequestDataForBooking({ data, }: { data: Partial> & { + eventTypeId: number; responses: { email: string; name: string; diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 6bda5c3c32..0940105554 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -17,6 +17,7 @@ const workspaces = packagedEmbedTestsOnly include: ["packages/**/*.{test,spec}.{ts,js}", "apps/**/*.{test,spec}.{ts,js}"], // TODO: Ignore the api until tests are fixed exclude: ["apps/api/**/*", "**/node_modules/**/*", "packages/embeds/**/*"], + setupFiles: ["setupVitest.ts"], }, }, {