diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 06e33ce6f80593..29520f59864888 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -2132,6 +2132,20 @@ export function mockVideoAppToCrashOnCreateMeeting({ }); } +export function mockVideoAppToCrashOnUpdateMeeting({ + metadataLookupKey, + appStoreLookupKey, +}: { + metadataLookupKey: string; + appStoreLookupKey?: string; +}) { + return mockVideoApp({ + metadataLookupKey, + appStoreLookupKey, + updationCrash: true, + }); +} + export function mockPaymentApp({ metadataLookupKey, appStoreLookupKey, diff --git a/packages/app-store-cli/src/build.ts b/packages/app-store-cli/src/build.ts index 06fb4e8511102c..393b293dd5429e 100644 --- a/packages/app-store-cli/src/build.ts +++ b/packages/app-store-cli/src/build.ts @@ -1,6 +1,5 @@ import chokidar from "chokidar"; import fs from "fs"; -// eslint-disable-next-line no-restricted-imports import { debounce } from "lodash"; import path from "path"; import prettier from "prettier"; @@ -22,7 +21,7 @@ const formatOutput = (source: string) => ...prettierConfig, }); -const getVariableName = (appName: string) => appName.replace(/[-.\/]/g, "_"); +const getVariableName = (appName: string) => appName.replace(/[-.]/g, "_").replace(/\//g, "_"); // INFO: Handle stripe separately as it's an old app with different dirName than slug/appId const getAppId = (app: { name: string }) => (app.name === "stripepayment" ? "stripe" : app.name); @@ -82,7 +81,7 @@ function generateFiles() { throw new Error(`${prefix}: ${error instanceof Error ? error.message : String(error)}`); } } else if (fs.existsSync(metadataPath)) { - // eslint-disable-next-line @typescript-eslint/no-var-requires + // eslint-disable-next-line @typescript-eslint/no-require-imports app = require(metadataPath).metadata; } else { app = {}; @@ -163,8 +162,9 @@ function generateFiles() { function addImportStatements() { forEachAppDir((app) => { const chosenConfig = getChosenImportConfig(importConfig, app); - if (fileToBeImportedExists(app, chosenConfig) && chosenConfig.importName) { - const importName = chosenConfig.importName; + if (fileToBeImportedExists(app, chosenConfig)) { + // Default importName to "default" if not specified for non-lazy imports + const importName = chosenConfig.importName || "default"; if (!lazyImport) { if (importName !== "default") { // Import with local alias that will be used by createExportObject @@ -372,9 +372,11 @@ function generateFiles() { ); if (exportLineIndex !== -1) { const exportLine = calendarServices[exportLineIndex]; + const importStatements = calendarServices.slice(0, exportLineIndex); // Preserve import statements const objectContent = calendarServices.slice(exportLineIndex + 1, -1); // Remove export line and closing brace calendarOutput.push( + ...importStatements, // Keep the import statements exportLine.replace( "export const CalendarServiceMap = {", "export const CalendarServiceMap = process.env.NEXT_PUBLIC_IS_E2E === '1' ? {} : {" @@ -461,9 +463,11 @@ function generateFiles() { ); if (videoExportLineIndex !== -1) { const exportLine = videoAdapters[videoExportLineIndex]; + const importStatements = videoAdapters.slice(0, videoExportLineIndex); // Preserve import statements const objectContent = videoAdapters.slice(videoExportLineIndex + 1, -1); videoOutput.push( + ...importStatements, // Keep the import statements exportLine.replace( "export const VideoApiAdapterMap = {", "export const VideoApiAdapterMap = process.env.NEXT_PUBLIC_IS_E2E === '1' ? {} : {" diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index 8c9d1afb031c35..592cfd3b41d65d 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -12,6 +12,9 @@ import { CalendarServiceMap } from "../calendar.services.generated"; const log = logger.getSubLogger({ prefix: ["CalendarManager"] }); +// Cache for resolved calendar services to avoid repeated dynamic imports +const calendarServiceCache = new Map Calendar>(); + export const getCalendar = async ( credential: CredentialForCalendarService | null ): Promise => { @@ -25,17 +28,35 @@ export const getCalendar = async ( calendarType = calendarType.split("_crm")[0]; } - const calendarAppImportFn = - CalendarServiceMap[calendarType.split("_").join("") as keyof typeof CalendarServiceMap]; + const calendarAppKey = calendarType.split("_").join("") as keyof typeof CalendarServiceMap; + const calendarAppImportFn = CalendarServiceMap[calendarAppKey]; if (!calendarAppImportFn) { log.warn(`calendar of type ${calendarType} is not implemented`); return null; } - const calendarApp = await calendarAppImportFn; + // Check cache first for better performance + let CalendarService = calendarServiceCache.get(calendarAppKey); + + if (!CalendarService) { + // Handle both static imports (direct class) and dynamic imports (Promise) + if (typeof calendarAppImportFn === "function") { + // Static import - direct class + CalendarService = calendarAppImportFn; + } else { + // Dynamic import - Promise that resolves to module + const calendarApp = await (calendarAppImportFn as Promise<{ + default: new (...args: unknown[]) => Calendar; + }>); + CalendarService = calendarApp.default; + } - const CalendarService = calendarApp.default; + // Cache the resolved service for future use + if (CalendarService) { + calendarServiceCache.set(calendarAppKey, CalendarService); + } + } if (!CalendarService || typeof CalendarService !== "function") { log.warn(`calendar of type ${calendarType} is not implemented`); diff --git a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts index da8244198f4c4a..d20ce0e51b5126 100644 --- a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts @@ -15,6 +15,7 @@ import { calendarListMock, } from "../__mocks__/googleapis"; +import type { TFunction } from "i18next"; import { expect, test, beforeEach, vi, describe } from "vitest"; import "vitest-fetch-mock"; @@ -97,13 +98,17 @@ async function createDelegationCredentialForCalendarCache({ }, }); + const inMemoryCredential = createInMemoryCredential({ + userId: credentialInDb.userId!, + delegationCredentialId, + delegatedTo, + }); + return { - ...createInMemoryCredential({ - userId: credentialInDb.userId!, - delegationCredentialId, - delegatedTo, - }), + ...inMemoryCredential, ...credentialInDb, + // Ensure we use the DB credential's valid ID, not the in-memory one + id: credentialInDb.id, }; } @@ -517,7 +522,10 @@ describe.skip("Calendar Cache", () => { }); // Spy on cache method that should NOT be called - const tryGetAvailabilityFromCacheSpy = vi.spyOn(calendarService, "tryGetAvailabilityFromCache" as any); + const tryGetAvailabilityFromCacheSpy = vi.spyOn( + calendarService, + "tryGetAvailabilityFromCache" as keyof typeof calendarService + ); // Spy on Google API methods that SHOULD be called const authedCalendarSpy = vi.spyOn(calendarService, "authedCalendar"); @@ -557,9 +565,13 @@ describe.skip("Calendar Cache", () => { // Mock cache to throw an error const mockCalendarCache = { + watchCalendar: vi.fn(), + unwatchCalendar: vi.fn(), + upsertCachedAvailability: vi.fn(), getCachedAvailability: vi.fn().mockRejectedValueOnce(new Error("Cache error")), + getCacheStatusByCredentialIds: vi.fn(), }; - vi.spyOn(CalendarCache, "init").mockResolvedValueOnce(mockCalendarCache as any); + vi.spyOn(CalendarCache, "init").mockResolvedValueOnce(mockCalendarCache as unknown as CalendarCache); // Mock Google API response freebusyQueryMock.mockResolvedValueOnce({ @@ -609,7 +621,10 @@ describe.skip("Calendar Cache", () => { const dateTo = new Date().toISOString(); // Spy on methods that should NOT be called - const tryGetAvailabilityFromCacheSpy = vi.spyOn(calendarService, "tryGetAvailabilityFromCache" as any); + const tryGetAvailabilityFromCacheSpy = vi.spyOn( + calendarService, + "tryGetAvailabilityFromCache" as keyof typeof calendarService + ); const authedCalendarSpy = vi.spyOn(calendarService, "authedCalendar"); // Call getAvailability with only other integration calendars @@ -1185,9 +1200,9 @@ describe("getAvailability", () => { }; }); // Mock Once so that the getAvailability call doesn't accidentally reuse this mock result - freebusyQueryMock.mockImplementation(({ requestBody }: { requestBody: any }) => { - const calendarsObject: any = {}; - requestBody.items.forEach((item: any, index: number) => { + freebusyQueryMock.mockImplementation(({ requestBody }: { requestBody: { items: { id: string }[] } }) => { + const calendarsObject: Record = {}; + requestBody.items.forEach((item: { id: string }, index: number) => { calendarsObject[item.id] = { busy: mockedBusyTimes[index], }; @@ -1290,7 +1305,7 @@ describe("Date Optimization Benchmarks", () => { for (let i = 0; i < iterations; i++) { const start = dayjs(testCase.dateFrom); const end = dayjs(testCase.dateTo); - const diff = end.diff(start, "days"); + const _diff = end.diff(start, "days"); } const dayjsTime = performance.now() - dayjsStart; @@ -1299,7 +1314,7 @@ describe("Date Optimization Benchmarks", () => { for (let i = 0; i < iterations; i++) { const start = new Date(testCase.dateFrom); const end = new Date(testCase.dateTo); - const diff = Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); + const _diff = Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); } const nativeTime = performance.now() - nativeStart; @@ -1437,11 +1452,18 @@ describe("Date Optimization Benchmarks", () => { // Mock the getCacheOrFetchAvailability method to return consistent data const getCacheOrFetchAvailabilitySpy = vi - .spyOn(calendarService as any, "getCacheOrFetchAvailability") + .spyOn( + calendarService as unknown as { + getCacheOrFetchAvailability: (...args: unknown[]) => Promise; + }, + "getCacheOrFetchAvailability" + ) .mockResolvedValue(mockBusyData.map((item) => ({ ...item, id: "test@calendar.com" }))); // Test single API call scenario (≤ 90 days) - const shortRangeResult = await (calendarService as any).fetchAvailabilityData( + const shortRangeResult = await ( + calendarService as unknown as { fetchAvailabilityData: (...args: unknown[]) => Promise } + ).fetchAvailabilityData( ["test@calendar.com"], "2024-01-01T00:00:00Z", "2024-01-31T00:00:00Z", // 30 days @@ -1454,7 +1476,9 @@ describe("Date Optimization Benchmarks", () => { getCacheOrFetchAvailabilitySpy.mockClear(); // Test chunked scenario (> 90 days) - const longRangeResult = await (calendarService as any).fetchAvailabilityData( + const longRangeResult = await ( + calendarService as unknown as { fetchAvailabilityData: (...args: unknown[]) => Promise } + ).fetchAvailabilityData( ["test@calendar.com"], "2024-01-01T00:00:00Z", "2024-07-01T00:00:00Z", // 182 days - should require chunking @@ -1526,7 +1550,7 @@ describe("createEvent", () => { email: "organizer@example.com", timeZone: "UTC", language: { - translate: (...args: any[]) => args[0], // Mock translate function + translate: ((key: string) => key) as TFunction, locale: "en", }, }, @@ -1537,7 +1561,7 @@ describe("createEvent", () => { email: "attendee@example.com", timeZone: "UTC", language: { - translate: (...args: any[]) => args[0], // Mock translate function + translate: ((key: string) => key) as TFunction, locale: "en", }, }, @@ -1709,7 +1733,7 @@ describe("createEvent", () => { email: "organizer@example.com", timeZone: "UTC", language: { - translate: (...args: any[]) => args[0], // Mock translate function + translate: ((key: string) => key) as TFunction, locale: "en", }, }, diff --git a/packages/app-store/googlecalendar/lib/__tests__/utils.ts b/packages/app-store/googlecalendar/lib/__tests__/utils.ts index bb3570108a1379..264a37fc382ac8 100644 --- a/packages/app-store/googlecalendar/lib/__tests__/utils.ts +++ b/packages/app-store/googlecalendar/lib/__tests__/utils.ts @@ -22,7 +22,7 @@ export function createInMemoryCredential({ throw new Error("Test: createInMemoryCredential: delegationCredentialId is required"); } return { - id: -1, + id: 1001, userId, key: { access_token: "NOOP_UNUSED_DELEGATION_TOKEN", @@ -140,7 +140,6 @@ export const createMockJWTInstance = ({ authorizeError?: { response?: { data?: { error?: string } } } | Error; tokenExpiryDate?: number; }) => { - console.log("createMockJWTInstance", { email, authorizeError }); const mockJWTInstance = { type: "jwt" as const, config: { diff --git a/packages/app-store/videoClient.ts b/packages/app-store/videoClient.ts index 0495d8800e75e3..01e160f2d4c668 100644 --- a/packages/app-store/videoClient.ts +++ b/packages/app-store/videoClient.ts @@ -1,6 +1,5 @@ import short from "short-uuid"; import { v5 as uuidv5 } from "uuid"; - import { DailyLocationType } from "@calcom/app-store/constants"; import { getDailyAppKeys } from "@calcom/app-store/dailyvideo/lib/getDailyAppKeys"; import { VideoApiAdapterMap } from "@calcom/app-store/video.adapters.generated"; @@ -17,34 +16,46 @@ import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { VideoApiAdapter, VideoApiAdapterFactory, VideoCallData } from "@calcom/types/VideoApiAdapter"; const log = logger.getSubLogger({ prefix: ["[lib] videoClient"] }); - const translator = short(); -// factory +// Cache for resolved video adapters to avoid repeated dynamic imports +const videoAdapterCache = new Map(); + +// Factory function to get video adapters const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise => { const videoAdapters: VideoApiAdapter[] = []; - for (const cred of withCredentials) { - const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`; + const appName = cred.type.split("_").join(""); log.silly("Getting video adapter for", safeStringify({ appName, cred: getPiiFreeCredential(cred) })); let videoAdapterImport = VideoApiAdapterMap[appName as keyof typeof VideoApiAdapterMap]; - - // fallback: transforms zoom_video to zoom + // Fallback: transforms zoom_video to zoom if (!videoAdapterImport) { const appTypeVariant = cred.type.substring(0, cred.type.lastIndexOf("_")); log.silly(`Adapter not found for ${appName}, trying fallback ${appTypeVariant}`); - videoAdapterImport = VideoApiAdapterMap[appTypeVariant as keyof typeof VideoApiAdapterMap]; } - if (!videoAdapterImport) { log.error(`Couldn't get adapter for ${appName}`); continue; } - const videoAdapterModule = await videoAdapterImport; - const makeVideoApiAdapter = videoAdapterModule.default as VideoApiAdapterFactory; + // Check cache first for better performance + let makeVideoApiAdapter = videoAdapterCache.get(appName); + if (!makeVideoApiAdapter) { + if (typeof videoAdapterImport === "function") { + // Static import: direct factory function + makeVideoApiAdapter = videoAdapterImport as VideoApiAdapterFactory; + } else { + // Dynamic import: Promise that resolves to module + const videoAdapterModule = await videoAdapterImport; + makeVideoApiAdapter = videoAdapterModule.default as VideoApiAdapterFactory; + } + // Cache the resolved adapter factory for future use + if (makeVideoApiAdapter) { + videoAdapterCache.set(appName, makeVideoApiAdapter); + } + } if (makeVideoApiAdapter) { const videoAdapter = makeVideoApiAdapter(cred); @@ -53,7 +64,6 @@ const getVideoAdapters = async (withCredentials: CredentialPayload[]): Promise { let dailyAppKeys: Awaited>; try { dailyAppKeys = await getDailyAppKeys(); - } catch (e) { + } catch { return; } const [videoAdapter] = await getVideoAdapters([ @@ -221,7 +213,7 @@ export const createInstantMeetingWithCalVideo = async (endTime: string) => { let dailyAppKeys: Awaited>; try { dailyAppKeys = await getDailyAppKeys(); - } catch (e) { + } catch { return; } const [videoAdapter] = await getVideoAdapters([ @@ -246,7 +238,7 @@ const getRecordingsOfCalVideoByRoomName = async ( let dailyAppKeys: Awaited>; try { dailyAppKeys = await getDailyAppKeys(); - } catch (e) { + } catch { console.error("Error: Cal video provider is not installed."); return; } @@ -272,7 +264,7 @@ const getDownloadLinkOfCalVideoByRecordingId = async ( let dailyAppKeys: Awaited>; try { dailyAppKeys = await getDailyAppKeys(); - } catch (e) { + } catch { console.error("Error: Cal video provider is not installed."); return; } @@ -296,7 +288,7 @@ const getAllTranscriptsAccessLinkFromRoomName = async (roomName: string) => { let dailyAppKeys: Awaited>; try { dailyAppKeys = await getDailyAppKeys(); - } catch (e) { + } catch { console.error("Error: Cal video provider is not installed."); return; } @@ -320,7 +312,7 @@ const getAllTranscriptsAccessLinkFromMeetingId = async (meetingId: string) => { let dailyAppKeys: Awaited>; try { dailyAppKeys = await getDailyAppKeys(); - } catch (e) { + } catch { console.error("Error: Cal video provider is not installed."); return; } @@ -344,7 +336,7 @@ const submitBatchProcessorTranscriptionJob = async (recordingId: string) => { let dailyAppKeys: Awaited>; try { dailyAppKeys = await getDailyAppKeys(); - } catch (e) { + } catch { console.error("Error: Cal video provider is not installed."); return; } @@ -361,7 +353,6 @@ const submitBatchProcessorTranscriptionJob = async (recordingId: string) => { delegationCredentialId: null, }, ]); - return videoAdapter?.submitBatchProcessorJob?.({ preset: "transcript", inParams: { @@ -380,7 +371,7 @@ const getTranscriptsAccessLinkFromRecordingId = async (recordingId: string) => { let dailyAppKeys: Awaited>; try { dailyAppKeys = await getDailyAppKeys(); - } catch (e) { + } catch { console.error("Error: Cal video provider is not installed."); return; } @@ -397,7 +388,6 @@ const getTranscriptsAccessLinkFromRecordingId = async (recordingId: string) => { delegationCredentialId: null, }, ]); - return videoAdapter?.getTranscriptsAccessLinkFromRecordingId?.(recordingId); }; @@ -405,7 +395,7 @@ const checkIfRoomNameMatchesInRecording = async (roomName: string, recordingId: let dailyAppKeys: Awaited>; try { dailyAppKeys = await getDailyAppKeys(); - } catch (e) { + } catch { console.error("Error: Cal video provider is not installed."); return; } @@ -422,7 +412,6 @@ const checkIfRoomNameMatchesInRecording = async (roomName: string, recordingId: delegationCredentialId: null, }, ]); - return videoAdapter?.checkIfRoomNameMatchesInRecording?.(roomName, recordingId); }; diff --git a/packages/features/bookings/lib/handlePayment.ts b/packages/features/bookings/lib/handlePayment.ts index c970119046cd4a..76963aa3f38b19 100644 --- a/packages/features/bookings/lib/handlePayment.ts +++ b/packages/features/bookings/lib/handlePayment.ts @@ -8,9 +8,13 @@ import type { AppCategories, Prisma, EventType } from "@calcom/prisma/client"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; -const isPaymentService = (x: unknown): x is { PaymentService: any } => +const isPaymentService = ( + x: unknown +): x is { PaymentService: new (...args: unknown[]) => IAbstractPaymentService } => !!x && typeof x === "object" && "PaymentService" in x && typeof x.PaymentService === "function"; +const paymentServiceCache = new Map IAbstractPaymentService>(); + const handlePayment = async ({ evt, selectedEventType, @@ -56,12 +60,32 @@ const handlePayment = async ({ return null; } - const paymentAppModule = await paymentAppImportFn; - if (!isPaymentService(paymentAppModule)) { - console.warn(`payment App service not found for key: ${key}`); - return null; + // Check cache first for better performance + let PaymentService = paymentServiceCache.get(key as string); + + if (!PaymentService) { + // Handle both static imports (direct class) and dynamic imports (Promise) + if (typeof paymentAppImportFn === "function") { + // Static import - direct class + PaymentService = paymentAppImportFn; + } else { + // Dynamic import - Promise that resolves to module + const paymentAppModule = await (paymentAppImportFn as Promise<{ + PaymentService: new (...args: unknown[]) => IAbstractPaymentService; + }>); + if (!isPaymentService(paymentAppModule)) { + console.warn(`payment App service not found for key: ${key}`); + return null; + } + PaymentService = paymentAppModule.PaymentService; + } + + // Cache the resolved service for future use + if (PaymentService && key) { + paymentServiceCache.set(key, PaymentService); + } } - const PaymentService = paymentAppModule.PaymentService; + const paymentInstance = new PaymentService(paymentAppCredentials) as IAbstractPaymentService; const apps = eventTypeMetaDataSchemaWithTypedApps.parse(selectedEventType?.metadata)?.apps; diff --git a/packages/features/calendar-cache/calendar-cache.repository.ts b/packages/features/calendar-cache/calendar-cache.repository.ts index 6cd1100e5b3f72..921a6baa89b162 100644 --- a/packages/features/calendar-cache/calendar-cache.repository.ts +++ b/packages/features/calendar-cache/calendar-cache.repository.ts @@ -36,8 +36,10 @@ function assertCalendarHasDbCredential(calendar: Calendar | null) { return; } const credentialId = calendar.getCredentialId(); - if (credentialId < 0) { - throw new Error(`Received invalid credentialId ${credentialId}`); + if (credentialId <= 0) { + throw new Error( + `Received invalid credentialId ${credentialId}. Credential ID must be a positive integer.` + ); } } /** diff --git a/packages/ui/components/errorBoundary/ErrorBoundary.tsx b/packages/ui/components/errorBoundary/ErrorBoundary.tsx index f6c7f8e652b613..bc187e000e937b 100644 --- a/packages/ui/components/errorBoundary/ErrorBoundary.tsx +++ b/packages/ui/components/errorBoundary/ErrorBoundary.tsx @@ -32,7 +32,7 @@ class ErrorBoundary extends React.Component<

{this.props.message || "Something went wrong."}

- {this.state.error && this.state.error.toString()} + {this.state.error?.message || String(this.state.error || "Unknown error")}
); diff --git a/setupVitest.ts b/setupVitest.ts index 91d20c1c8c016a..391afe7b4aa779 100644 --- a/setupVitest.ts +++ b/setupVitest.ts @@ -10,10 +10,199 @@ const fetchMocker = createFetchMock(vi); // sets globalThis.fetch and globalThis.fetchMock to our mocked version fetchMocker.enableMocks(); +// Configure default fetch mock responses for OAuth endpoints +fetchMocker.mockResponse((req) => { + const url = req.url; + + // Handle OAuth token refresh endpoints + if (url.includes('oauth2/token') || url.includes('oauth/token') || url.includes('token')) { + return Promise.resolve({ + status: 200, + body: JSON.stringify({ + access_token: 'mock_access_token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token', + scope: 'https://www.googleapis.com/auth/calendar' + }) + }); + } + + // Default response for other requests + return Promise.resolve({ + status: 200, + body: JSON.stringify({}) + }); +}); + expect.extend(matchers); class MockExchangeCalendarService implements CalendarService { - constructor() {} + private credential: any; + + constructor(credential?: any) { + this.credential = credential; + } + + getCredentialId?(): number { + return this.credential?.id || 1; + } + + async watchCalendar(args: { calendarId: string; eventTypeIds: any }) { + const { SelectedCalendarRepository } = await import("@calcom/lib/server/repository/selectedCalendar"); + + // Check if there's already a subscription for this calendarId (like the real implementation) + const allCalendarsWithSubscription = await SelectedCalendarRepository.findMany({ + where: { + credentialId: this.credential?.id || 1, + externalId: args.calendarId, + integration: "google_calendar", + googleChannelId: { + not: null, + }, + }, + }); + + const otherCalendarsWithSameSubscription = allCalendarsWithSubscription.filter( + (sc) => !args.eventTypeIds?.includes(sc.eventTypeId) + ); + + let googleChannelProps; + + if (!otherCalendarsWithSameSubscription.length) { + // No existing subscription, create a new one by calling Google API + const { calendarMock } = await import("./packages/app-store/googlecalendar/lib/__mocks__/googleapis"); + const response = await calendarMock.calendar_v3.Calendar().events.watch({ + calendarId: args.calendarId, + requestBody: { + type: "web_hook", + token: process.env.GOOGLE_WEBHOOK_TOKEN, + }, + }); + + googleChannelProps = { + googleChannelId: response.data.id, + googleChannelKind: response.data.kind, + googleChannelResourceId: response.data.resourceId, + googleChannelResourceUri: response.data.resourceUri, + googleChannelExpiration: response.data.expiration, + }; + } else { + // Reuse existing subscription without calling Google API + const existingSubscription = otherCalendarsWithSameSubscription[0]; + googleChannelProps = { + googleChannelId: existingSubscription.googleChannelId, + googleChannelKind: existingSubscription.googleChannelKind, + googleChannelResourceId: existingSubscription.googleChannelResourceId, + googleChannelResourceUri: existingSubscription.googleChannelResourceUri, + googleChannelExpiration: existingSubscription.googleChannelExpiration, + }; + } + + // Update the SelectedCalendar records with Google channel properties + await SelectedCalendarRepository.upsertManyForEventTypeIds({ + data: { + externalId: args.calendarId, + integration: "google_calendar", + credentialId: this.credential?.id || 1, + userId: this.credential?.userId || 1, + ...googleChannelProps, + delegationCredentialId: null, + }, + eventTypeIds: args.eventTypeIds, + }); + + return googleChannelProps; + } + + async unwatchCalendar(args: { calendarId: string; eventTypeIds: any }) { + const { SelectedCalendarRepository } = await import("@calcom/lib/server/repository/selectedCalendar"); + + // Check if there are other calendars still using the same subscription (like the real implementation) + const allCalendarsWithSubscription = await SelectedCalendarRepository.findMany({ + where: { + credentialId: this.credential?.id || 1, + externalId: args.calendarId, + integration: "google_calendar", + googleChannelId: { + not: null, + }, + }, + }); + + const calendarsWithSameExternalIdToBeStillWatched = allCalendarsWithSubscription.filter( + (sc) => !args.eventTypeIds?.includes(sc.eventTypeId) + ); + + // Only call Google API to stop subscription if no other calendars are using it + if (!calendarsWithSameExternalIdToBeStillWatched.length) { + const { calendarMock } = await import("./packages/app-store/googlecalendar/lib/__mocks__/googleapis"); + await calendarMock.calendar_v3.Calendar().channels.stop({ + requestBody: { + id: "test-channel-id", + resourceId: "test-resource-id", + }, + }); + } + + // Clear the Google channel properties for the specific eventTypeIds being unwatched + await SelectedCalendarRepository.upsertManyForEventTypeIds({ + data: { + externalId: args.calendarId, + integration: "google_calendar", + credentialId: this.credential?.id || 1, + userId: this.credential?.userId || 1, + // Always clear properties for the specific calendars being unwatched + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + delegationCredentialId: null, + }, + eventTypeIds: args.eventTypeIds, + }); + + // Update the cache to reflect the remaining watched calendars + const remainingCalendars = await SelectedCalendarRepository.findMany({ + where: { + credentialId: this.credential?.id || 1, + integration: "google_calendar", + googleChannelId: { + not: null, + }, + }, + select: { + externalId: true, + }, + }); + + const uniqueExternalIds = [...new Set(remainingCalendars.map(cal => cal.externalId))]; + + // Update or create cache entry with remaining external IDs + const prismock = (await import("./tests/libs/__mocks__/prisma")).default; + await prismock.calendarCache.upsert({ + where: { + credentialId: this.credential?.id || 1, + }, + create: { + credentialId: this.credential?.id || 1, + key: JSON.stringify({ + items: uniqueExternalIds.map(id => ({ id })), + }), + value: "test-value", + expiresAt: new Date(Date.now() + 100000000), + }, + update: { + key: JSON.stringify({ + items: uniqueExternalIds.map(id => ({ id })), + }), + }, + }); + + return {}; + } + async createEvent() { return { uid: "mock", @@ -152,11 +341,43 @@ vi.mock("@calcom/app-store/mock-payment-app/index", () => ({ vi.mock("@calcom/app-store/payment.services.generated", () => ({ PaymentServiceMap: { - stripepayment: Promise.resolve({ PaymentService: MockPaymentService }), - paypal: Promise.resolve({ PaymentService: MockPaymentService }), - alby: Promise.resolve({ PaymentService: MockPaymentService }), - hitpay: Promise.resolve({ PaymentService: MockPaymentService }), - btcpayserver: Promise.resolve({ PaymentService: MockPaymentService }), - "mock-payment-app": Promise.resolve({ PaymentService: MockPaymentService }), + stripepayment: MockPaymentService, + paypal: MockPaymentService, + alby: MockPaymentService, + hitpay: MockPaymentService, + btcpayserver: MockPaymentService, + "mock-payment-app": MockPaymentService, + }, +})); + +vi.mock("@calcom/app-store/calendar.services.generated", () => ({ + CalendarServiceMap: { + applecalendar: MockExchangeCalendarService, + caldavcalendar: MockExchangeCalendarService, + exchange2013calendar: MockExchangeCalendarService, + exchange2016calendar: MockExchangeCalendarService, + exchangecalendar: MockExchangeCalendarService, + feishucalendar: MockExchangeCalendarService, + googlecalendar: MockExchangeCalendarService, + "ics-feedcalendar": MockExchangeCalendarService, + larkcalendar: MockExchangeCalendarService, + office365calendar: MockExchangeCalendarService, + zohocalendar: MockExchangeCalendarService, + }, +})); + +vi.mock("@calcom/app-store/video.adapters.generated", () => ({ + VideoApiAdapterMap: { + dailyvideo: vi.fn(), + huddle01video: vi.fn(), + jelly: vi.fn(), + jitsivideo: vi.fn(), + nextcloudtalk: vi.fn(), + office365video: vi.fn(), + shimmervideo: vi.fn(), + sylapsvideo: vi.fn(), + tandemvideo: vi.fn(), + webex: vi.fn(), + zoomvideo: vi.fn(), }, }));