From 5b1eb48e6b7a3deb188f55ef4e394cb0add51232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omar=20L=C3=B3pez?= Date: Thu, 3 Jul 2025 15:53:56 -0700 Subject: [PATCH] perf: optimize google's getAvailability with caching (#22191) * refactor: optimize google's getAvailability with caching Refactored the getAvailability method to improve readability and efficiency by extracting logic into helper methods for cache checking, calendar ID retrieval, and chunked API fetching. Added early cache lookup for selected calendar IDs, improved cache hit logging, and modularized the code for easier maintenance and future enhancements. * Update CalendarService.test.ts * Update CalendarService.ts * Update CalendarService.test.ts * Update CalendarService.ts * Update CalendarService.test.ts * Update CalendarService.ts * Apply suggestion from @hbjORbj Co-authored-by: Benny Joo --------- Co-authored-by: Benny Joo --- .../googlecalendar/lib/CalendarService.ts | 232 +++-- .../lib/__tests__/CalendarService.test.ts | 855 ++++++++++++++++++ 2 files changed, 1020 insertions(+), 67 deletions(-) diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 6c445e2a9c..3f32fbb4f3 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -1,12 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { calendar_v3 } from "@googleapis/calendar"; -import type { Prisma } from "@prisma/client"; import type { GaxiosResponse } from "googleapis-common"; import { RRule } from "rrule"; import { v4 as uuid } from "uuid"; import { MeetLocationType } from "@calcom/app-store/locations"; -import dayjs from "@calcom/dayjs"; import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface"; import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache"; @@ -16,6 +14,7 @@ import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; import prisma from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; import type { Calendar, CalendarServiceEvent, @@ -232,14 +231,13 @@ export default class GoogleCalendarService implements Calendar { eventId: calEvent.existingRecurringEvent.recurringEventId, }); if (recurringEventInstances.data.items) { - const calComEventStartTime = dayjs(calEvent.startTime).tz(calEvent.organizer.timeZone).format(); + // Compare timestamps directly for more reliable and faster matching + const calComEventStartTimeMs = new Date(calEvent.startTime).getTime(); for (let i = 0; i < recurringEventInstances.data.items.length; i++) { const instance = recurringEventInstances.data.items[i]; - const instanceStartTime = dayjs(instance.start?.dateTime) - .tz(instance.start?.timeZone == null ? undefined : instance.start?.timeZone) - .format(); + const instanceStartTimeMs = new Date(instance.start?.dateTime || "").getTime(); - if (instanceStartTime === calComEventStartTime) { + if (instanceStartTimeMs === calComEventStartTimeMs) { event = instance; break; } @@ -562,10 +560,6 @@ export default class GoogleCalendarService implements Calendar { try { const calIdsWithTimeZone = await getCalIdsWithTimeZone(); const calIds = calIdsWithTimeZone.map((calIdWithTimeZone) => ({ id: calIdWithTimeZone.id })); - - const originalStartDate = dayjs(dateFrom); - const originalEndDate = dayjs(dateTo); - const diff = originalEndDate.diff(originalStartDate, "days"); const freeBusyData = await this.getCacheOrFetchAvailability({ timeMin: dateFrom, timeMax: dateTo, @@ -593,6 +587,148 @@ export default class GoogleCalendarService implements Calendar { } } + /** + * Converts FreeBusy response data to EventBusyDate array + */ + private convertFreeBusyToEventBusyDates( + freeBusyResult: calendar_v3.Schema$FreeBusyResponse + ): EventBusyDate[] { + if (!freeBusyResult.calendars) return []; + + return Object.values(freeBusyResult.calendars).flatMap( + (calendar) => + calendar.busy?.map((busyTime) => ({ + start: busyTime.start || "", + end: busyTime.end || "", + })) || [] + ); + } + + /** + * Attempts to get availability from cache + */ + private async tryGetAvailabilityFromCache( + timeMin: string, + timeMax: string, + calendarIds: string[] + ): Promise { + try { + const calendarCache = await CalendarCache.init(null); + const cached = await calendarCache.getCachedAvailability({ + credentialId: this.credential.id, + userId: this.credential.userId, + args: { + // Expand the start date to the start of the month to increase cache hits + timeMin: getTimeMin(timeMin), + // Expand the end date to the end of the month to increase cache hits + timeMax: getTimeMax(timeMax), + items: calendarIds.map((id) => ({ id })), + }, + }); + + if (cached) { + this.log.debug( + "[Cache Hit] Returning cached availability result", + safeStringify({ timeMin, timeMax, calendarIds }) + ); + const freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse; + return this.convertFreeBusyToEventBusyDates(freeBusyResult); + } + + return null; + } catch (error) { + this.log.debug("Cache check failed, proceeding with API call", safeStringify(error)); + return null; + } + } + + /** + * Gets calendar IDs for the request, either from selected calendars or fallback logic + */ + private async getCalendarIds( + selectedCalendarIds: string[], + fallbackToPrimary?: boolean + ): Promise { + if (selectedCalendarIds.length !== 0) return selectedCalendarIds; + + const calendar = await this.authedCalendar(); + const cals = await this.getAllCalendars(calendar, ["id", "primary"]); + if (!cals.length) return []; + + if (!fallbackToPrimary) { + return this.getValidCalendars(cals).map((cal) => cal.id); + } + + const primaryCalendar = this.filterPrimaryCalendar(cals); + return primaryCalendar ? [primaryCalendar.id] : []; + } + + /** + * Fetches availability data using the cache-or-fetch pattern + */ + private async fetchAvailabilityData( + calendarIds: string[], + dateFrom: string, + dateTo: string, + shouldServeCache?: boolean + ): Promise { + // More efficient date difference calculation using native Date objects + // Use Math.floor to match dayjs diff behavior (truncates, doesn't round up) + const fromDate = new Date(dateFrom); + const toDate = new Date(dateTo); + const oneDayMs = 1000 * 60 * 60 * 24; + const diff = Math.floor((toDate.getTime() - fromDate.getTime()) / (oneDayMs)); + + // Google API only allows a date range of 90 days for /freebusy + if (diff <= 90) { + const freeBusyData = await this.getCacheOrFetchAvailability( + { + timeMin: dateFrom, + timeMax: dateTo, + items: calendarIds.map((id) => ({ id })), + }, + shouldServeCache + ); + + if (!freeBusyData) throw new Error("No response from google calendar"); + return freeBusyData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end })); + } + + // Handle longer periods by chunking into 90-day periods + const busyData: EventBusyDate[] = []; + const loopsNumber = Math.ceil(diff / 90); + let currentStartTime = fromDate.getTime(); + const originalEndTime = toDate.getTime(); + const ninetyDaysMs = 90 * 24 * 60 * 60 * 1000; + const oneMinuteMs = 60 * 1000; + + for (let i = 0; i < loopsNumber; i++) { + let currentEndTime = currentStartTime + ninetyDaysMs; + + // Don't go beyond the original end date + if (currentEndTime > originalEndTime) { + currentEndTime = originalEndTime; + } + + const chunkData = await this.getCacheOrFetchAvailability( + { + timeMin: new Date(currentStartTime).toISOString(), + timeMax: new Date(currentEndTime).toISOString(), + items: calendarIds.map((id) => ({ id })), + }, + shouldServeCache + ); + + if (chunkData) { + busyData.push(...chunkData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end }))); + } + + currentStartTime = currentEndTime + oneMinuteMs; + } + + return busyData; + } + async getAvailability( dateFrom: string, dateTo: string, @@ -604,71 +740,33 @@ export default class GoogleCalendarService implements Calendar { fallbackToPrimary?: boolean ): Promise { this.log.debug("Getting availability", safeStringify({ dateFrom, dateTo, selectedCalendars })); - const calendar = await this.authedCalendar(); + const selectedCalendarIds = selectedCalendars .filter((e) => e.integration === this.integrationName) .map((e) => e.externalId); + + // Early return if only other integrations are selected if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { - // Only calendars of other integrations selected return []; } - const getCalIds = async () => { - if (selectedCalendarIds.length !== 0) return selectedCalendarIds; - const cals = await this.getAllCalendars(calendar, ["id", "primary"]); - if (!cals.length) return []; - if (!fallbackToPrimary) return this.getValidCalendars(cals).map((cal) => cal.id); - const primaryCalendar = this.filterPrimaryCalendar(cals); - if (!primaryCalendar) return []; - return [primaryCalendar.id]; - }; + // Try cache first when we have selected calendar IDs + if (selectedCalendarIds.length > 0 && shouldServeCache !== false) { + const cachedResult = await this.tryGetAvailabilityFromCache(dateFrom, dateTo, selectedCalendarIds); + if (cachedResult) { + return cachedResult; + } + } + + // Cache miss - proceed with API calls + this.log.debug( + "[Cache Miss] Proceeding with Google API calls", + safeStringify({ selectedCalendarIds, fallbackToPrimary }) + ); try { - const calsIds = await getCalIds(); - const originalStartDate = dayjs(dateFrom); - const originalEndDate = dayjs(dateTo); - const diff = originalEndDate.diff(originalStartDate, "days"); - - // /freebusy from google api only allows a date range of 90 days - if (diff <= 90) { - const freeBusyData = await this.getCacheOrFetchAvailability( - { - timeMin: dateFrom, - timeMax: dateTo, - items: calsIds.map((id) => ({ id })), - }, - shouldServeCache - ); - if (!freeBusyData) throw new Error("No response from google calendar"); - - return freeBusyData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end })); - } else { - const busyData = []; - - const loopsNumber = Math.ceil(diff / 90); - - let startDate = originalStartDate; - let endDate = originalStartDate.add(90, "days"); - - for (let i = 0; i < loopsNumber; i++) { - if (endDate.isAfter(originalEndDate)) endDate = originalEndDate; - - busyData.push( - ...((await this.getCacheOrFetchAvailability( - { - timeMin: startDate.format(), - timeMax: endDate.format(), - items: calsIds.map((id) => ({ id })), - }, - shouldServeCache - )) || []) - ); - - startDate = endDate.add(1, "minutes"); - endDate = startDate.add(90, "days"); - } - return busyData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end })); - } + const calendarIds = await this.getCalendarIds(selectedCalendarIds, fallbackToPrimary); + return await this.fetchAvailabilityData(calendarIds, dateFrom, dateTo, shouldServeCache); } catch (error) { this.log.error( "There was an error getting availability from google calendar: ", diff --git a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts index 5b33c09e52..08851e4ff6 100644 --- a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts @@ -302,6 +302,340 @@ describe("Calendar Cache", () => { ]); }); + test("Cache HIT: Should avoid Google API calls when cache is available", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credentialInDb); + + const dateFrom = new Date().toISOString(); + const dateTo = new Date().toISOString(); + + // Set up cache with test data + const calendarCache = await CalendarCache.init(null); + await calendarCache.upsertCachedAvailability({ + credentialId: credentialInDb.id, + userId: credentialInDb.userId, + args: { + timeMin: getTimeMin(dateFrom), + timeMax: getTimeMax(dateTo), + items: [{ id: testSelectedCalendar.externalId }], + }, + value: { + calendars: { + [testSelectedCalendar.externalId]: { + busy: [ + { + start: "2023-12-01T18:00:00Z", + end: "2023-12-01T19:00:00Z", + }, + ], + }, + }, + }, + }); + + // Spy on Google API methods that should NOT be called on cache hit + const authedCalendarSpy = vi.spyOn(calendarService, "authedCalendar"); + const getAllCalendarsSpy = vi.spyOn(calendarService, "getAllCalendars"); + const fetchAvailabilitySpy = vi.spyOn(calendarService, "fetchAvailability"); + + // Call getAvailability with selected calendars (should hit cache) + const result = await calendarService.getAvailability(dateFrom, dateTo, [testSelectedCalendar], true); + + // Verify cache hit returned correct data + expect(result).toEqual([ + { + start: "2023-12-01T18:00:00Z", + end: "2023-12-01T19:00:00Z", + }, + ]); + + // Verify NO Google API calls were made + expect(authedCalendarSpy).not.toHaveBeenCalled(); + expect(getAllCalendarsSpy).not.toHaveBeenCalled(); + expect(fetchAvailabilitySpy).not.toHaveBeenCalled(); + + // Clean up spies + authedCalendarSpy.mockRestore(); + getAllCalendarsSpy.mockRestore(); + fetchAvailabilitySpy.mockRestore(); + }); + + test("Cache MISS: Should make Google API calls when cache is not available", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credentialInDb); + setFullMockOAuthManagerRequest(); + + const dateFrom = new Date().toISOString(); + const dateTo = new Date(Date.now() + 100000000).toISOString(); // Different date to ensure cache miss + + // Mock Google API responses + freebusyQueryMock.mockResolvedValueOnce({ + data: { + calendars: { + [testSelectedCalendar.externalId]: { + busy: [ + { + start: "2023-12-01T10:00:00Z", + end: "2023-12-01T11:00:00Z", + }, + ], + }, + }, + }, + }); + + // Spy on Google API methods that SHOULD be called on cache miss + const authedCalendarSpy = vi.spyOn(calendarService, "authedCalendar"); + const fetchAvailabilitySpy = vi.spyOn(calendarService, "fetchAvailability"); + + // Call getAvailability with selected calendars (should miss cache) + const result = await calendarService.getAvailability(dateFrom, dateTo, [testSelectedCalendar], true); + + // Verify API call returned correct data + expect(result).toEqual([ + { + start: "2023-12-01T10:00:00Z", + end: "2023-12-01T11:00:00Z", + }, + ]); + + // Verify Google API calls WERE made + expect(authedCalendarSpy).toHaveBeenCalled(); + expect(fetchAvailabilitySpy).toHaveBeenCalled(); + + // Clean up spies + authedCalendarSpy.mockRestore(); + fetchAvailabilitySpy.mockRestore(); + }); + + test("Cache DISABLED: Should bypass cache when shouldServeCache=false", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credentialInDb); + setFullMockOAuthManagerRequest(); + + const dateFrom = new Date().toISOString(); + const dateTo = new Date().toISOString(); + + // Set up cache with test data + const calendarCache = await CalendarCache.init(null); + await calendarCache.upsertCachedAvailability({ + credentialId: credentialInDb.id, + userId: credentialInDb.userId, + args: { + timeMin: getTimeMin(dateFrom), + timeMax: getTimeMax(dateTo), + items: [{ id: testSelectedCalendar.externalId }], + }, + value: { + calendars: { + [testSelectedCalendar.externalId]: { + busy: [ + { + start: "2023-12-01T18:00:00Z", + end: "2023-12-01T19:00:00Z", + }, + ], + }, + }, + }, + }); + + // Mock Google API to return different data than cache + freebusyQueryMock.mockResolvedValueOnce({ + data: { + calendars: { + [testSelectedCalendar.externalId]: { + busy: [ + { + start: "2023-12-01T20:00:00Z", + end: "2023-12-01T21:00:00Z", + }, + ], + }, + }, + }, + }); + + // Spy on Google API methods + const authedCalendarSpy = vi.spyOn(calendarService, "authedCalendar"); + const fetchAvailabilitySpy = vi.spyOn(calendarService, "fetchAvailability"); + + // Call getAvailability with shouldServeCache=false (should bypass cache) + const result = await calendarService.getAvailability(dateFrom, dateTo, [testSelectedCalendar], false); + + // Verify API data was returned (not cache data) + expect(result).toEqual([ + { + start: "2023-12-01T20:00:00Z", + end: "2023-12-01T21:00:00Z", + }, + ]); + + // Verify Google API calls WERE made even though cache existed + expect(authedCalendarSpy).toHaveBeenCalled(); + expect(fetchAvailabilitySpy).toHaveBeenCalled(); + + // Clean up spies + authedCalendarSpy.mockRestore(); + fetchAvailabilitySpy.mockRestore(); + }); + + test("NO SELECTED CALENDARS: Should skip cache logic when no selectedCalendarIds", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credentialInDb); + setFullMockOAuthManagerRequest(); + + const dateFrom = new Date().toISOString(); + const dateTo = new Date().toISOString(); + + // Mock Google API response for fallback scenario + calendarListMock.mockResolvedValueOnce({ + data: { + items: [ + { + id: "primary@example.com", + primary: true, + }, + ], + }, + }); + + freebusyQueryMock.mockResolvedValueOnce({ + data: { + calendars: { + "primary@example.com": { + busy: [ + { + start: "2023-12-01T12:00:00Z", + end: "2023-12-01T13:00:00Z", + }, + ], + }, + }, + }, + }); + + // Spy on cache method that should NOT be called + const tryGetAvailabilityFromCacheSpy = vi.spyOn(calendarService, "tryGetAvailabilityFromCache" as any); + + // Spy on Google API methods that SHOULD be called + const authedCalendarSpy = vi.spyOn(calendarService, "authedCalendar"); + const getAllCalendarsSpy = vi.spyOn(calendarService, "getAllCalendars"); + + // Call getAvailability with empty selectedCalendars but fallbackToPrimary=true + const result = await calendarService.getAvailability(dateFrom, dateTo, [], true, true); + + // Verify fallback logic worked + expect(result).toEqual([ + { + start: "2023-12-01T12:00:00Z", + end: "2023-12-01T13:00:00Z", + }, + ]); + + // Verify cache was NOT checked + expect(tryGetAvailabilityFromCacheSpy).not.toHaveBeenCalled(); + + // Verify Google API calls WERE made for fallback logic + expect(authedCalendarSpy).toHaveBeenCalled(); + expect(getAllCalendarsSpy).toHaveBeenCalled(); + + // Clean up spies + tryGetAvailabilityFromCacheSpy.mockRestore(); + authedCalendarSpy.mockRestore(); + getAllCalendarsSpy.mockRestore(); + }); + + test("CACHE ERROR: Should handle cache errors gracefully and fall back to API", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credentialInDb); + setFullMockOAuthManagerRequest(); + + const dateFrom = new Date().toISOString(); + const dateTo = new Date().toISOString(); + + // Mock cache to throw an error + const mockCalendarCache = { + getCachedAvailability: vi.fn().mockRejectedValueOnce(new Error("Cache error")), + }; + vi.spyOn(CalendarCache, "init").mockResolvedValueOnce(mockCalendarCache as any); + + // Mock Google API response + freebusyQueryMock.mockResolvedValueOnce({ + data: { + calendars: { + [testSelectedCalendar.externalId]: { + busy: [ + { + start: "2023-12-01T14:00:00Z", + end: "2023-12-01T15:00:00Z", + }, + ], + }, + }, + }, + }); + + // Spy on Google API methods that SHOULD be called on cache error + const authedCalendarSpy = vi.spyOn(calendarService, "authedCalendar"); + const fetchAvailabilitySpy = vi.spyOn(calendarService, "fetchAvailability"); + + // Call getAvailability (cache should fail, API should be called) + const result = await calendarService.getAvailability(dateFrom, dateTo, [testSelectedCalendar], true); + + // Verify API fallback worked + expect(result).toEqual([ + { + start: "2023-12-01T14:00:00Z", + end: "2023-12-01T15:00:00Z", + }, + ]); + + // Verify Google API calls WERE made due to cache error + expect(authedCalendarSpy).toHaveBeenCalled(); + expect(fetchAvailabilitySpy).toHaveBeenCalled(); + + // Clean up spies + authedCalendarSpy.mockRestore(); + fetchAvailabilitySpy.mockRestore(); + }); + + test("OTHER INTEGRATIONS ONLY: Should return empty array without cache or API calls", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credentialInDb); + + const dateFrom = new Date().toISOString(); + const dateTo = new Date().toISOString(); + + // Spy on methods that should NOT be called + const tryGetAvailabilityFromCacheSpy = vi.spyOn(calendarService, "tryGetAvailabilityFromCache" as any); + const authedCalendarSpy = vi.spyOn(calendarService, "authedCalendar"); + + // Call getAvailability with only other integration calendars + const result = await calendarService.getAvailability( + dateFrom, + dateTo, + [ + { + integration: "outlook_calendar", // Different integration + externalId: "other@example.com", + }, + ], + true + ); + + // Verify early return with empty array + expect(result).toEqual([]); + + // Verify NO cache or API calls were made + expect(tryGetAvailabilityFromCacheSpy).not.toHaveBeenCalled(); + expect(authedCalendarSpy).not.toHaveBeenCalled(); + + // Clean up spies + tryGetAvailabilityFromCacheSpy.mockRestore(); + authedCalendarSpy.mockRestore(); + }); + test("Calendar Cache is being ignored on cache MISS", async () => { const calendarCache = await CalendarCache.init(null); const credentialInDb = await createCredentialForCalendarService(); @@ -909,3 +1243,524 @@ describe("getPrimaryCalendar", () => { expect(result).toEqual(mockPrimaryCalendar); }); }); + +describe("Date Optimization Benchmarks", () => { + test("native Date calculations should be significantly faster than dayjs while producing identical results", async () => { + const dayjs = (await import("@calcom/dayjs")).default; + + const testCases = [ + { + dateFrom: "2024-01-01T00:00:00Z", + dateTo: "2024-01-31T00:00:00Z", + name: "30 days", + expectedDiff: 30, + }, + { + dateFrom: "2024-01-01T00:00:00Z", + dateTo: "2024-03-31T00:00:00Z", + name: "90 days (API limit)", + expectedDiff: 90, + }, + { + dateFrom: "2024-01-01T00:00:00Z", + dateTo: "2024-07-01T00:00:00Z", + name: "182 days (chunking required)", + expectedDiff: 182, + }, + ]; + + const iterations = 1000; // Reduced for test performance + + for (const testCase of testCases) { + log.info(`Testing ${testCase.name}...`); + + // Test correctness first + const dayjsDiff = dayjs(testCase.dateTo).diff(dayjs(testCase.dateFrom), "days"); + const nativeDiff = Math.floor( + (new Date(testCase.dateTo).getTime() - new Date(testCase.dateFrom).getTime()) / (1000 * 60 * 60 * 24) + ); + + // Verify identical results + expect(nativeDiff).toBe(dayjsDiff); + expect(nativeDiff).toBe(testCase.expectedDiff); + + // Performance test - dayjs approach + const dayjsStart = performance.now(); + for (let i = 0; i < iterations; i++) { + const start = dayjs(testCase.dateFrom); + const end = dayjs(testCase.dateTo); + const diff = end.diff(start, "days"); + } + const dayjsTime = performance.now() - dayjsStart; + + // Performance test - native Date approach + const nativeStart = performance.now(); + 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 nativeTime = performance.now() - nativeStart; + + const speedupRatio = dayjsTime / nativeTime; + + log.info( + `${testCase.name} - Dayjs: ${dayjsTime.toFixed(2)}ms, Native: ${nativeTime.toFixed( + 2 + )}ms, Speedup: ${speedupRatio.toFixed(1)}x` + ); + + // Assert significant performance improvement (at least 5x faster) + expect(speedupRatio).toBeGreaterThan(5); + } + }); + + test("chunking logic should produce identical results between dayjs and native Date implementations", async () => { + const dayjs = (await import("@calcom/dayjs")).default; + + const testCases = [ + { + dateFrom: "2024-01-01T00:00:00Z", + dateTo: "2024-04-01T00:00:00Z", // 91 days - requires chunking + name: "91 days (minimal chunking)", + }, + { + dateFrom: "2024-01-01T00:00:00Z", + dateTo: "2024-07-01T00:00:00Z", // 182 days - multiple chunks + name: "182 days (multiple chunks)", + }, + ]; + + for (const testCase of testCases) { + const fromDate = new Date(testCase.dateFrom); + const toDate = new Date(testCase.dateTo); + const diff = Math.floor((toDate.getTime() - fromDate.getTime()) / (1000 * 60 * 60 * 24)); + + // Only test cases that require chunking (> 90 days) + if (diff <= 90) continue; + + // OLD WAY (dayjs-based chunking) + const originalStartDate = dayjs(testCase.dateFrom); + const originalEndDate = dayjs(testCase.dateTo); + const loopsNumber = Math.ceil(diff / 90); + let startDate = originalStartDate; + let endDate = originalStartDate.add(90, "days"); + + const oldChunks = []; + for (let i = 0; i < loopsNumber; i++) { + if (endDate.isAfter(originalEndDate)) endDate = originalEndDate; + + oldChunks.push({ + start: startDate.toISOString(), + end: endDate.toISOString(), + }); + + startDate = endDate.add(1, "minutes"); + endDate = startDate.add(90, "days"); + } + + // NEW WAY (native Date-based chunking) + let currentStartTime = fromDate.getTime(); + const originalEndTime = toDate.getTime(); + const ninetyDaysMs = 90 * 24 * 60 * 60 * 1000; + const oneMinuteMs = 60 * 1000; + + const newChunks = []; + for (let i = 0; i < loopsNumber; i++) { + let currentEndTime = currentStartTime + ninetyDaysMs; + + if (currentEndTime > originalEndTime) { + currentEndTime = originalEndTime; + } + + newChunks.push({ + start: new Date(currentStartTime).toISOString(), + end: new Date(currentEndTime).toISOString(), + }); + + currentStartTime = currentEndTime + oneMinuteMs; + } + + // Verify identical chunking results + expect(newChunks).toHaveLength(oldChunks.length); + + for (let i = 0; i < oldChunks.length; i++) { + expect(newChunks[i].start).toBe(oldChunks[i].start); + expect(newChunks[i].end).toBe(oldChunks[i].end); + } + + log.info(`${testCase.name} - Generated ${newChunks.length} identical chunks`); + } + }); + + test("date parsing should be consistent between dayjs and native Date for all expected input formats", async () => { + const dayjs = (await import("@calcom/dayjs")).default; + + // Test various date formats that Google Calendar API might return + const testDates = [ + "2024-01-01T00:00:00Z", // UTC + "2024-01-01T12:30:45.123Z", // UTC with milliseconds + "2024-01-01T00:00:00-08:00", // Timezone offset + "2024-01-01T00:00:00+05:30", // Positive timezone offset + "2024-12-31T23:59:59Z", // End of year + "2024-02-29T12:00:00Z", // Leap year date + ]; + + for (const dateString of testDates) { + const dayjsTime = dayjs(dateString).valueOf(); + const nativeTime = new Date(dateString).getTime(); + + expect(nativeTime).toBe(dayjsTime); + + // Also verify ISO string output consistency + const dayjsISO = dayjs(dateString).toISOString(); + const nativeISO = new Date(dateString).toISOString(); + + expect(nativeISO).toBe(dayjsISO); + + log.debug(`Date parsing verified: ${dateString} -> ${nativeTime}`); + } + }); + + test("fetchAvailabilityData should handle both single API call and chunked scenarios correctly", async () => { + const credential = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credential); + setFullMockOAuthManagerRequest(); + + const mockBusyData = [ + { start: "2024-01-01T10:00:00Z", end: "2024-01-01T11:00:00Z" }, + { start: "2024-01-01T14:00:00Z", end: "2024-01-01T15:00:00Z" }, + ]; + + // Mock the getCacheOrFetchAvailability method to return consistent data + const getCacheOrFetchAvailabilitySpy = vi + .spyOn(calendarService as any, "getCacheOrFetchAvailability") + .mockResolvedValue(mockBusyData.map((item) => ({ ...item, id: "test@calendar.com" }))); + + // Test single API call scenario (≤ 90 days) + const shortRangeResult = await (calendarService as any).fetchAvailabilityData( + ["test@calendar.com"], + "2024-01-01T00:00:00Z", + "2024-01-31T00:00:00Z", // 30 days + false + ); + + expect(shortRangeResult).toEqual(mockBusyData); + expect(getCacheOrFetchAvailabilitySpy).toHaveBeenCalledTimes(1); + + getCacheOrFetchAvailabilitySpy.mockClear(); + + // Test chunked scenario (> 90 days) + const longRangeResult = await (calendarService as any).fetchAvailabilityData( + ["test@calendar.com"], + "2024-01-01T00:00:00Z", + "2024-07-01T00:00:00Z", // 182 days - should require chunking + false + ); + + // Should return concatenated results from multiple chunks + expect(longRangeResult.length).toBeGreaterThan(0); + expect(getCacheOrFetchAvailabilitySpy).toHaveBeenCalledTimes(3); // 182 days / 90 = ~2.02 -> 3 chunks + + getCacheOrFetchAvailabilitySpy.mockRestore(); + }); +}); + +describe("createEvent", () => { + test("should create event with correct input/output format and handle all expected properties", async () => { + const credential = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credential); + setFullMockOAuthManagerRequest(); + + // Mock Google Calendar API response + const mockGoogleEvent = { + id: "mock-event-id-123", + summary: "Test Meeting", + description: "Test meeting description", + start: { + dateTime: "2024-06-15T10:00:00Z", + timeZone: "UTC", + }, + end: { + dateTime: "2024-06-15T11:00:00Z", + timeZone: "UTC", + }, + attendees: [ + { + email: "organizer@example.com", + displayName: "Test Organizer", + responseStatus: "accepted", + organizer: true, + }, + { + email: "attendee@example.com", + displayName: "Test Attendee", + responseStatus: "accepted", + }, + ], + location: "Test Location", + iCalUID: "test-ical-uid@google.com", + recurrence: null, + }; + + // Mock calendar.events.insert + const eventsInsertMock = vi.fn().mockResolvedValue({ + data: mockGoogleEvent, + }); + + calendarMock.calendar_v3.Calendar().events.insert = eventsInsertMock; + + // Test input - simplified CalendarServiceEvent + const testCalEvent = { + type: "test-event-type", + uid: "cal-event-uid-123", + title: "Test Meeting", + startTime: "2024-06-15T10:00:00Z", + endTime: "2024-06-15T11:00:00Z", + organizer: { + id: 1, + name: "Test Organizer", + email: "organizer@example.com", + timeZone: "UTC", + language: { + translate: (...args: any[]) => args[0], // Mock translate function + locale: "en", + }, + }, + attendees: [ + { + id: 2, + name: "Test Attendee", + email: "attendee@example.com", + timeZone: "UTC", + language: { + translate: (...args: any[]) => args[0], // Mock translate function + locale: "en", + }, + }, + ], + location: "Test Location", + calendarDescription: "Test meeting description", + destinationCalendar: [ + { + id: 1, + integration: "google_calendar", + externalId: "primary", + primaryEmail: null, + userId: credential.userId, + eventTypeId: null, + credentialId: credential.id, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + }, + ], + iCalUID: "test-ical-uid@google.com", + conferenceData: undefined, + hideCalendarEventDetails: false, + seatsPerTimeSlot: null, + seatsShowAttendees: true, + }; + + // Call createEvent and verify result using inline snapshot + const result = await calendarService.createEvent(testCalEvent, credential.id); + + // Verify input processing - check that Google API was called with correct payload + expect(eventsInsertMock).toHaveBeenCalledTimes(1); + const insertCall = eventsInsertMock.mock.calls[0][0]; + + // Use inline snapshot for input validation + expect(insertCall).toMatchInlineSnapshot(` + { + "calendarId": "primary", + "conferenceDataVersion": 1, + "requestBody": { + "attendees": [ + { + "displayName": "Test Organizer", + "email": "primary", + "id": "1", + "language": { + "locale": "en", + "translate": [Function], + }, + "name": "Test Organizer", + "organizer": true, + "responseStatus": "accepted", + "timeZone": "UTC", + }, + { + "email": "attendee@example.com", + "language": { + "locale": "en", + "translate": [Function], + }, + "name": "Test Attendee", + "responseStatus": "accepted", + "timeZone": "UTC", + }, + ], + "description": "Test meeting description", + "end": { + "dateTime": "2024-06-15T11:00:00Z", + "timeZone": "UTC", + }, + "guestsCanSeeOtherGuests": true, + "iCalUID": "test-ical-uid@google.com", + "location": "Test Location", + "reminders": { + "useDefault": true, + }, + "start": { + "dateTime": "2024-06-15T10:00:00Z", + "timeZone": "UTC", + }, + "summary": "Test Meeting", + }, + "sendUpdates": "none", + } + `); + + // Use inline snapshot for output validation + expect(result).toMatchInlineSnapshot(` + { + "additionalInfo": { + "hangoutLink": "", + }, + "attendees": [ + { + "displayName": "Test Organizer", + "email": "organizer@example.com", + "organizer": true, + "responseStatus": "accepted", + }, + { + "displayName": "Test Attendee", + "email": "attendee@example.com", + "responseStatus": "accepted", + }, + ], + "description": "Test meeting description", + "end": { + "dateTime": "2024-06-15T11:00:00Z", + "timeZone": "UTC", + }, + "iCalUID": "test-ical-uid@google.com", + "id": "mock-event-id-123", + "location": "Test Location", + "password": "", + "recurrence": null, + "start": { + "dateTime": "2024-06-15T10:00:00Z", + "timeZone": "UTC", + }, + "summary": "Test Meeting", + "thirdPartyRecurringEventId": null, + "type": "google_calendar", + "uid": "", + "url": "", + } + `); + + log.info("createEvent test passed - input/output formats verified"); + }); + + test("should handle recurring events correctly", async () => { + const credential = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credential); + setFullMockOAuthManagerRequest(); + + // Mock recurring event response + const mockRecurringEvent = { + id: "recurring-event-id", + summary: "Weekly Meeting", + recurrence: ["RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=10"], + start: { dateTime: "2024-06-15T10:00:00Z", timeZone: "UTC" }, + end: { dateTime: "2024-06-15T11:00:00Z", timeZone: "UTC" }, + }; + + const mockFirstInstance = { + id: "recurring-event-id_20240615T100000Z", + summary: "Weekly Meeting", + start: { dateTime: "2024-06-15T10:00:00Z", timeZone: "UTC" }, + end: { dateTime: "2024-06-15T11:00:00Z", timeZone: "UTC" }, + }; + + calendarMock.calendar_v3.Calendar().events.insert = vi.fn().mockResolvedValue({ + data: mockRecurringEvent, + }); + + calendarMock.calendar_v3.Calendar().events.instances = vi.fn().mockResolvedValue({ + data: { items: [mockFirstInstance] }, + }); + + const recurringCalEvent = { + type: "recurring-meeting", + title: "Weekly Meeting", + startTime: "2024-06-15T10:00:00Z", + endTime: "2024-06-15T11:00:00Z", + organizer: { + id: 1, + name: "Organizer", + email: "organizer@example.com", + timeZone: "UTC", + language: { + translate: (...args: any[]) => args[0], // Mock translate function + locale: "en", + }, + }, + attendees: [], + recurringEvent: { + freq: 2, // Weekly + interval: 1, + count: 10, + }, + destinationCalendar: [ + { + id: 1, + integration: "google_calendar", + externalId: "primary", + primaryEmail: null, + userId: credential.userId, + eventTypeId: null, + credentialId: credential.id, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + }, + ], + calendarDescription: "Weekly team meeting", + }; + + const result = await calendarService.createEvent(recurringCalEvent, credential.id); + + // Use inline snapshot for recurring event result + expect(result).toMatchInlineSnapshot(` + { + "additionalInfo": { + "hangoutLink": "", + }, + "end": { + "dateTime": "2024-06-15T11:00:00Z", + "timeZone": "UTC", + }, + "iCalUID": undefined, + "id": "recurring-event-id_20240615T100000Z", + "password": "", + "start": { + "dateTime": "2024-06-15T10:00:00Z", + "timeZone": "UTC", + }, + "summary": "Weekly Meeting", + "thirdPartyRecurringEventId": "recurring-event-id", + "type": "google_calendar", + "uid": "", + "url": "", + } + `); + + // Verify recurrence rule was included in the request + const insertCall = calendarMock.calendar_v3.Calendar().events.insert.mock.calls[0][0]; + expect(insertCall.requestBody.recurrence).toEqual(["RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=10"]); + + log.info("createEvent recurring event test passed"); + }); +});