diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index 592cf462d919bd..62824f4417aa36 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -30,7 +30,7 @@ jobs: with: node-version: 20 cache: 'npm' - - uses: lingodotdev/lingo.dev@main + - uses: lingodotdev/lingo.dev@cee3eb86550ca4905fc99fe90786fb7523288a61 # main env: GH_TOKEN: ${{ steps.generate-token.outputs.token }} with: diff --git a/packages/features/bookings/lib/service/InstantBookingCreateService.test.ts b/packages/features/bookings/lib/service/InstantBookingCreateService.test.ts index ca29d2b1b8a200..80c6d889b1724c 100644 --- a/packages/features/bookings/lib/service/InstantBookingCreateService.test.ts +++ b/packages/features/bookings/lib/service/InstantBookingCreateService.test.ts @@ -1,29 +1,47 @@ import prismock from "@calcom/testing/lib/__mocks__/prisma"; - import { createBookingScenario, - getScenarioData, getGoogleCalendarCredential, - TestData, getOrganizer, - mockSuccessfulVideoMeetingCreation, + getScenarioData, mockCalendarToHaveNoBusySlots, mockNoTranslations, + mockSuccessfulVideoMeetingCreation, + TestData, } from "@calcom/testing/lib/bookingScenario/bookingScenario"; - -import { describe, it, expect, vi, beforeEach } from "vitest"; - import { BookingStatus } from "@calcom/prisma/enums"; - +import { beforeEach, describe, expect, it, vi } from "vitest"; import { getInstantBookingCreateService } from "../../di/InstantBookingCreateService.container"; import type { CreateInstantBookingData } from "../dto/types"; -vi.mock("@calcom/features/notifications/sendNotification", () => ({ - sendNotification: vi.fn(), +// Mock calendar services map to prevent real calendar service modules (feishu, lark, etc.) from being +// imported. Their top-level imports trigger async fetch calls (getAppAccessToken) that cause +// "Closing rpc while fetch was pending" errors when the test worker shuts down. +// This vi.mock must be in the test file itself (not just in bookingScenario.ts) to guarantee +// Vitest hoists it before any transitive imports resolve the real module. +vi.mock("@calcom/app-store/calendar.services.generated", () => ({ + CalendarServiceMap: new Proxy( + {}, + { + get(_target: Record, prop: string) { + if (typeof prop === "symbol") return undefined; + return Promise.resolve({ default: vi.fn() }); + }, + } + ), +})); + +// Mock OrganizationRepository container to prevent a deep transitive import chain +// (InstantBookingCreateService → getBookingFields → workflows/types → routing-forms → +// webhooks DI → eventTypes → OrganizationRepository.container) from triggering a Vitest +// module-resolution RPC that is still in flight when the worker shuts down, causing +// "Closing rpc while fetch was pending" errors. +vi.mock("@calcom/features/ee/organizations/di/OrganizationRepository.container", () => ({ + getOrganizationRepository: vi.fn().mockReturnValue({}), })); -vi.mock("@calcom/app-store/feishucalendar/lib/CalendarService", () => ({ - default: class MockFeishuCalendarService {}, +vi.mock("@calcom/features/notifications/sendNotification", () => ({ + sendNotification: vi.fn(), })); vi.mock("@calcom/features/conferencing/lib/videoClient", () => ({ @@ -257,9 +275,7 @@ describe("handleInstantMeeting", () => { const callArgs = onBookingCreatedSpy.mock.calls[0][0]; expect(callArgs.payload.booking.uid).toBe(result.bookingUid); expect(callArgs.payload.config.isDryRun).toBe(false); - expect(callArgs.actor).toEqual( - expect.objectContaining({ identifiedBy: expect.any(String) }) - ); + expect(callArgs.actor).toEqual(expect.objectContaining({ identifiedBy: expect.any(String) })); expect(callArgs.auditData).toEqual( expect.objectContaining({ startTime: expect.any(Number), diff --git a/packages/testing/src/lib/bookingScenario/bookingScenario.ts b/packages/testing/src/lib/bookingScenario/bookingScenario.ts index 08c105fc735e33..30b946cb72494f 100644 --- a/packages/testing/src/lib/bookingScenario/bookingScenario.ts +++ b/packages/testing/src/lib/bookingScenario/bookingScenario.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { TFunction } from "i18next"; import i18nMock from "../__mocks__/libServerI18n"; import prismock from "../__mocks__/prisma"; +import type { TFunction } from "i18next"; import { v4 as uuidv4 } from "uuid"; import { vi } from "vitest"; import "vitest-fetch-mock"; @@ -51,13 +51,26 @@ import type { getMockRequestDataForBooking } from "./getMockRequestDataForBookin import { getMockPaymentService } from "./MockPaymentService"; type NonNullableVideoApiAdapter = NonNullable; + +// Shared map of mock calendar service constructors accessible to both the vi.mock factory and mockCalendar. +// Using a Proxy-based CalendarServiceMap ensures any calendar service key is automatically mocked, +// preventing real module imports (e.g., feishu/lark) that trigger async fetch calls during worker shutdown. +const calendarServiceConstructorMocks: Record> = {}; + +function getOrCreateCalendarServiceMock(key: string): ReturnType { + if (!calendarServiceConstructorMocks[key]) { + calendarServiceConstructorMocks[key] = vi.fn(); + } + return calendarServiceConstructorMocks[key]; +} + vi.mock("@calcom/app-store/calendar.services.generated", () => ({ - CalendarServiceMap: { - googlecalendar: Promise.resolve({ default: vi.fn() }), - office365calendar: Promise.resolve({ default: vi.fn() }), - applecalendar: Promise.resolve({ default: vi.fn() }), - caldavcalendar: Promise.resolve({ default: vi.fn() }), - }, + CalendarServiceMap: new Proxy({} as Record }>>, { + get(_target, prop: string) { + if (typeof prop === "symbol") return undefined; + return Promise.resolve({ default: getOrCreateCalendarServiceMock(prop) }); + }, + }), })); const mockVideoAdapterRegistry: Record = {}; @@ -1726,7 +1739,7 @@ export function mockNoTranslations() { }); } -export const enum BookingLocations { +export enum BookingLocations { CalVideo = "integrations:daily", ZoomVideo = "integrations:zoom", GoogleMeet = "integrations:google:meet", @@ -1834,162 +1847,159 @@ export async function mockCalendar( const getAvailabilityCalls: GetAvailabilityMethodMockCall[] = []; const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata]; - const { CalendarServiceMap } = await import("@calcom/app-store/calendar.services.generated"); - const calendarServiceKey = appStoreLookupKey as keyof typeof CalendarServiceMap; - - const calendarServicePromise = CalendarServiceMap[calendarServiceKey]; - if (calendarServicePromise) { - const resolvedService = await calendarServicePromise; - vi.mocked(resolvedService.default).mockImplementation(function MockCalendarService(credential) { - return { - createEvent: async (...rest: Parameters): Promise => { - if (calendarData?.creationCrash) { - throw new Error("MockCalendarService.createEvent fake error"); - } - const [calEvent, credentialId, externalCalendarId] = rest; - log.debug( - "mockCalendar.createEvent", - JSON.stringify({ calEvent, credentialId, externalCalendarId }) - ); - createEventCalls.push({ - args: { - calEvent, - credentialId, - externalCalendarId, - }, - calendarServiceConstructorArgs: { - credential, - }, - }); - const isGoogleMeetLocation = calEvent?.location === BookingLocations.GoogleMeet; - if (app.type === "google_calendar") { - return Promise.resolve({ - type: app.type, - additionalInfo: { - hangoutLink: - normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink || - "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", - }, + // Use the shared mock map directly instead of dynamically importing calendar.services.generated. + // This avoids loading real calendar service modules (which can trigger async fetch calls that + // cause "Closing rpc while fetch was pending" errors when the test worker shuts down). + const mockCalendarServiceConstructor = getOrCreateCalendarServiceMock(appStoreLookupKey); + mockCalendarServiceConstructor.mockImplementation(function MockCalendarService(credential) { + return { + createEvent: async (...rest: Parameters): Promise => { + if (calendarData?.creationCrash) { + throw new Error("MockCalendarService.createEvent fake error"); + } + const [calEvent, credentialId, externalCalendarId] = rest; + log.debug( + "mockCalendar.createEvent", + JSON.stringify({ calEvent, credentialId, externalCalendarId }) + ); + createEventCalls.push({ + args: { + calEvent, + credentialId, + externalCalendarId, + }, + calendarServiceConstructorArgs: { + credential, + }, + }); + const isGoogleMeetLocation = calEvent?.location === BookingLocations.GoogleMeet; + if (app.type === "google_calendar") { + return Promise.resolve({ + type: app.type, + additionalInfo: { hangoutLink: normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink || "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", - uid: normalizedCalendarData.create?.uid || "GOOGLE_CALENDAR_EVENT_ID", - id: normalizedCalendarData.create?.id || "GOOGLE_CALENDAR_EVENT_ID", - iCalUID: - normalizedCalendarData.create?.iCalUID || calEvent.iCalUID || "GOOGLE_CALENDAR_EVENT_ID", - password: "MOCK_PASSWORD", - url: - normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink || - "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", - }); - } else if (app.type === "office365_calendar") { - return Promise.resolve({ - type: app.type, - additionalInfo: {}, - uid: normalizedCalendarData.create?.uid || "OFFICE_365_CALENDAR_EVENT_ID", - id: normalizedCalendarData.create?.id || "OFFICE_365_CALENDAR_EVENT_ID", - iCalUID: - normalizedCalendarData.create?.iCalUID || calEvent.iCalUID || "OFFICE_365_CALENDAR_EVENT_ID", - password: "MOCK_PASSWORD", - url: - normalizedCalendarData.create?.appSpecificData?.office365Calendar?.url || - "https://UNUSED_URL", - }); - } else { - return Promise.resolve({ - type: app.type, - additionalInfo: {}, - uid: "PROBABLY_UNUSED_UID", - hangoutLink: - (isGoogleMeetLocation - ? normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink - : null) || "https://UNUSED_URL", - // A Calendar is always expected to return an id. - id: normalizedCalendarData.create?.id || "FALLBACK_MOCK_CALENDAR_EVENT_ID", - iCalUID: normalizedCalendarData.create?.iCalUID, - // Password and URL seems useless for CalendarService, plan to remove them if that's the case - password: "MOCK_PASSWORD", - url: "https://UNUSED_URL", - }); - } - }, - updateEvent: async (...rest: Parameters): Promise => { - if (calendarData?.updationCrash) { - throw new Error("MockCalendarService.updateEvent fake error"); - } - const [uid, event, externalCalendarId] = rest; - log.silly("mockCalendar.updateEvent", JSON.stringify({ uid, event, externalCalendarId })); - updateEventCalls.push({ - args: { - uid, - event, - externalCalendarId, - }, - calendarServiceConstructorArgs: { - credential, }, + hangoutLink: + normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink || + "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", + uid: normalizedCalendarData.create?.uid || "GOOGLE_CALENDAR_EVENT_ID", + id: normalizedCalendarData.create?.id || "GOOGLE_CALENDAR_EVENT_ID", + iCalUID: + normalizedCalendarData.create?.iCalUID || calEvent.iCalUID || "GOOGLE_CALENDAR_EVENT_ID", + password: "MOCK_PASSWORD", + url: + normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink || + "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", }); - const isGoogleMeetLocation = event.location === BookingLocations.GoogleMeet; + } else if (app.type === "office365_calendar") { return Promise.resolve({ type: app.type, additionalInfo: {}, - uid: "PROBABLY_UNUSED_UID", - iCalUID: normalizedCalendarData.update?.iCalUID, - id: normalizedCalendarData.update?.uid || "FALLBACK_MOCK_ID", - // Password and URL seems useless for CalendarService, plan to remove them if that's the case + uid: normalizedCalendarData.create?.uid || "OFFICE_365_CALENDAR_EVENT_ID", + id: normalizedCalendarData.create?.id || "OFFICE_365_CALENDAR_EVENT_ID", + iCalUID: + normalizedCalendarData.create?.iCalUID || calEvent.iCalUID || "OFFICE_365_CALENDAR_EVENT_ID", password: "MOCK_PASSWORD", - url: "https://UNUSED_URL", - location: isGoogleMeetLocation ? "https://UNUSED_URL" : undefined, + url: + normalizedCalendarData.create?.appSpecificData?.office365Calendar?.url || + "https://UNUSED_URL", + }); + } else { + return Promise.resolve({ + type: app.type, + additionalInfo: {}, + uid: "PROBABLY_UNUSED_UID", hangoutLink: (isGoogleMeetLocation - ? normalizedCalendarData.update?.appSpecificData?.googleCalendar?.hangoutLink + ? normalizedCalendarData.create?.appSpecificData?.googleCalendar?.hangoutLink : null) || "https://UNUSED_URL", - conferenceData: isGoogleMeetLocation ? event.conferenceData : undefined, - }); - }, - deleteEvent: async (...rest: Parameters) => { - log.silly("mockCalendar.deleteEvent", JSON.stringify({ rest })); - deleteEventCalls.push({ - args: { - uid: rest[0], - event: rest[1], - externalCalendarId: rest[2], - }, - calendarServiceConstructorArgs: { - credential, - }, - }); - }, - getAvailability: async (params: { - dateFrom: string; - dateTo: string; - selectedCalendars: IntegrationCalendar[]; - mode: "slots" | "overlay" | "booking"; - fallbackToPrimary?: boolean; - }): Promise => { - const { dateFrom, dateTo, selectedCalendars, mode } = params; - if (calendarData?.getAvailabilityCrash) { - throw new Error("MockCalendarService.getAvailability fake error"); - } - getAvailabilityCalls.push({ - args: { - dateFrom, - dateTo, - selectedCalendars, - mode, - }, - calendarServiceConstructorArgs: { - credential, - }, - }); - return new Promise((resolve) => { - resolve(calendarData?.busySlots || []); + // A Calendar is always expected to return an id. + id: normalizedCalendarData.create?.id || "FALLBACK_MOCK_CALENDAR_EVENT_ID", + iCalUID: normalizedCalendarData.create?.iCalUID, + // Password and URL seems useless for CalendarService, plan to remove them if that's the case + password: "MOCK_PASSWORD", + url: "https://UNUSED_URL", }); - }, - listCalendars: async (): Promise => Promise.resolve([]), - } as Calendar; - }); - } + } + }, + updateEvent: async (...rest: Parameters): Promise => { + if (calendarData?.updationCrash) { + throw new Error("MockCalendarService.updateEvent fake error"); + } + const [uid, event, externalCalendarId] = rest; + log.silly("mockCalendar.updateEvent", JSON.stringify({ uid, event, externalCalendarId })); + updateEventCalls.push({ + args: { + uid, + event, + externalCalendarId, + }, + calendarServiceConstructorArgs: { + credential, + }, + }); + const isGoogleMeetLocation = event.location === BookingLocations.GoogleMeet; + return Promise.resolve({ + type: app.type, + additionalInfo: {}, + uid: "PROBABLY_UNUSED_UID", + iCalUID: normalizedCalendarData.update?.iCalUID, + id: normalizedCalendarData.update?.uid || "FALLBACK_MOCK_ID", + // Password and URL seems useless for CalendarService, plan to remove them if that's the case + password: "MOCK_PASSWORD", + url: "https://UNUSED_URL", + location: isGoogleMeetLocation ? "https://UNUSED_URL" : undefined, + hangoutLink: + (isGoogleMeetLocation + ? normalizedCalendarData.update?.appSpecificData?.googleCalendar?.hangoutLink + : null) || "https://UNUSED_URL", + conferenceData: isGoogleMeetLocation ? event.conferenceData : undefined, + }); + }, + deleteEvent: async (...rest: Parameters) => { + log.silly("mockCalendar.deleteEvent", JSON.stringify({ rest })); + deleteEventCalls.push({ + args: { + uid: rest[0], + event: rest[1], + externalCalendarId: rest[2], + }, + calendarServiceConstructorArgs: { + credential, + }, + }); + }, + getAvailability: async (params: { + dateFrom: string; + dateTo: string; + selectedCalendars: IntegrationCalendar[]; + mode: "slots" | "overlay" | "booking"; + fallbackToPrimary?: boolean; + }): Promise => { + const { dateFrom, dateTo, selectedCalendars, mode } = params; + if (calendarData?.getAvailabilityCrash) { + throw new Error("MockCalendarService.getAvailability fake error"); + } + getAvailabilityCalls.push({ + args: { + dateFrom, + dateTo, + selectedCalendars, + mode, + }, + calendarServiceConstructorArgs: { + credential, + }, + }); + return new Promise((resolve) => { + resolve(calendarData?.busySlots || []); + }); + }, + listCalendars: async (): Promise => Promise.resolve([]), + } as Calendar; + }); return { createEventCalls,