From 87316c79c90d3ea69304d008c8c7b4f0b3875f1f Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 22 Nov 2023 09:15:47 -0500 Subject: [PATCH] test: Integration Test GCal Primary Calendar (#12011) Co-authored-by: Alex van Andel --- .env.example | 6 + apps/web/playwright/fixtures/users.ts | 12 +- .../lib/CalendarService.test.ts | 20 +- .../googlecalendar/lib/CalendarService.ts | 2 +- .../tests/google-calendar.e2e.ts | 215 ++++++++++++++++++ .../googlecalendar/tests/testUtils.ts | 127 +++++++++++ packages/prisma/seed.ts | 16 ++ turbo.json | 4 + 8 files changed, 389 insertions(+), 13 deletions(-) create mode 100644 packages/app-store/googlecalendar/tests/google-calendar.e2e.ts create mode 100644 packages/app-store/googlecalendar/tests/testUtils.ts diff --git a/.env.example b/.env.example index dfa0a49d66..3690d058f9 100644 --- a/.env.example +++ b/.env.example @@ -250,6 +250,12 @@ AUTH_BEARER_TOKEN_VERCEL= E2E_TEST_APPLE_CALENDAR_EMAIL="" E2E_TEST_APPLE_CALENDAR_PASSWORD="" +# - CALCOM QA ACCOUNT +# Used for E2E tests on Cal.com that require 3rd party integrations +E2E_TEST_CALCOM_QA_EMAIL="qa@example.com" +# Replace with your own password +E2E_TEST_CALCOM_QA_PASSWORD="password" + # - APP CREDENTIAL SYNC *********************************************************************************** # Used for self-hosters that are implementing Cal.com into their applications that already have certain integrations # Under settings/admin/apps ensure that all app secrets are set the same as the parent application diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index ae5fbfbec2..b0d0a48c65 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -396,6 +396,15 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn await prisma.user.delete({ where: { id } }); store.users = store.users.filter((b) => b.id !== id); }, + set: async (email: string) => { + const user = await prisma.user.findUniqueOrThrow({ + where: { email }, + include: userIncludes, + }); + const userFixture = createUserFixture(user, store.page); + store.users.push(userFixture); + return userFixture; + }, }; }; @@ -420,7 +429,8 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { eventTypes: user.eventTypes, routingForms: user.routingForms, self, - apiLogin: async () => apiLogin({ ...(await self()), password: user.username }, store.page), + apiLogin: async (password?: string) => + apiLogin({ ...(await self()), password: password || user.username }, store.page), /** * @deprecated use apiLogin instead */ diff --git a/packages/app-store/googlecalendar/lib/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/CalendarService.test.ts index 8a416ea6eb..8cf8f5b247 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.test.ts @@ -78,17 +78,15 @@ test("Calendar Cache is being called", async () => { // prismaMock.calendarCache.create.mock. const calendarService = new CalendarService(testCredential); - // @ts-expect-error authedCalendar is a private method, hence the TS error - vi.spyOn(calendarService, "authedCalendar").mockReturnValue( - // @ts-expect-error trust me bro - { - freebusy: { - query: vi.fn().mockReturnValue({ - data: testFreeBusyResponse, - }), - }, - } - ); + vi.spyOn(calendarService, "authedCalendar").mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore - Mocking the authedCalendar so can't return the actual response + freebusy: { + query: vi.fn().mockReturnValue({ + data: testFreeBusyResponse, + }), + }, + }); await calendarService.getAvailability(new Date().toISOString(), new Date().toISOString(), [ testSelectedCalendar, diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index f3af3a9cff..e01982378b 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -132,7 +132,7 @@ export default class GoogleCalendarService implements Calendar { }; }; - private authedCalendar = async () => { + public authedCalendar = async () => { const myGoogleAuth = await this.auth.getToken(); const calendar = google.calendar({ version: "v3", diff --git a/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts b/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts new file mode 100644 index 0000000000..226b7a61cd --- /dev/null +++ b/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts @@ -0,0 +1,215 @@ +import { expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import dayjs from "@calcom/dayjs"; +import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import { test } from "@calcom/web/playwright/lib/fixtures"; +import { selectSecondAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils"; + +import metadata from "../_metadata"; +import GoogleCalendarService from "../lib/CalendarService"; +import { createBookingAndFetchGCalEvent, deleteBookingAndEvent, assertValueExists } from "./testUtils"; + +test.describe("Google Calendar", async () => { + test.describe("Test using the primary calendar", async () => { + let qaUsername: string; + let qaGCalCredential: Prisma.CredentialGetPayload<{ select: { id: true } }>; + test.beforeAll(async () => { + let runIntegrationTest = false; + + test.skip(!!APP_CREDENTIAL_SHARING_ENABLED, "Credential sharing enabled"); + + if (process.env.E2E_TEST_CALCOM_QA_EMAIL && process.env.E2E_TEST_CALCOM_QA_PASSWORD) { + qaGCalCredential = await prisma.credential.findFirstOrThrow({ + where: { + user: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL, + }, + type: metadata.type, + }, + select: { + id: true, + }, + }); + + const qaUserQuery = await prisma.user.findFirstOrThrow({ + where: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL, + }, + select: { + username: true, + }, + }); + + assertValueExists(qaUserQuery.username, "qaUsername"); + qaUsername = qaUserQuery.username; + + if (qaGCalCredential && qaUsername) runIntegrationTest = true; + } + + test.skip(!runIntegrationTest, "QA user not found"); + }); + + test.beforeEach(async ({ page, users }) => { + assertValueExists(process.env.E2E_TEST_CALCOM_QA_EMAIL, "qaEmail"); + + const qaUserStore = await users.set(process.env.E2E_TEST_CALCOM_QA_EMAIL); + + await qaUserStore.apiLogin(process.env.E2E_TEST_CALCOM_QA_PASSWORD); + + // Need to refresh keys from DB + const refreshedCredential = await prisma.credential.findFirst({ + where: { + id: qaGCalCredential?.id, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + assertValueExists(refreshedCredential, "refreshedCredential"); + + const googleCalendarService = new GoogleCalendarService(refreshedCredential); + + const calendars = await googleCalendarService.listCalendars(); + + const primaryCalendarName = calendars.find((calendar) => calendar.primary)?.name; + assertValueExists(primaryCalendarName, "primaryCalendarName"); + + await page.goto("/apps/installed/calendar"); + + await page.waitForSelector('[title*="Create events on"]'); + await page.locator('[title*="Create events on"]').locator("svg").click(); + await page.locator("#react-select-2-option-0-0").getByText(primaryCalendarName).click(); + }); + + test("On new booking, event should be created on GCal", async ({ page }) => { + const { gCalEvent, gCalReference, booking, authedCalendar } = await createBookingAndFetchGCalEvent( + page as Page, + qaGCalCredential, + qaUsername + ); + + assertValueExists(gCalEvent.start?.timeZone, "gCalEvent"); + assertValueExists(gCalEvent.end?.timeZone, "gCalEvent"); + + // Ensure that the start and end times are matching + const startTimeMatches = dayjs(booking.startTime).isSame( + dayjs(gCalEvent.start.dateTime).tz(gCalEvent.start.timeZone) + ); + const endTimeMatches = dayjs(booking.endTime).isSame( + dayjs(gCalEvent.end?.dateTime).tz(gCalEvent.end.timeZone) + ); + expect(startTimeMatches && endTimeMatches).toBe(true); + + // Ensure that the titles are matching + expect(booking.title).toBe(gCalEvent.summary); + + // Ensure that the attendee is on the event + const bookingAttendee = booking?.attendees[0].email; + const attendeeInGCalEvent = gCalEvent.attendees?.find((attendee) => attendee.email === bookingAttendee); + expect(attendeeInGCalEvent).toBeTruthy(); + + await deleteBookingAndEvent(authedCalendar, booking.uid, gCalReference.uid); + }); + + test("On reschedule, event should be updated on GCal", async ({ page }) => { + // Reschedule the booking and check the gCalEvent's time is also changed + // On reschedule gCal UID stays the same + const { gCalReference, booking, authedCalendar } = await createBookingAndFetchGCalEvent( + page, + qaGCalCredential, + qaUsername + ); + + await page.locator('[data-testid="reschedule-link"]').click(); + + await selectSecondAvailableTimeSlotNextMonth(page); + await page.locator('[data-testid="confirm-reschedule-button"]').click(); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + + const rescheduledBookingUrl = await page.url(); + const rescheduledBookingUid = rescheduledBookingUrl.match(/booking\/([^\/?]+)/); + + assertValueExists(rescheduledBookingUid, "rescheduledBookingUid"); + + // Get the rescheduled booking start and end times + const rescheduledBooking = await prisma.booking.findFirst({ + where: { + uid: rescheduledBookingUid[1], + }, + select: { + startTime: true, + endTime: true, + }, + }); + assertValueExists(rescheduledBooking, "rescheduledBooking"); + + // The GCal event UID persists after reschedule but should get the rescheduled data + const gCalRescheduledEventResponse = await authedCalendar.events.get({ + calendarId: "primary", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + eventId: gCalReference.uid, + }); + + expect(gCalRescheduledEventResponse.status).toBe(200); + + const rescheduledGCalEvent = gCalRescheduledEventResponse.data; + + assertValueExists(rescheduledGCalEvent.start?.timeZone, "rescheduledGCalEvent"); + assertValueExists(rescheduledGCalEvent.end?.timeZone, "rescheduledGCalEvent"); + + // Ensure that the new start and end times are matching + const rescheduledStartTimeMatches = dayjs(rescheduledBooking.startTime).isSame( + dayjs(rescheduledGCalEvent.start?.dateTime).tz(rescheduledGCalEvent.start?.timeZone) + ); + const rescheduledEndTimeMatches = dayjs(rescheduledBooking.endTime).isSame( + dayjs(rescheduledGCalEvent.end?.dateTime).tz(rescheduledGCalEvent.end.timeZone) + ); + expect(rescheduledStartTimeMatches && rescheduledEndTimeMatches).toBe(true); + + // After test passes we can delete the bookings and GCal event + await deleteBookingAndEvent(authedCalendar, booking.uid, gCalReference.uid); + + await prisma.booking.delete({ + where: { + uid: rescheduledBookingUid[1], + }, + }); + }); + + test("When canceling the booking, the GCal event should also be deleted", async ({ page }) => { + const { gCalReference, booking, authedCalendar } = await createBookingAndFetchGCalEvent( + page, + qaGCalCredential, + qaUsername + ); + + // Cancel the booking + await page.locator('[data-testid="cancel"]').click(); + await page.locator('[data-testid="confirm_cancel"]').click(); + // Query for the bookingUID and ensure that it doesn't exist on GCal + + await page.waitForSelector('[data-testid="cancelled-headline"]'); + + const canceledGCalEventResponse = await authedCalendar.events.get({ + calendarId: "primary", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + eventId: gCalReference.uid, + }); + + expect(canceledGCalEventResponse.data.status).toBe("cancelled"); + + // GCal API sees canceled events as already deleted + await deleteBookingAndEvent(authedCalendar, booking.uid); + }); + }); +}); diff --git a/packages/app-store/googlecalendar/tests/testUtils.ts b/packages/app-store/googlecalendar/tests/testUtils.ts new file mode 100644 index 0000000000..5d4920d2b1 --- /dev/null +++ b/packages/app-store/googlecalendar/tests/testUtils.ts @@ -0,0 +1,127 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import { bookFirstEvent } from "@calcom/web/playwright/lib/testUtils"; + +import metadata from "../_metadata"; +import GoogleCalendarService from "../lib/CalendarService"; + +/** + * Creates the booking on Cal.com and makes the GCal call to fetch the event. + * Ends on the booking success page + * @param page + * + * @returns the raw GCal event GET response and the booking reference + */ +export const createBookingAndFetchGCalEvent = async ( + page: Page, + qaGCalCredential: Prisma.CredentialGetPayload<{ select: { id: true } }> | null, + qaUsername: string +) => { + await page.goto(`/${qaUsername}`); + await bookFirstEvent(page); + + const bookingUrl = await page.url(); + const bookingUid = bookingUrl.match(/booking\/([^\/?]+)/); + assertValueExists(bookingUid, "bookingUid"); + + const [gCalReference, booking] = await Promise.all([ + prisma.bookingReference.findFirst({ + where: { + booking: { + uid: bookingUid[1], + }, + type: metadata.type, + credentialId: qaGCalCredential?.id, + }, + select: { + uid: true, + booking: {}, + }, + }), + prisma.booking.findFirst({ + where: { + uid: bookingUid[1], + }, + select: { + uid: true, + startTime: true, + endTime: true, + title: true, + attendees: { + select: { + email: true, + }, + }, + user: { + select: { + email: true, + }, + }, + }, + }), + ]); + assertValueExists(gCalReference, "gCalReference"); + assertValueExists(booking, "booking"); + + // Need to refresh keys from DB + const refreshedCredential = await prisma.credential.findFirst({ + where: { + id: qaGCalCredential?.id, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); + + expect(refreshedCredential).toBeTruthy(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + const googleCalendarService = new GoogleCalendarService(refreshedCredential); + + const authedCalendar = await googleCalendarService.authedCalendar(); + + const gCalEventResponse = await authedCalendar.events.get({ + calendarId: "primary", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + eventId: gCalReference.uid, + }); + + expect(gCalEventResponse.status).toBe(200); + + return { gCalEvent: gCalEventResponse.data, gCalReference, booking, authedCalendar }; +}; + +export const deleteBookingAndEvent = async ( + authedCalendar: any, + bookingUid: string, + gCalReferenceUid?: string +) => { + // After test passes we can delete the booking and GCal event + await prisma.booking.delete({ + where: { + uid: bookingUid, + }, + }); + + if (gCalReferenceUid) { + await authedCalendar.events.delete({ + calendarId: "primary", + eventId: gCalReferenceUid, + }); + } +}; + +export function assertValueExists(value: unknown, variableName?: string): asserts value { + if (!value) { + throw new Error(`Value is not defined: ${variableName}`); + } +} diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index 29d981a3a7..78c6861372 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -455,6 +455,22 @@ async function main() { }, }); + await createUserAndEventType({ + user: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL || "qa@example.com", + password: process.env.E2E_TEST_CALCOM_QA_PASSWORD || "qa", + username: "qa", + name: "QA Example", + }, + eventTypes: [ + { + title: "15min", + slug: "15min", + length: 15, + }, + ], + }); + await createTeamAndAddUsers( { name: "Seeded Team", diff --git a/turbo.json b/turbo.json index c07decca73..9f86fed1a6 100644 --- a/turbo.json +++ b/turbo.json @@ -209,6 +209,8 @@ "CALCOM_CREDENTIAL_SYNC_ENDPOINT", "CALCOM_ENV", "CALCOM_LICENSE_KEY", + "CALCOM_QA_EMAIL", + "CALCOM_QA_PASSWORD", "CALCOM_TELEMETRY_DISABLED", "CALCOM_WEBHOOK_HEADER_NAME", "CALENDSO_ENCRYPTION_KEY", @@ -222,6 +224,8 @@ "DEBUG", "E2E_TEST_APPLE_CALENDAR_EMAIL", "E2E_TEST_APPLE_CALENDAR_PASSWORD", + "E2E_TEST_CALCOM_QA_EMAIL", + "E2E_TEST_CALCOM_QA_PASSWORD", "E2E_TEST_MAILHOG_ENABLED", "E2E_TEST_OIDC_CLIENT_ID", "E2E_TEST_OIDC_CLIENT_SECRET",