From ae7fd0cae207a828a0f63c0b8cf782e1986ed6a5 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Thu, 20 Nov 2025 17:02:18 +0100 Subject: [PATCH] refactor: Remove all code related to the old cache system (#25284) * chore: Remove all code related to the old cache system * Removed some redundant tests, some type fixes * Further type fixes * More type fixes re. tests * Next iteration, couple of fixes remaining * Remove cache from CredentialActionsDropdown * Fix tests by mocking credential, instead of db queries * Remove Cache DI wiring from v2 * Make sure apiv2 build passes * Remove another cache cron * Remove old tokens for calendar-cache v1 --- PR_TODO.md | 15 - .../src/lib/modules/available-slots.module.ts | 2 - .../src/lib/modules/regular-booking.module.ts | 2 - .../lib/services/available-slots.service.ts | 3 - apps/api/v2/src/lib/services/cache.service.ts | 11 - .../lib/services/regular-booking.service.ts | 3 - .../app/api/availability/calendar/route.ts | 9 +- .../api/cron/calendar-cache-cleanup/route.ts | 26 - apps/web/cron-tester.ts | 1 - apps/web/pages/api/calendar-cache/cron.ts | 1 - .../playwright/booking-race-condition.e2e.ts | 83 +- apps/web/vercel.json | 8 - packages/app-store/delegationCredential.ts | 35 - .../app-store/googlecalendar/api/index.ts | 1 - .../app-store/googlecalendar/api/webhook.ts | 52 - .../googlecalendar/lib/CalendarService.ts | 321 +---- .../lib/__tests__/CalendarService.test.ts | 1170 +---------------- .../lib/__tests__/getFreeBusyResult.test.ts | 135 -- .../lib/VideoApiAdapter.test.ts | 2 - .../components/CredentialActionsDropdown.tsx | 64 +- .../di/RegularBookingService.module.ts | 2 - .../lib/service/RegularBookingService.ts | 2 - .../features/calendar-cache/CalendarCache.md | 24 - packages/features/calendar-cache/api/cron.ts | 165 --- .../calendar-cache.repository.interface.ts | 32 - .../calendar-cache.repository.mock.ts | 31 - .../calendar-cache.repository.schema.ts | 9 - .../calendar-cache.repository.ts | 188 --- .../features/calendar-cache/calendar-cache.ts | 31 - .../calendar-cache/lib/datesForCache.test.ts | 97 -- .../calendar-cache/lib/datesForCache.ts | 59 - .../lib/getShouldServeCache.test.ts | 93 -- .../calendar-cache/lib/getShouldServeCache.ts | 16 - .../features/di/containers/AvailableSlots.ts | 2 - packages/features/di/containers/Cache.ts | 16 - .../features/di/modules/AvailableSlots.ts | 1 - packages/features/di/modules/Cache.ts | 23 - packages/features/di/tokens.ts | 2 - .../SelectedCalendarsSettingsWebWrapper.tsx | 6 +- packages/platform/libraries/index.ts | 1 - .../viewer/admin/toggleFeatureFlag.handler.ts | 16 +- .../routers/viewer/calendars/_router.tsx | 14 - .../calendars/connectedCalendars.handler.ts | 9 +- .../viewer/calendars/deleteCache.handler.ts | 33 - .../trpc/server/routers/viewer/slots/util.ts | 7 +- 45 files changed, 58 insertions(+), 2765 deletions(-) delete mode 100644 PR_TODO.md delete mode 100644 apps/api/v2/src/lib/services/cache.service.ts delete mode 100644 apps/web/app/api/cron/calendar-cache-cleanup/route.ts delete mode 100644 apps/web/pages/api/calendar-cache/cron.ts delete mode 100644 packages/app-store/googlecalendar/api/webhook.ts delete mode 100644 packages/app-store/googlecalendar/lib/__tests__/getFreeBusyResult.test.ts delete mode 100644 packages/features/calendar-cache/CalendarCache.md delete mode 100644 packages/features/calendar-cache/api/cron.ts delete mode 100644 packages/features/calendar-cache/calendar-cache.repository.interface.ts delete mode 100644 packages/features/calendar-cache/calendar-cache.repository.mock.ts delete mode 100644 packages/features/calendar-cache/calendar-cache.repository.schema.ts delete mode 100644 packages/features/calendar-cache/calendar-cache.repository.ts delete mode 100644 packages/features/calendar-cache/calendar-cache.ts delete mode 100644 packages/features/calendar-cache/lib/datesForCache.test.ts delete mode 100644 packages/features/calendar-cache/lib/datesForCache.ts delete mode 100644 packages/features/calendar-cache/lib/getShouldServeCache.test.ts delete mode 100644 packages/features/calendar-cache/lib/getShouldServeCache.ts delete mode 100644 packages/features/di/containers/Cache.ts delete mode 100644 packages/features/di/modules/Cache.ts delete mode 100644 packages/trpc/server/routers/viewer/calendars/deleteCache.handler.ts diff --git a/PR_TODO.md b/PR_TODO.md deleted file mode 100644 index e68c73c9f5..0000000000 --- a/PR_TODO.md +++ /dev/null @@ -1,15 +0,0 @@ -Possible issue: -- We won't be able to use already built calendar-cache when Delegation Credential is enabled because existing CalendarCache entries don't have userId set. - -## Approach of always using SelectedCalendar.credentialId for CalendarCache even for DelegationCredentials -- Delete Credentials when DelegationCredential is disabled - - -TO Test -- New members beyond the batch size are processed - - - - - - diff --git a/apps/api/v2/src/lib/modules/available-slots.module.ts b/apps/api/v2/src/lib/modules/available-slots.module.ts index 894eb01469..c9484b8407 100644 --- a/apps/api/v2/src/lib/modules/available-slots.module.ts +++ b/apps/api/v2/src/lib/modules/available-slots.module.ts @@ -10,7 +10,6 @@ import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository" import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; import { AvailableSlotsService } from "@/lib/services/available-slots.service"; import { BusyTimesService } from "@/lib/services/busy-times.service"; -import { CacheService } from "@/lib/services/cache.service"; import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; import { FilterHostsService } from "@/lib/services/filter-hosts.service"; import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service"; @@ -35,7 +34,6 @@ import { Module } from "@nestjs/common"; PrismaFeaturesRepository, PrismaMembershipRepository, CheckBookingLimitsService, - CacheService, AvailableSlotsService, UserAvailabilityService, BusyTimesService, diff --git a/apps/api/v2/src/lib/modules/regular-booking.module.ts b/apps/api/v2/src/lib/modules/regular-booking.module.ts index 0cdc705579..2767becd06 100644 --- a/apps/api/v2/src/lib/modules/regular-booking.module.ts +++ b/apps/api/v2/src/lib/modules/regular-booking.module.ts @@ -4,7 +4,6 @@ import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.rep import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository"; import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; -import { CacheService } from "@/lib/services/cache.service"; import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service"; import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; import { HashedLinkService } from "@/lib/services/hashed-link.service"; @@ -22,7 +21,6 @@ import { Module } from "@nestjs/common"; PrismaHostRepository, PrismaOOORepository, PrismaUserRepository, - CacheService, CheckBookingAndDurationLimitsService, CheckBookingLimitsService, HashedLinkService, diff --git a/apps/api/v2/src/lib/services/available-slots.service.ts b/apps/api/v2/src/lib/services/available-slots.service.ts index 0ac997c01d..dba0270eb9 100644 --- a/apps/api/v2/src/lib/services/available-slots.service.ts +++ b/apps/api/v2/src/lib/services/available-slots.service.ts @@ -8,7 +8,6 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"; import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; import { BusyTimesService } from "@/lib/services/busy-times.service"; -import { CacheService } from "@/lib/services/cache.service"; import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; import { NoSlotsNotificationService } from "@/lib/services/no-slots-notification.service"; import { QualifiedHostsService } from "@/lib/services/qualified-hosts.service"; @@ -34,7 +33,6 @@ export class AvailableSlotsService extends BaseAvailableSlotsService { featuresRepository: PrismaFeaturesRepository, qualifiedHostsService: QualifiedHostsService, checkBookingLimitsService: CheckBookingLimitsService, - cacheService: CacheService, userAvailabilityService: UserAvailabilityService, busyTimesService: BusyTimesService, noSlotsNotificationService: NoSlotsNotificationService @@ -50,7 +48,6 @@ export class AvailableSlotsService extends BaseAvailableSlotsService { userRepo: userRepository, redisClient: redisService, checkBookingLimitsService, - cacheService, userAvailabilityService, busyTimesService, qualifiedHostsService, diff --git a/apps/api/v2/src/lib/services/cache.service.ts b/apps/api/v2/src/lib/services/cache.service.ts deleted file mode 100644 index fd8d8e3b59..0000000000 --- a/apps/api/v2/src/lib/services/cache.service.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; -import { Injectable } from "@nestjs/common"; - -import { CacheService as BaseCacheService } from "@calcom/platform-libraries"; - -@Injectable() -export class CacheService extends BaseCacheService { - constructor(featuresRepository: PrismaFeaturesRepository) { - super({ featuresRepository }); - } -} diff --git a/apps/api/v2/src/lib/services/regular-booking.service.ts b/apps/api/v2/src/lib/services/regular-booking.service.ts index d2b05337f8..8666b86a8f 100644 --- a/apps/api/v2/src/lib/services/regular-booking.service.ts +++ b/apps/api/v2/src/lib/services/regular-booking.service.ts @@ -1,6 +1,5 @@ import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; -import { CacheService } from "@/lib/services/cache.service"; import { CheckBookingAndDurationLimitsService } from "@/lib/services/check-booking-and-duration-limits.service"; import { HashedLinkService } from "@/lib/services/hashed-link.service"; import { LuckyUserService } from "@/lib/services/lucky-user.service"; @@ -13,7 +12,6 @@ import type { PrismaClient } from "@calcom/prisma"; @Injectable() export class RegularBookingService extends BaseRegularBookingService { constructor( - cacheService: CacheService, checkBookingAndDurationLimitsService: CheckBookingAndDurationLimitsService, prismaWriteService: PrismaWriteService, bookingRepository: PrismaBookingRepository, @@ -22,7 +20,6 @@ export class RegularBookingService extends BaseRegularBookingService { userRepository: PrismaUserRepository ) { super({ - cacheService, checkBookingAndDurationLimitsService, prismaClient: prismaWriteService.prisma as unknown as PrismaClient, bookingRepository, diff --git a/apps/web/app/api/availability/calendar/route.ts b/apps/web/app/api/availability/calendar/route.ts index 620cb1b0a3..a8bc833497 100644 --- a/apps/web/app/api/availability/calendar/route.ts +++ b/apps/web/app/api/availability/calendar/route.ts @@ -5,7 +5,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; import { getCalendarCredentials, getConnectedCalendars, @@ -92,15 +91,9 @@ async function deleteHandler(req: NextRequest) { const user = await authMiddleware(); const searchParams = Object.fromEntries(req.nextUrl.searchParams.entries()); - const { integration, externalId, credentialId, eventTypeId } = + const { integration, externalId, eventTypeId } = selectedCalendarSelectSchema.parse(searchParams); - const calendarCacheRepository = await CalendarCache.initFromCredentialId(credentialId); - await calendarCacheRepository.unwatchCalendar({ - calendarId: externalId, - eventTypeIds: [eventTypeId ?? null], - }); - await SelectedCalendarRepository.delete({ where: { userId: user.id, diff --git a/apps/web/app/api/cron/calendar-cache-cleanup/route.ts b/apps/web/app/api/cron/calendar-cache-cleanup/route.ts deleted file mode 100644 index 96b08bacc0..0000000000 --- a/apps/web/app/api/cron/calendar-cache-cleanup/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - -import prisma from "@calcom/prisma"; - -async function postHandler(request: NextRequest) { - const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); - - if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) { - return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); - } - - const deleted = await prisma.calendarCache.deleteMany({ - where: { - // Delete all cache entries that expired before now - expiresAt: { - lte: new Date(Date.now()), - }, - }, - }); - - return NextResponse.json({ ok: true, count: deleted.count }); -} - -export const POST = defaultResponderForAppDir(postHandler); diff --git a/apps/web/cron-tester.ts b/apps/web/cron-tester.ts index 808b83a708..51e63d1a90 100755 --- a/apps/web/cron-tester.ts +++ b/apps/web/cron-tester.ts @@ -24,7 +24,6 @@ try { async function () { await Promise.allSettled([ fetchCron("/cron/calendar-subscriptions"), - // fetchCron("/calendar-cache/cron"), // fetchCron("/cron/calVideoNoShowWebhookTriggers"), fetchCron("/tasks/cron"), ]); diff --git a/apps/web/pages/api/calendar-cache/cron.ts b/apps/web/pages/api/calendar-cache/cron.ts deleted file mode 100644 index 73b6e95dcd..0000000000 --- a/apps/web/pages/api/calendar-cache/cron.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "@calcom/features/calendar-cache/api/cron"; diff --git a/apps/web/playwright/booking-race-condition.e2e.ts b/apps/web/playwright/booking-race-condition.e2e.ts index e09e827424..a7e7c14f11 100644 --- a/apps/web/playwright/booking-race-condition.e2e.ts +++ b/apps/web/playwright/booking-race-condition.e2e.ts @@ -2,8 +2,6 @@ import { expect } from "@playwright/test"; import type { Page, Browser, Route, Response } from "@playwright/test"; import type { z } from "zod"; -import { CalendarCacheRepository } from "@calcom/features/calendar-cache/calendar-cache.repository"; -import { getTimeMin, getTimeMax } from "@calcom/features/calendar-cache/lib/datesForCache"; import { prisma } from "@calcom/prisma"; import type { Team, EventType, User } from "@calcom/prisma/client"; import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; @@ -65,10 +63,7 @@ test.describe("Booking Race Condition Prevention", () => { await setupGoogleCalendarCredentials(teamMembers); await createIdenticalBookingHistories(teamMembers, teamEvent.id); - const { selectedDate, selectedDateISO } = await getDynamicBookingDate(page, org, team, teamEvent); - - const { targetHost, calendarCacheHits } = await setupCalendarCache(teamMembers, selectedDateISO); - await enableCalendarCacheFeatures(team.id); + const { selectedDate } = await getDynamicBookingDate(page, org, team, teamEvent); const { firstResponse, secondResponse } = await performConcurrentBookings( page, @@ -226,82 +221,6 @@ async function getDynamicBookingDate( }; } -async function setupCalendarCache(teamMembers: User[], selectedDateISO: string) { - const cacheTimeRange = { - timeMin: getTimeMin(selectedDateISO), - timeMax: getTimeMax(selectedDateISO), - }; - - const credentials = await prisma.credential.findMany({ - where: { - userId: { in: teamMembers.map((m) => m.id) }, - type: "google_calendar", - }, - }); - - const calendarCacheRepo = new CalendarCacheRepository(null); - const targetHost = teamMembers[0]; - const calendarCacheHits: string[] = []; - - for (let i = 0; i < credentials.length; i++) { - const credential = credentials[i]; - const member = teamMembers[i]; - - const cacheArgs = { - timeMin: cacheTimeRange.timeMin, - timeMax: cacheTimeRange.timeMax, - items: [{ id: member.email! }], - }; - - const availabilityData = { - kind: "calendar#freeBusy", - calendars: { - [member.email!]: { - busy: - member.id === targetHost.id - ? [] - : [ - { - start: `${selectedDateISO.slice(0, 10)}T08:00:00.000Z`, - end: `${selectedDateISO.slice(0, 10)}T08:30:00.000Z`, - }, - ], - }, - }, - }; - - await calendarCacheRepo.upsertCachedAvailability({ - credentialId: credential.id, - userId: member.id, - args: cacheArgs, - value: availabilityData, - }); - - calendarCacheHits.push(`${member.email}-${credential.id}`); - } - - return { targetHost, calendarCacheHits }; -} - -async function enableCalendarCacheFeatures(teamId: number) { - await prisma.teamFeatures.createMany({ - data: [ - { - teamId, - featureId: "calendar-cache", - assignedAt: new Date(), - assignedBy: "race-condition-test", - }, - { - teamId, - featureId: "calendar-cache-serve", - assignedAt: new Date(), - assignedBy: "race-condition-test", - }, - ], - }); -} - async function mockGoogleCalendarAPI(page: Page, selectedDateISO: string) { const busyStart = `${selectedDateISO.slice(0, 10)}T08:00:00.000Z`; const busyEnd = `${selectedDateISO.slice(0, 10)}T09:00:00.000Z`; diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 8bbd2b3dac..007507b6d3 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -1,9 +1,5 @@ { "crons": [ - { - "path": "/api/cron/calendar-cache-cleanup", - "schedule": "0 5 * * *" - }, { "path": "/api/cron/calendar-subscriptions", "schedule": "*/5 * * * *" @@ -20,10 +16,6 @@ "path": "/api/tasks/cron", "schedule": "* * * * *" }, - { - "path": "/api/calendar-cache/cron", - "schedule": "* * * * *" - }, { "path": "/api/tasks/cleanup", "schedule": "0 0 * * *" diff --git a/packages/app-store/delegationCredential.ts b/packages/app-store/delegationCredential.ts index d6bd5dd302..979d493688 100644 --- a/packages/app-store/delegationCredential.ts +++ b/packages/app-store/delegationCredential.ts @@ -615,41 +615,6 @@ export async function findUniqueDelegationCalendarCredential({ return dwdCredential; } -/** - * CredentialForCalendarCache is different from CredentialForCalendarService in the sense that CredentialForCalendarCache.id is greater than 0 and CredentialForCalendarService.id is -1 - * Thus it is a Credential from DB and and also a Delegation User Credential(when CredentialForCalendarCache.delegatedTo is not null) - */ -export async function getCredentialForCalendarCache({ credentialId }: { credentialId: number }) { - const credential = await CredentialRepository.findByIdIncludeDelegationCredential({ - id: credentialId, - }); - - let credentialForCalendarService; - - if (credential?.delegationCredential) { - if (!credential.userId) { - throw new Error(`Credential ${credentialId} doesn't have a user`); - } - const delegationCredential = await findUniqueDelegationCalendarCredential({ - userId: credential.userId, - delegationCredentialId: credential.delegationCredential.id, - }); - - if (!delegationCredential) { - credentialForCalendarService = null; - } else { - // We prepare a credential that is in-db(in contrast with an in-memory credential used elsewhere where we generate CredentialForCalendarService) - credentialForCalendarService = { - ...delegationCredential, - id: credential.id, - }; - } - } else { - credentialForCalendarService = buildNonDelegationCredential(credential); - } - return credentialForCalendarService; -} - /** * It includes in-memory DelegationCredential credentials as well. */ diff --git a/packages/app-store/googlecalendar/api/index.ts b/packages/app-store/googlecalendar/api/index.ts index 567bbf7979..eb12c1b4ed 100644 --- a/packages/app-store/googlecalendar/api/index.ts +++ b/packages/app-store/googlecalendar/api/index.ts @@ -1,3 +1,2 @@ export { default as add } from "./add"; export { default as callback } from "./callback"; -export { default as webhook } from "./webhook"; diff --git a/packages/app-store/googlecalendar/api/webhook.ts b/packages/app-store/googlecalendar/api/webhook.ts deleted file mode 100644 index d0cf11bc36..0000000000 --- a/packages/app-store/googlecalendar/api/webhook.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { NextApiRequest } from "next"; - -import { getCredentialForCalendarCache } from "@calcom/app-store/delegationCredential"; -import { HttpError } from "@calcom/lib/http-error"; -import logger from "@calcom/lib/logger"; -import { safeStringify } from "@calcom/lib/safeStringify"; -import { defaultHandler } from "@calcom/lib/server/defaultHandler"; -import { defaultResponder } from "@calcom/lib/server/defaultResponder"; -import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; - -import { getCalendar } from "../../_utils/getCalendar"; - -const log = logger.getSubLogger({ prefix: ["GoogleCalendarWebhook"] }); - -async function postHandler(req: NextApiRequest) { - const channelToken = req.headers["x-goog-channel-token"]; - const channelId = req.headers["x-goog-channel-id"]; - - log.debug("postHandler", safeStringify({ channelToken, channelId })); - if (channelToken !== process.env.GOOGLE_WEBHOOK_TOKEN) { - throw new HttpError({ statusCode: 403, message: "Invalid API key" }); - } - if (typeof channelId !== "string") { - throw new HttpError({ statusCode: 403, message: "Missing Channel ID" }); - } - - // There could be multiple selected calendars for the same googleChannelId for different eventTypes and same user - // Every such record has their googleChannel related fields set which are same - // So, it is enough to get the first selected calendar for this googleChannelId - // Further code gets all the selected calendars for this calendar's credential - const selectedCalendar = await SelectedCalendarRepository.findFirstByGoogleChannelId(channelId); - - if (!selectedCalendar) { - log.info("postHandler", `No selected calendar found for googleChannelId: ${channelId}`); - return { message: "ok" }; - } - const { credential } = selectedCalendar; - if (!credential) { - log.info("postHandler", `No credential found for selected calendar for googleChannelId: ${channelId}`); - return { message: "ok" }; - } - const { selectedCalendars } = credential; - const credentialForCalendarCache = await getCredentialForCalendarCache({ credentialId: credential.id }); - const calendarServiceForCalendarCache = await getCalendar(credentialForCalendarCache); - - await calendarServiceForCalendarCache?.fetchAvailabilityAndSetCache?.(selectedCalendars); - return { message: "ok" }; -} - -export default defaultHandler({ - POST: Promise.resolve({ default: defaultResponder(postHandler) }), -}); diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 13c331ecf1..41ae0080c3 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -5,16 +5,12 @@ import { RRule } from "rrule"; import { v4 as uuid } from "uuid"; import { MeetLocationType } from "@calcom/app-store/constants"; -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"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import { uniqueBy } from "@calcom/lib/array"; import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants"; 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, @@ -30,6 +26,8 @@ import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Creden import { AxiosLikeResponseToFetchResponse } from "../../_utils/oauth/AxiosLikeResponseToFetchResponse"; import { CalendarAuth } from "./CalendarAuth"; +type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: string }[] }; + const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarService"] }); interface GoogleCalError extends Error { @@ -38,20 +36,12 @@ interface GoogleCalError extends Error { const MS_PER_DAY = 24 * 60 * 60 * 1000; const ONE_MONTH_IN_MS = 30 * MS_PER_DAY; -// eslint-disable-next-line turbo/no-undeclared-env-vars -- GOOGLE_WEBHOOK_URL only for local testing + const GOOGLE_WEBHOOK_URL_BASE = process.env.GOOGLE_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL; const GOOGLE_WEBHOOK_URL = `${GOOGLE_WEBHOOK_URL_BASE}/api/integrations/googlecalendar/webhook`; const isGaxiosResponse = (error: unknown): error is GaxiosResponse => - typeof error === "object" && !!error && error.hasOwnProperty("config"); - -type GoogleChannelProps = { - kind?: string | null; - id?: string | null; - resourceId?: string | null; - resourceUri?: string | null; - expiration?: string | null; -}; + typeof error === "object" && !!error && Object.prototype.hasOwnProperty.call(error, "config"); export default class GoogleCalendarService implements Calendar { private integrationName = ""; @@ -196,7 +186,7 @@ export default class GoogleCalendarService implements Calendar { reminders: { useDefault: true, }, - guestsCanSeeOtherGuests: !!calEvent.seatsPerTimeSlot ? calEvent.seatsShowAttendees : true, + guestsCanSeeOtherGuests: calEvent.seatsPerTimeSlot ? calEvent.seatsShowAttendees : true, iCalUID: calEvent.iCalUID, }; if (calEvent.hideCalendarEventDetails) { @@ -355,7 +345,7 @@ export default class GoogleCalendarService implements Calendar { reminders: { useDefault: true, }, - guestsCanSeeOtherGuests: !!event.seatsPerTimeSlot ? event.seatsShowAttendees : true, + guestsCanSeeOtherGuests: event.seatsPerTimeSlot ? event.seatsShowAttendees : true, }; if (event.location) { @@ -464,26 +454,7 @@ export default class GoogleCalendarService implements Calendar { async getFreeBusyResult( args: FreeBusyArgs, - shouldServeCache?: boolean ): Promise { - if (!shouldServeCache) return await this.fetchAvailability(args); - 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(args.timeMin), - // Expand the end date to the end of the month to increase cache hits - timeMax: getTimeMax(args.timeMax), - items: args.items, - }, - }); - if (cached) { - log.debug("[Cache Hit] Returning cached freebusy result", safeStringify({ cached, args })); - return cached.value as unknown as calendar_v3.Schema$FreeBusyResponse; - } - log.debug("[Cache Miss] Fetching freebusy result", safeStringify({ args })); return await this.fetchAvailability(args); } @@ -500,11 +471,10 @@ export default class GoogleCalendarService implements Calendar { return validCals[0]; } - async getCacheOrFetchAvailability( + async getFreeBusyData( args: FreeBusyArgs, - shouldServeCache?: boolean ): Promise<(EventBusyDate & { id: string })[] | null> { - const freeBusyResult = await this.getFreeBusyResult(args, shouldServeCache); + const freeBusyResult = await this.getFreeBusyResult(args); if (!freeBusyResult.calendars) return null; const result = Object.entries(freeBusyResult.calendars).reduce((c, [id, i]) => { @@ -567,7 +537,7 @@ export default class GoogleCalendarService implements Calendar { try { const calIdsWithTimeZone = await getCalIdsWithTimeZone(); const calIds = calIdsWithTimeZone.map((calIdWithTimeZone) => ({ id: calIdWithTimeZone.id })); - const freeBusyData = await this.getCacheOrFetchAvailability({ + const freeBusyData = await this.getFreeBusyData({ timeMin: dateFrom, timeMax: dateTo, items: calIds, @@ -611,44 +581,6 @@ export default class GoogleCalendarService implements Calendar { ); } - /** - * 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 */ @@ -677,7 +609,6 @@ export default class GoogleCalendarService implements Calendar { 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) @@ -688,13 +619,12 @@ export default class GoogleCalendarService implements Calendar { // Google API only allows a date range of 90 days for /freebusy if (diff <= 90) { - const freeBusyData = await this.getCacheOrFetchAvailability( + const freeBusyData = await this.getFreeBusyData( { timeMin: dateFrom, timeMax: dateTo, items: calendarIds.map((id) => ({ id })), - }, - shouldServeCache + } ); if (!freeBusyData) throw new Error("No response from google calendar"); @@ -717,13 +647,12 @@ export default class GoogleCalendarService implements Calendar { currentEndTime = originalEndTime; } - const chunkData = await this.getCacheOrFetchAvailability( + const chunkData = await this.getFreeBusyData( { timeMin: new Date(currentStartTime).toISOString(), timeMax: new Date(currentEndTime).toISOString(), items: calendarIds.map((id) => ({ id })), - }, - shouldServeCache + } ); if (chunkData) { @@ -740,7 +669,6 @@ export default class GoogleCalendarService implements Calendar { dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[], - shouldServeCache?: boolean, /** * If true, we will fallback to the primary calendar if no valid selected calendars are found */ @@ -757,23 +685,9 @@ export default class GoogleCalendarService implements Calendar { return []; } - // 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 calendarIds = await this.getCalendarIds(selectedCalendarIds, fallbackToPrimary); - return await this.fetchAvailabilityData(calendarIds, dateFrom, dateTo, shouldServeCache); + return await this.fetchAvailabilityData(calendarIds, dateFrom, dateTo); } catch (error) { this.log.error( "There was an error getting availability from google calendar: ", @@ -824,213 +738,6 @@ export default class GoogleCalendarService implements Calendar { return !!cals.data.items; } - /** - * calendarId is the externalId for the SelectedCalendar - * It doesn't check if the subscription has expired or not. - * It just creates a new subscription. - */ - async watchCalendar({ - calendarId, - eventTypeIds, - }: { - calendarId: string; - eventTypeIds: SelectedCalendarEventTypeIds; - }) { - log.debug("watchCalendar", safeStringify({ calendarId, eventTypeIds })); - if (!process.env.GOOGLE_WEBHOOK_TOKEN) { - log.warn("GOOGLE_WEBHOOK_TOKEN is not set, skipping watching calendar"); - return; - } - - const allCalendarsWithSubscription = await SelectedCalendarRepository.findMany({ - where: { - credentialId: this.credential.id, - externalId: calendarId, - integration: this.integrationName, - googleChannelId: { - not: null, - }, - }, - }); - - const otherCalendarsWithSameSubscription = allCalendarsWithSubscription.filter( - (sc) => !eventTypeIds?.includes(sc.eventTypeId) - ); - - let googleChannelProps: GoogleChannelProps = otherCalendarsWithSameSubscription.length - ? { - kind: otherCalendarsWithSameSubscription[0].googleChannelKind, - id: otherCalendarsWithSameSubscription[0].googleChannelId, - resourceId: otherCalendarsWithSameSubscription[0].googleChannelResourceId, - resourceUri: otherCalendarsWithSameSubscription[0].googleChannelResourceUri, - expiration: otherCalendarsWithSameSubscription[0].googleChannelExpiration, - } - : {}; - - if (!otherCalendarsWithSameSubscription.length) { - try { - googleChannelProps = await this.startWatchingCalendarsInGoogle({ calendarId }); - } catch (error) { - this.log.error(`Failed to watch calendar ${calendarId}`, safeStringify(error)); - throw error; - } - } else { - logger.info( - `Calendar ${calendarId} is already being watched for event types ${otherCalendarsWithSameSubscription.map( - (sc) => sc.eventTypeId - )}. So, not watching again and instead reusing the existing channel` - ); - } - // FIXME: We shouldn't create SelectedCalendar, we should only update if exists - await this.upsertSelectedCalendarsForEventTypeIds( - { - externalId: calendarId, - googleChannelId: googleChannelProps.id, - googleChannelKind: googleChannelProps.kind, - googleChannelResourceId: googleChannelProps.resourceId, - googleChannelResourceUri: googleChannelProps.resourceUri, - googleChannelExpiration: googleChannelProps.expiration, - }, - eventTypeIds - ); - return googleChannelProps; - } - - /** - * GoogleChannel subscription is only stopped when all selectedCalendars are un-watched. - */ - async unwatchCalendar({ - calendarId, - eventTypeIds, - }: { - calendarId: string; - eventTypeIds: SelectedCalendarEventTypeIds; - }) { - const credentialId = this.credential.id; - const eventTypeIdsToBeUnwatched = eventTypeIds; - - const calendarsWithSameCredentialId = await SelectedCalendarRepository.findMany({ - where: { - credentialId, - }, - }); - - const calendarWithSameExternalId = calendarsWithSameCredentialId.filter( - (sc) => sc.externalId === calendarId && sc.integration === this.integrationName - ); - - const calendarsWithSameExternalIdThatAreBeingWatched = calendarWithSameExternalId.filter( - (sc) => !!sc.googleChannelId - ); - - // Except those requested to be un-watched, other calendars are still being watched - const calendarsWithSameExternalIdToBeStillWatched = calendarsWithSameExternalIdThatAreBeingWatched.filter( - (sc) => !eventTypeIdsToBeUnwatched.includes(sc.eventTypeId) - ); - - if (calendarsWithSameExternalIdToBeStillWatched.length) { - logger.info( - `There are other ${calendarsWithSameExternalIdToBeStillWatched.length} calendars with the same externalId_credentialId. Not unwatching. Just removing the channelId from this selected calendar` - ); - - // CalendarCache still need to exist - // We still need to keep the subscription - - // Just remove the google channel related fields from this selected calendar - await this.upsertSelectedCalendarsForEventTypeIds( - { - externalId: calendarId, - googleChannelId: null, - googleChannelKind: null, - googleChannelResourceId: null, - googleChannelResourceUri: null, - googleChannelExpiration: null, - }, - eventTypeIdsToBeUnwatched - ); - return; - } - - const allChannelsForThisCalendarBeingUnwatched = calendarsWithSameExternalIdThatAreBeingWatched.map( - (sc) => ({ - googleChannelResourceId: sc.googleChannelResourceId, - googleChannelId: sc.googleChannelId, - }) - ); - - // Delete the calendar cache to force a fresh cache - await prisma.calendarCache.deleteMany({ where: { credentialId } }); - await this.stopWatchingCalendarsInGoogle(allChannelsForThisCalendarBeingUnwatched); - await this.upsertSelectedCalendarsForEventTypeIds( - { - externalId: calendarId, - googleChannelId: null, - googleChannelKind: null, - googleChannelResourceId: null, - googleChannelResourceUri: null, - googleChannelExpiration: null, - }, - eventTypeIdsToBeUnwatched - ); - - // Populate the cache back for the remaining calendars, if any - const remainingCalendars = - calendarsWithSameCredentialId.filter( - (sc) => sc.externalId !== calendarId && sc.integration === this.integrationName - ) || []; - if (remainingCalendars.length > 0) { - await this.fetchAvailabilityAndSetCache(remainingCalendars); - } - } - - async setAvailabilityInCache(args: FreeBusyArgs, data: calendar_v3.Schema$FreeBusyResponse): Promise { - log.debug("setAvailabilityInCache", safeStringify({ args, data })); - const calendarCache = await CalendarCache.init(null); - await calendarCache.upsertCachedAvailability({ - credentialId: this.credential.id, - userId: this.credential.userId, - args, - value: JSON.parse(JSON.stringify(data)), - }); - } - - async fetchAvailabilityAndSetCache(selectedCalendars: IntegrationCalendar[]) { - this.log.debug("fetchAvailabilityAndSetCache", safeStringify({ selectedCalendars })); - const selectedCalendarsPerEventType = new Map< - SelectedCalendarEventTypeIds[number], - IntegrationCalendar[] - >(); - - // TODO: Should be done outside of CalendarService as it is applicable to all Apps' CalendarServices - selectedCalendars.reduce((acc, selectedCalendar) => { - const eventTypeId = selectedCalendar.eventTypeId ?? null; - const mapValue = selectedCalendarsPerEventType.get(eventTypeId); - if (mapValue) { - mapValue.push(selectedCalendar); - } else { - acc.set(eventTypeId, [selectedCalendar]); - } - return acc; - }, selectedCalendarsPerEventType); - - for (const [_eventTypeId, selectedCalendars] of Array.from(selectedCalendarsPerEventType.entries())) { - const parsedArgs = { - /** Expand the start date to the start of the month to increase cache hits */ - timeMin: getTimeMin(), - /** Expand the end date to the end of the month to increase cache hits */ - timeMax: getTimeMax(), - // Dont use eventTypeId in key because it can be used by any eventType - // The only reason we are building it per eventType is because there can be different groups of calendars to lookup the availability for - items: selectedCalendars.map((sc) => ({ id: sc.externalId })), - }; - const data = await this.fetchAvailability(parsedArgs); - await this.setAvailabilityInCache(parsedArgs, data); - } - - // Update SelectedCalendar.updatedAt for all calendars under this credential - await SelectedCalendarRepository.updateManyByCredentialId(this.credential.id, {}); - } - async createSelectedCalendar( data: Omit ) { diff --git a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts index da8244198f..318d0ce624 100644 --- a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts @@ -1,4 +1,3 @@ -import prismock from "../../../../../tests/libs/__mocks__/prisma"; import oAuthManagerMock, { defaultMockOAuthManager, setFullMockOAuthManagerRequest, @@ -18,233 +17,17 @@ import { import { expect, test, beforeEach, vi, describe } from "vitest"; import "vitest-fetch-mock"; -import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; -import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache"; import logger from "@calcom/lib/logger"; -import { safeStringify } from "@calcom/lib/safeStringify"; -import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; import CalendarService from "../CalendarService"; import { createMockJWTInstance, - createInMemoryDelegationCredentialForCalendarService, - defaultDelegatedCredential, createCredentialForCalendarService, - createInMemoryCredential, } from "./utils"; +import { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; const log = logger.getSubLogger({ prefix: ["CalendarService.test"] }); -async function expectCacheToBeSet({ - credentialId, - itemsInKey, -}: { - credentialId: number; - itemsInKey: { id: string }[]; -}) { - const caches = await prismock.calendarCache.findMany({ - where: { - credentialId, - }, - }); - expect(caches).toHaveLength(1); - expect(JSON.parse(caches[0].key)).toEqual( - expect.objectContaining({ - items: itemsInKey, - }) - ); -} - -async function createSelectedCalendarForDelegationCredential(data: { - userId: number; - credentialId: number | null; - delegationCredentialId: string; - externalId: string; - integration: string; - googleChannelId: string | null; - googleChannelKind: string | null; - googleChannelResourceId: string | null; - googleChannelResourceUri: string | null; - googleChannelExpiration: string | null; -}) { - if (!data.delegationCredentialId) { - throw new Error("delegationCredentialId is required"); - } - return await prismock.selectedCalendar.create({ - data: { - ...data, - }, - }); -} - -/** - * The flow that sets CalendarCache must use CredentialForCalendarCache - */ - -async function createDelegationCredentialForCalendarCache({ - user, - delegatedTo, - delegationCredentialId, -}: { - user?: { email: string } | null; - delegatedTo?: typeof defaultDelegatedCredential; - delegationCredentialId: string; -}) { - delegatedTo = delegatedTo || defaultDelegatedCredential; - const credentialInDb = await createCredentialForCalendarService({ - user: user || { - email: "service@example.com", - }, - }); - - return { - ...createInMemoryCredential({ - userId: credentialInDb.userId!, - delegationCredentialId, - delegatedTo, - }), - ...credentialInDb, - }; -} - -const testSelectedCalendar = { - userId: 1, - integration: "google_calendar", - externalId: "example@cal.com", -}; - -const calendarCacheHelpers = { - FUTURE_EXPIRATION_DATE: new Date(Date.now() + 100000000), - getDatePair: () => { - // Keep it small to not go to next month - const timeDiffInMinutes = 1; - const dateFrom = new Date(Date.now()).toISOString(); - const dateTo = new Date(Date.now() + timeDiffInMinutes * 60 * 1000).toISOString(); - return { dateFrom, dateTo, minDateFrom: getTimeMin(dateFrom), maxDateTo: getTimeMax(dateTo) }; - }, - setCache: async ({ - credentialId, - key, - value, - userId, - expiresAt, - }: { - credentialId: number; - key: string; - value: string; - userId: number | null; - expiresAt: Date; - }) => { - log.info("Setting Calendar Cache", safeStringify({ key, value, expiresAt, credentialId, userId })); - await prismock.calendarCache.create({ - data: { - key, - value, - expiresAt, - credentialId, - userId, - }, - }); - }, - - setRegularCredentialCache: async ({ - credentialId, - userId, - key, - value, - expiresAt, - }: { - credentialId: number; - userId: number; - key: string; - value: string; - expiresAt: Date; - }) => { - await calendarCacheHelpers.setCache({ - credentialId, - key, - value, - userId, - expiresAt, - }); - }, -}; - -function expectGoogleSubscriptionToHaveOccurredAndClearMock({ calendarId }: { calendarId: string }) { - expect(calendarMock.calendar_v3.Calendar().events.watch).toHaveBeenCalledTimes(1); - expect(calendarMock.calendar_v3.Calendar().events.watch).toHaveBeenCalledWith( - expect.objectContaining({ - calendarId, - requestBody: expect.objectContaining({ - type: "web_hook", - token: process.env.GOOGLE_WEBHOOK_TOKEN, - }), - }) - ); - calendarMock.calendar_v3.Calendar().events.watch.mockClear(); -} - -function expectGoogleSubscriptionToNotHaveOccurredAndClearMock() { - expect(calendarMock.calendar_v3.Calendar().events.watch).not.toHaveBeenCalled(); - calendarMock.calendar_v3.Calendar().events.watch.mockClear(); -} - -function expectGoogleUnsubscriptionToHaveOccurredAndClearMock( - channels: { - resourceId: string; - channelId: string; - }[] -) { - expect(calendarMock.calendar_v3.Calendar().channels.stop).toHaveBeenCalledTimes(1); - channels.forEach((channel) => { - expect(calendarMock.calendar_v3.Calendar().channels.stop).toHaveBeenCalledWith({ - requestBody: { - resourceId: channel.resourceId, - id: channel.channelId, - }, - }); - }); - calendarMock.calendar_v3.Calendar().channels.stop.mockClear(); -} - -function expectGoogleUnsubscriptionToNotHaveOccurredAndClearMock() { - expect(calendarMock.calendar_v3.Calendar().channels.stop).not.toHaveBeenCalled(); - calendarMock.calendar_v3.Calendar().channels.stop.mockClear(); -} - -async function expectSelectedCalendarToHaveGoogleChannelProps( - id: string, - googleChannelProps: { - googleChannelId: string; - googleChannelKind: string; - googleChannelResourceId: string; - googleChannelResourceUri: string; - googleChannelExpiration: string; - } -) { - const selectedCalendar = await SelectedCalendarRepository.findById(id); - - expect(selectedCalendar).toEqual(expect.objectContaining(googleChannelProps)); -} - -async function expectSelectedCalendarToNotHaveGoogleChannelProps(selectedCalendarId: string) { - const selectedCalendar = await SelectedCalendarRepository.findFirst({ - where: { - id: selectedCalendarId, - }, - }); - - expect(selectedCalendar).toEqual( - expect.objectContaining({ - googleChannelId: null, - googleChannelKind: null, - googleChannelResourceId: null, - googleChannelResourceUri: null, - googleChannelExpiration: null, - }) - ); -} - beforeEach(() => { vi.clearAllMocks(); setCredentialsMock.mockClear(); @@ -257,904 +40,27 @@ beforeEach(() => { createMockJWTInstance({}); }); -// disabled as cache is being reworked -describe.skip("Calendar Cache", () => { - test("Calendar Cache is being read on cache HIT", async () => { - const credentialInDb1 = await createCredentialForCalendarService(); - const dateFrom1 = new Date().toISOString(); - const dateTo1 = new Date().toISOString(); - - // Create cache - const calendarCache = await CalendarCache.init(null); - await calendarCache.upsertCachedAvailability({ - credentialId: credentialInDb1.id, - userId: credentialInDb1.userId, - args: { - timeMin: getTimeMin(dateFrom1), - timeMax: getTimeMax(dateTo1), - items: [{ id: testSelectedCalendar.externalId }], - }, - value: JSON.parse( - JSON.stringify({ - calendars: [ - { - busy: [ - { - start: "2023-12-01T18:00:00Z", - end: "2023-12-01T19:00:00Z", - }, - ], - }, - ], - }) - ), - }); - - oAuthManagerMock.OAuthManager = defaultMockOAuthManager; - const calendarService = new CalendarService(credentialInDb1); - - // Test cache hit - const data = await calendarService.getAvailability(dateFrom1, dateTo1, [testSelectedCalendar]); - expect(data).toEqual([ - { - start: "2023-12-01T18:00:00Z", - end: "2023-12-01T19:00:00Z", - }, - ]); - }); - - 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(); - const dateFrom = new Date(Date.now()).toISOString(); - // Tweak date so that it's a cache miss - const dateTo = new Date(Date.now() + 100000000).toISOString(); - const calendarService = new CalendarService(credentialInDb); - - // Test Cache Miss - await calendarService.getAvailability(dateFrom, dateTo, [testSelectedCalendar]); - - // Expect cache to be ignored in case of a MISS - const cachedAvailability = await calendarCache.getCachedAvailability({ - credentialId: credentialInDb.id, - userId: null, - args: { - timeMin: dateFrom, - timeMax: dateTo, - items: [{ id: testSelectedCalendar.externalId }], - }, - }); - - expect(cachedAvailability).toBeNull(); - }); - - test("fetchAvailabilityAndSetCache should fetch and cache availability for selected calendars grouped by eventTypeId", async () => { - const credentialInDb = await createCredentialForCalendarService(); - const calendarService = new CalendarService(credentialInDb); - - const selectedCalendars = [ - { - externalId: "calendar1@test.com", - eventTypeId: 1, - }, - { - externalId: "calendar2@test.com", - eventTypeId: 1, - }, - { - externalId: "calendar1@test.com", - eventTypeId: 2, - }, - { - externalId: "calendar1@test.com", - eventTypeId: null, - }, - { - externalId: "calendar2@test.com", - eventTypeId: null, - }, - ]; - - const mockAvailabilityData = { busy: [] }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(calendarService, "fetchAvailability").mockResolvedValue(mockAvailabilityData as any); - const setAvailabilityInCacheSpy = vi.spyOn(calendarService, "setAvailabilityInCache"); - - await calendarService.fetchAvailabilityAndSetCache(selectedCalendars); - - // Should make 2 calls - one for each unique eventTypeId - expect(calendarService.fetchAvailability).toHaveBeenCalledTimes(3); - - // First call for eventTypeId 1 calendars - expect(calendarService.fetchAvailability).toHaveBeenNthCalledWith(1, { - timeMin: expect.any(String), - timeMax: expect.any(String), - items: [{ id: "calendar1@test.com" }, { id: "calendar2@test.com" }], - }); - - // Second call for eventTypeId 2 calendar - expect(calendarService.fetchAvailability).toHaveBeenNthCalledWith(2, { - timeMin: expect.any(String), - timeMax: expect.any(String), - items: [{ id: "calendar1@test.com" }], - }); - - // Second call for eventTypeId 2 calendar - expect(calendarService.fetchAvailability).toHaveBeenNthCalledWith(3, { - timeMin: expect.any(String), - timeMax: expect.any(String), - items: [{ id: "calendar1@test.com" }, { id: "calendar2@test.com" }], - }); - - // Should cache results for both calls - expect(setAvailabilityInCacheSpy).toHaveBeenCalledTimes(3); - expect(setAvailabilityInCacheSpy).toHaveBeenCalledWith( - expect.objectContaining({ - items: expect.any(Array), - }), - mockAvailabilityData - ); - }); - - test("A cache set through fetchAvailabilityAndSetCache should be used when doing getAvailability", async () => { - const credentialInDb = await createCredentialForCalendarService(); - const calendarService = new CalendarService(credentialInDb); - vi.setSystemTime(new Date("2025-04-01T00:00:00.000Z")); - setFullMockOAuthManagerRequest(); - const selectedCalendars = [ - { - externalId: "calendar1@test.com", - integration: "google_calendar", - eventTypeId: null, - credentialId: credentialInDb.id, - userId: credentialInDb.userId!, - }, - ]; - - const mockedBusyTimes = [{ end: "2025-04-02T18:30:00Z", start: "2025-04-01T18:30:00Z" }]; - // Mock Once so that the getAvailability call doesn't accidentally reuse this mock result - freebusyQueryMock.mockResolvedValueOnce({ - data: { - calendars: { - "calendar1@test.com": { - busy: mockedBusyTimes, - }, - }, - }, - }); - - const calendarCachesBefore = await prismock.calendarCache.findMany(); - expect(calendarCachesBefore).toHaveLength(0); - await calendarService.fetchAvailabilityAndSetCache(selectedCalendars); - const calendarCachesAfter = await prismock.calendarCache.findMany(); - console.log({ calendarCachesAfter }); - expect(calendarCachesAfter).toHaveLength(1); - const datesForWhichCachedAvailabilityIsUsed = [ - { - dateFrom: "2025-04-01T00:00:00.000Z", - dateTo: "2025-06-01T00:00:00.000Z", - }, - // Add more dates for which cached availability should be used - // { - // dateFrom: "2025-04-01T00:00:00.000Z", - // dateTo: "2025-04-30T00:00:00.000Z", - // }, - ]; - try { - for (const { dateFrom, dateTo } of datesForWhichCachedAvailabilityIsUsed) { - const result = await calendarService.getAvailability(dateFrom, dateTo, selectedCalendars, true); - expect(result).toEqual(mockedBusyTimes); - } - } catch (error) { - console.log({ error }); - throw "Looks like cache was not used"; - } - - // The following is not working because getBusyCalendarTimes expands the date range a bit. - // const busyCalendarTimesResult = await getBusyCalendarTimes( - // [credentialInDb], - // "2025-04-01T00:00:00.000Z", - // "2025-04-30T00:00:00.000Z", - // selectedCalendars, - // true - // ); - - // expect(busyCalendarTimesResult).toEqual(mockedBusyTimes); - }); -}); - -describe("Watching and unwatching calendar", () => { - test("Calendar can be watched and unwatched", async () => { - const credentialInDb1 = await createCredentialForCalendarService(); - const calendarService = new CalendarService(credentialInDb1); - - await calendarService.watchCalendar({ - calendarId: testSelectedCalendar.externalId, - eventTypeIds: [null], - }); - - // Watching a non-existent selectedCalendar creates it - const watchedCalendar = await prismock.selectedCalendar.findFirst({ - where: { - userId: credentialInDb1.userId!, - externalId: testSelectedCalendar.externalId, - integration: "google_calendar", - }, - }); - - expect(watchedCalendar).toEqual( - expect.objectContaining({ - userId: 1, - eventTypeId: null, - integration: "google_calendar", - externalId: "example@cal.com", - credentialId: 1, - delegationCredentialId: null, - googleChannelId: "mock-channel-id", - googleChannelKind: "api#channel", - googleChannelResourceId: "mock-resource-id", - googleChannelResourceUri: "mock-resource-uri", - googleChannelExpiration: "1111111111", - }) - ); - - expect(watchedCalendar?.id).toBeDefined(); - - await calendarService.unwatchCalendar({ - calendarId: testSelectedCalendar.externalId, - eventTypeIds: [null], - }); - const calendarAfterUnwatch = await prismock.selectedCalendar.findFirst({ - where: { - userId: credentialInDb1.userId!, - externalId: testSelectedCalendar.externalId, - integration: "google_calendar", - }, - }); - - expect(calendarAfterUnwatch).toEqual( - expect.objectContaining({ - userId: 1, - eventTypeId: null, - integration: "google_calendar", - externalId: "example@cal.com", - credentialId: 1, - delegationCredentialId: null, - googleChannelId: null, - googleChannelKind: null, - googleChannelResourceId: null, - googleChannelResourceUri: null, - googleChannelExpiration: null, - }) - ); - expect(calendarAfterUnwatch?.id).toBeDefined(); - }); - - describe("Delegation Credential", () => { - test("On watching a SelectedCalendar having delegationCredential, it should set googleChannelId and other props", async () => { - const delegationCredential1Member1 = await createInMemoryDelegationCredentialForCalendarService({ - user: { email: "user1@example.com" }, - delegationCredentialId: "delegation-credential-id-1", - }); - - await prismock.selectedCalendar.create({ - data: { - userId: delegationCredential1Member1.userId!, - externalId: testSelectedCalendar.externalId, - integration: "google_calendar", - }, - }); - - const calendarService = new CalendarService(delegationCredential1Member1); - await calendarService.watchCalendar({ - calendarId: testSelectedCalendar.externalId, - eventTypeIds: [null], - }); - - expectGoogleSubscriptionToHaveOccurredAndClearMock({ - calendarId: testSelectedCalendar.externalId, - }); - - const calendars = await prismock.selectedCalendar.findMany(); - // Ensure no new calendar is created - expect(calendars).toHaveLength(1); - const watchedCalendar = calendars[0]; - - await expectSelectedCalendarToHaveGoogleChannelProps(watchedCalendar.id, { - googleChannelId: "mock-channel-id", - googleChannelKind: "api#channel", - googleChannelResourceId: "mock-resource-id", - googleChannelResourceUri: "mock-resource-uri", - googleChannelExpiration: "1111111111", - }); - }); - - test("On unwatching a SelectedCalendar connected to Delegation Credential, it should remove googleChannelId and other props", async () => { - const delegationCredential1Member1 = await createDelegationCredentialForCalendarCache({ - user: { email: "user1@example.com" }, - delegationCredentialId: "delegation-credential-id-1", - }); - - const selectedCalendar = await createSelectedCalendarForDelegationCredential({ - userId: delegationCredential1Member1.userId!, - delegationCredentialId: delegationCredential1Member1.delegatedToId!, - credentialId: delegationCredential1Member1.id, - externalId: testSelectedCalendar.externalId, - integration: "google_calendar", - googleChannelId: "mock-channel-id", - googleChannelKind: "api#channel", - googleChannelResourceId: "mock-resource-id", - googleChannelResourceUri: "mock-resource-uri", - googleChannelExpiration: "1111111111", - }); - - const calendarService = new CalendarService(delegationCredential1Member1); - await calendarService.unwatchCalendar({ - calendarId: selectedCalendar.externalId, - eventTypeIds: [null], - }); - - expectGoogleUnsubscriptionToHaveOccurredAndClearMock([ - { - resourceId: "mock-resource-id", - channelId: "mock-channel-id", - }, - ]); - - const calendars = await prismock.selectedCalendar.findMany(); - expect(calendars).toHaveLength(1); - const calendarAfterUnwatch = calendars[0]; - - expectSelectedCalendarToNotHaveGoogleChannelProps(calendarAfterUnwatch.id); - }); - }); - - test("watchCalendar should not do google subscription if already subscribed for the same calendarId", async () => { - const credentialInDb1 = await createCredentialForCalendarService(); - const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id); - const userLevelCalendar = await SelectedCalendarRepository.create({ - userId: credentialInDb1.userId!, - externalId: "externalId@cal.com", - integration: "google_calendar", - eventTypeId: null, - credentialId: credentialInDb1.id, - }); - - const eventTypeLevelCalendar = await SelectedCalendarRepository.create({ - userId: credentialInDb1.userId!, - externalId: "externalId@cal.com", - integration: "google_calendar", - eventTypeId: 1, - credentialId: credentialInDb1.id, - }); - - await calendarCache.watchCalendar({ - calendarId: userLevelCalendar.externalId, - eventTypeIds: [userLevelCalendar.eventTypeId], - }); - - expectGoogleSubscriptionToHaveOccurredAndClearMock({ - calendarId: userLevelCalendar.externalId, - }); - - await expectSelectedCalendarToHaveGoogleChannelProps(userLevelCalendar.id, { - googleChannelId: "mock-channel-id", - googleChannelKind: "api#channel", - googleChannelResourceId: "mock-resource-id", - googleChannelResourceUri: "mock-resource-uri", - googleChannelExpiration: "1111111111", - }); - - // Watch different selectedcalendar with same externalId and credentialId - await calendarCache.watchCalendar({ - calendarId: eventTypeLevelCalendar.externalId, - eventTypeIds: [eventTypeLevelCalendar.eventTypeId], - }); - - expectGoogleSubscriptionToNotHaveOccurredAndClearMock(); - // Google Subscription didn't occur but still the eventTypeLevelCalendar has the same googleChannelProps - await expectSelectedCalendarToHaveGoogleChannelProps(eventTypeLevelCalendar.id, { - googleChannelId: "mock-channel-id", - googleChannelKind: "api#channel", - googleChannelResourceId: "mock-resource-id", - googleChannelResourceUri: "mock-resource-uri", - googleChannelExpiration: "1111111111", - }); - }); - - test("watchCalendar should do google subscription if already subscribed but for different calendarId", async () => { - const credentialInDb1 = await createCredentialForCalendarService(); - const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id); - const userLevelCalendar = await SelectedCalendarRepository.create({ - userId: credentialInDb1.userId!, - externalId: "externalId@cal.com", - integration: "google_calendar", - eventTypeId: null, - credentialId: credentialInDb1.id, - }); - - const eventTypeLevelCalendar = await SelectedCalendarRepository.create({ - userId: credentialInDb1.userId!, - externalId: "externalId2@cal.com", - integration: "google_calendar", - eventTypeId: 1, - credentialId: credentialInDb1.id, - }); - - await calendarCache.watchCalendar({ - calendarId: userLevelCalendar.externalId, - eventTypeIds: [userLevelCalendar.eventTypeId], - }); - - expectGoogleSubscriptionToHaveOccurredAndClearMock({ - calendarId: userLevelCalendar.externalId, - }); - - await expectSelectedCalendarToHaveGoogleChannelProps(userLevelCalendar.id, { - googleChannelId: "mock-channel-id", - googleChannelKind: "api#channel", - googleChannelResourceId: "mock-resource-id", - googleChannelResourceUri: "mock-resource-uri", - googleChannelExpiration: "1111111111", - }); - - // Watch different selectedcalendar with same externalId and credentialId - await calendarCache.watchCalendar({ - calendarId: eventTypeLevelCalendar.externalId, - eventTypeIds: [eventTypeLevelCalendar.eventTypeId], - }); - - expectGoogleSubscriptionToHaveOccurredAndClearMock({ - calendarId: eventTypeLevelCalendar.externalId, - }); - - // Google Subscription didn't occur but still the eventTypeLevelCalendar has the same googleChannelProps - await expectSelectedCalendarToHaveGoogleChannelProps(eventTypeLevelCalendar.id, { - googleChannelId: "mock-channel-id", - googleChannelKind: "api#channel", - googleChannelResourceId: "mock-resource-id", - googleChannelResourceUri: "mock-resource-uri", - googleChannelExpiration: "1111111111", - }); - }); - - test("unwatchCalendar should not unsubscribe from google if there is another selectedCalendar with same externalId and credentialId", async () => { - const credentialInDb1 = await createCredentialForCalendarService(); - const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id); - - await prismock.calendarCache.create({ - data: { - key: "test-key", - value: "test-value", - expiresAt: new Date(Date.now() + 100000000), - credentialId: credentialInDb1.id, - }, - }); - - const someOtherCache = await prismock.calendarCache.create({ - data: { - key: JSON.stringify({ - items: [{ id: "someOtherExternalId@cal.com" }], - }), - value: "test-value-2", - expiresAt: new Date(Date.now() + 100000000), - credentialId: 999, - }, - }); - - const googleChannelProps = { - googleChannelId: "test-channel-id", - googleChannelKind: "api#channel", - googleChannelResourceId: "test-resource-id", - googleChannelResourceUri: "test-resource-uri", - googleChannelExpiration: "1111111111", - }; - - const commonProps = { - userId: credentialInDb1.userId!, - externalId: "externalId@cal.com", - integration: "google_calendar", - credentialId: credentialInDb1.id, - ...googleChannelProps, - }; - - const userLevelCalendar = await SelectedCalendarRepository.create({ - ...commonProps, - eventTypeId: null, - }); - - const eventTypeLevelCalendar = await SelectedCalendarRepository.create({ - ...commonProps, - eventTypeId: 1, - }); - - const eventTypeLevelCalendarForSomeOtherExternalIdButSameCredentialId = - await SelectedCalendarRepository.create({ - ...commonProps, - externalId: "externalId2@cal.com", - eventTypeId: 2, - }); - - await calendarCache.unwatchCalendar({ - calendarId: userLevelCalendar.externalId, - eventTypeIds: [userLevelCalendar.eventTypeId], - }); - // There is another selectedCalendar with same externalId and credentialId, so actual unsubscription does not happen - expectGoogleUnsubscriptionToNotHaveOccurredAndClearMock(); - await expectSelectedCalendarToNotHaveGoogleChannelProps(userLevelCalendar.id); - - await calendarCache.unwatchCalendar({ - calendarId: eventTypeLevelCalendar.externalId, - eventTypeIds: [eventTypeLevelCalendar.eventTypeId], - }); - - expectGoogleUnsubscriptionToHaveOccurredAndClearMock([ - { - resourceId: "test-resource-id", - channelId: "test-channel-id", - }, - ]); - - // Concerned cache will just have remaining externalIds - await expectCacheToBeSet({ - credentialId: credentialInDb1.id, - itemsInKey: [{ id: eventTypeLevelCalendarForSomeOtherExternalIdButSameCredentialId.externalId }], - }); - - await expectCacheToBeSet({ - credentialId: someOtherCache.credentialId, - itemsInKey: JSON.parse(someOtherCache.key).items, - }); - - await expectSelectedCalendarToNotHaveGoogleChannelProps(eventTypeLevelCalendar.id); - - // Some other selectedCalendar stays unaffected - await expectSelectedCalendarToHaveGoogleChannelProps( - eventTypeLevelCalendarForSomeOtherExternalIdButSameCredentialId.id, - googleChannelProps - ); - }); -}); +const mockCredential: CredentialForCalendarServiceWithEmail = { + id: 1, + userId: 1, + appId: "google-calendar", + type: "google_calendar", + key: { + access_token: "" + }, + user: { + email: "user@example.com", + }, + delegationCredentialId: null, + delegatedTo: null, + invalid: false, + teamId: null, +}; describe("getAvailability", () => { test("returns availability for selected calendars", async () => { - const credential = await createCredentialForCalendarService(); - const calendarService = new CalendarService(credential); + + const calendarService = new CalendarService(mockCredential); setFullMockOAuthManagerRequest(); const mockedBusyTimes1 = [ { @@ -1203,7 +109,6 @@ describe("getAvailability", () => { "2024-01-01", "2024-01-02", [], - false, true ); @@ -1213,7 +118,6 @@ describe("getAvailability", () => { "2024-01-01", "2024-01-02", [], - false, false ); @@ -1223,8 +127,7 @@ describe("getAvailability", () => { describe("getPrimaryCalendar", () => { test("should fetch primary calendar using 'primary' keyword", async () => { - const credential = await createCredentialForCalendarService(); - const calendarService = new CalendarService(credential); + const calendarService = new CalendarService(mockCredential); setFullMockOAuthManagerRequest(); const mockPrimaryCalendar = { id: "user@example.com", @@ -1426,8 +329,7 @@ describe("Date Optimization Benchmarks", () => { }); test("fetchAvailabilityData should handle both single API call and chunked scenarios correctly", async () => { - const credential = await createCredentialForCalendarService(); - const calendarService = new CalendarService(credential); + const calendarService = new CalendarService(mockCredential); setFullMockOAuthManagerRequest(); const mockBusyData = [ @@ -1436,8 +338,8 @@ describe("Date Optimization Benchmarks", () => { ]; // Mock the getCacheOrFetchAvailability method to return consistent data - const getCacheOrFetchAvailabilitySpy = vi - .spyOn(calendarService as any, "getCacheOrFetchAvailability") + const getFreeBusyDataSpy = vi + .spyOn(calendarService as any, "getFreeBusyData") .mockResolvedValue(mockBusyData.map((item) => ({ ...item, id: "test@calendar.com" }))); // Test single API call scenario (≤ 90 days) @@ -1449,9 +351,9 @@ describe("Date Optimization Benchmarks", () => { ); expect(shortRangeResult).toEqual(mockBusyData); - expect(getCacheOrFetchAvailabilitySpy).toHaveBeenCalledTimes(1); + expect(getFreeBusyDataSpy).toHaveBeenCalledTimes(1); - getCacheOrFetchAvailabilitySpy.mockClear(); + getFreeBusyDataSpy.mockClear(); // Test chunked scenario (> 90 days) const longRangeResult = await (calendarService as any).fetchAvailabilityData( @@ -1463,16 +365,15 @@ describe("Date Optimization Benchmarks", () => { // Should return concatenated results from multiple chunks expect(longRangeResult.length).toBeGreaterThan(0); - expect(getCacheOrFetchAvailabilitySpy).toHaveBeenCalledTimes(3); // 182 days / 90 = ~2.02 -> 3 chunks + expect(getFreeBusyDataSpy).toHaveBeenCalledTimes(3); // 182 days / 90 = ~2.02 -> 3 chunks - getCacheOrFetchAvailabilitySpy.mockRestore(); + getFreeBusyDataSpy.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); + const calendarService = new CalendarService(mockCredential); setFullMockOAuthManagerRequest(); // Mock Google Calendar API response @@ -1550,9 +451,9 @@ describe("createEvent", () => { integration: "google_calendar", externalId: "primary", primaryEmail: null, - userId: credential.userId, + userId: mockCredential.userId, eventTypeId: null, - credentialId: credential.id, + credentialId: mockCredential.id, delegationCredentialId: null, domainWideDelegationCredentialId: null, createdAt: new Date("2024-06-15T11:00:00Z"), @@ -1567,7 +468,7 @@ describe("createEvent", () => { }; // Call createEvent and verify result using inline snapshot - const result = await calendarService.createEvent(testCalEvent, credential.id); + const result = await calendarService.createEvent(testCalEvent, mockCredential.id); // Verify input processing - check that Google API was called with correct payload expect(eventsInsertMock).toHaveBeenCalledTimes(1); @@ -1670,8 +571,7 @@ describe("createEvent", () => { }); test("should handle recurring events correctly", async () => { - const credential = await createCredentialForCalendarService(); - const calendarService = new CalendarService(credential); + const calendarService = new CalendarService(mockCredential); setFullMockOAuthManagerRequest(); // Mock recurring event response @@ -1725,9 +625,9 @@ describe("createEvent", () => { integration: "google_calendar", externalId: "primary", primaryEmail: null, - userId: credential.userId, + userId: mockCredential.userId, eventTypeId: null, - credentialId: credential.id, + credentialId: mockCredential.id, delegationCredentialId: null, domainWideDelegationCredentialId: null, createdAt: new Date("2024-06-15T11:00:00Z"), @@ -1737,7 +637,7 @@ describe("createEvent", () => { calendarDescription: "Weekly team meeting", }; - const result = await calendarService.createEvent(recurringCalEvent, credential.id); + const result = await calendarService.createEvent(recurringCalEvent, mockCredential.id); // Use inline snapshot for recurring event result expect(result).toMatchInlineSnapshot(` diff --git a/packages/app-store/googlecalendar/lib/__tests__/getFreeBusyResult.test.ts b/packages/app-store/googlecalendar/lib/__tests__/getFreeBusyResult.test.ts deleted file mode 100644 index 64dfea74c9..0000000000 --- a/packages/app-store/googlecalendar/lib/__tests__/getFreeBusyResult.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { expect, test, beforeEach, vi, describe } from "vitest"; -import "vitest-fetch-mock"; - -import CalendarService from "../CalendarService"; - -describe("GoogleCalendarService.getFreeBusyResult - shouldServeCache logic", () => { - let calendarService: CalendarService; - let fetchAvailabilitySpy: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - - calendarService = {} as CalendarService; - - const mockFetchAvailability = vi.fn().mockResolvedValue({ - calendars: { - "test@example.com": { - busy: [ - { - start: "2023-12-01T20:00:00Z", - end: "2023-12-01T21:00:00Z", - }, - ], - }, - }, - }); - - calendarService.fetchAvailability = mockFetchAvailability; - fetchAvailabilitySpy = mockFetchAvailability; - - calendarService.getFreeBusyResult = CalendarService.prototype.getFreeBusyResult.bind(calendarService); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (calendarService as any).credential = { id: 1, userId: 1 }; - }); - - describe("shouldServeCache parameter handling", () => { - test("should call fetchAvailability immediately when shouldServeCache is explicitly false", async () => { - const args = { - timeMin: new Date().toISOString(), - timeMax: new Date().toISOString(), - items: [{ id: "test@example.com" }], - }; - - const result = await calendarService.getFreeBusyResult(args, false); - - expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args); - expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1); - expect(result.calendars?.["test@example.com"]?.busy).toEqual([ - { - start: "2023-12-01T20:00:00Z", - end: "2023-12-01T21:00:00Z", - }, - ]); - }); - - test("should call fetchAvailability immediately when shouldServeCache is undefined (falsey)", async () => { - const args = { - timeMin: new Date().toISOString(), - timeMax: new Date().toISOString(), - items: [{ id: "test@example.com" }], - }; - - const result = await calendarService.getFreeBusyResult(args, undefined); - - expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args); - expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1); - expect(result.calendars?.["test@example.com"]?.busy).toEqual([ - { - start: "2023-12-01T20:00:00Z", - end: "2023-12-01T21:00:00Z", - }, - ]); - }); - - test("should call fetchAvailability immediately when shouldServeCache is null (falsey)", async () => { - const args = { - timeMin: new Date().toISOString(), - timeMax: new Date().toISOString(), - items: [{ id: "test@example.com" }], - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await calendarService.getFreeBusyResult(args, null as any); - - expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args); - expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1); - expect(result.calendars?.["test@example.com"]?.busy).toEqual([ - { - start: "2023-12-01T20:00:00Z", - end: "2023-12-01T21:00:00Z", - }, - ]); - }); - - test("should call fetchAvailability immediately when shouldServeCache is 0 (falsey)", async () => { - const args = { - timeMin: new Date().toISOString(), - timeMax: new Date().toISOString(), - items: [{ id: "test@example.com" }], - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await calendarService.getFreeBusyResult(args, 0 as any); - - expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args); - expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1); - expect(result.calendars?.["test@example.com"]?.busy).toEqual([ - { - start: "2023-12-01T20:00:00Z", - end: "2023-12-01T21:00:00Z", - }, - ]); - }); - - test("should call fetchAvailability immediately when shouldServeCache is empty string (falsey)", async () => { - const args = { - timeMin: new Date().toISOString(), - timeMax: new Date().toISOString(), - items: [{ id: "test@example.com" }], - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await calendarService.getFreeBusyResult(args, "" as any); - - expect(fetchAvailabilitySpy).toHaveBeenCalledWith(args); - expect(fetchAvailabilitySpy).toHaveBeenCalledTimes(1); - expect(result.calendars?.["test@example.com"]?.busy).toEqual([ - { - start: "2023-12-01T20:00:00Z", - end: "2023-12-01T21:00:00Z", - }, - ]); - }); - }); -}); diff --git a/packages/app-store/office365video/lib/VideoApiAdapter.test.ts b/packages/app-store/office365video/lib/VideoApiAdapter.test.ts index 7d3917b8b1..d8939105a5 100644 --- a/packages/app-store/office365video/lib/VideoApiAdapter.test.ts +++ b/packages/app-store/office365video/lib/VideoApiAdapter.test.ts @@ -60,7 +60,6 @@ const testCredential = { describe("createMeeting", () => { test("Successful `createMeeting` call", async () => { - prismaMock.calendarCache.findUnique; const videoApi = VideoApiAdapter(testCredential); @@ -109,7 +108,6 @@ describe("createMeeting", () => { }); test(" `createMeeting` when there is no joinWebUrl and only joinUrl", async () => { - prismaMock.calendarCache.findUnique; const videoApi = VideoApiAdapter(testCredential); diff --git a/packages/features/apps/components/CredentialActionsDropdown.tsx b/packages/features/apps/components/CredentialActionsDropdown.tsx index 169e46c4d3..8031ecb96b 100644 --- a/packages/features/apps/components/CredentialActionsDropdown.tsx +++ b/packages/features/apps/components/CredentialActionsDropdown.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants"; import { trpc } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; import { ConfirmationDialogContent } from "@calcom/ui/components/dialog"; @@ -19,8 +18,6 @@ import { showToast } from "@calcom/ui/components/toast"; interface CredentialActionsDropdownProps { credentialId: number; - integrationType: string; - cacheUpdatedAt?: Date | null; onSuccess?: () => void; delegationCredentialId?: string | null; disableConnectionModification?: boolean; @@ -28,27 +25,14 @@ interface CredentialActionsDropdownProps { export default function CredentialActionsDropdown({ credentialId, - integrationType, - cacheUpdatedAt, onSuccess, delegationCredentialId, disableConnectionModification, }: CredentialActionsDropdownProps) { const { t } = useLocale(); const [dropdownOpen, setDropdownOpen] = useState(false); - const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [disconnectModalOpen, setDisconnectModalOpen] = useState(false); - const deleteCacheMutation = trpc.viewer.calendars.deleteCache.useMutation({ - onSuccess: () => { - showToast(t("cache_deleted_successfully"), "success"); - onSuccess?.(); - }, - onError: () => { - showToast(t("error_deleting_cache"), "error"); - }, - }); - const utils = trpc.useUtils(); const disconnectMutation = trpc.viewer.credentials.delete.useMutation({ onSuccess: () => { @@ -64,11 +48,9 @@ export default function CredentialActionsDropdown({ }, }); - const isGoogleCalendar = integrationType === GOOGLE_CALENDAR_TYPE; const canDisconnect = !delegationCredentialId && !disableConnectionModification; - const hasCache = isGoogleCalendar && cacheUpdatedAt; - if (!canDisconnect && !hasCache) { + if (!canDisconnect) { return null; } @@ -79,37 +61,6 @@ export default function CredentialActionsDropdown({