perf: (googlecalendar): batch freebusy calls by delegation credential (#24332)
* perf(googlecalendar): batch freebusy calls by delegation credential - Group selectedCalendars by delegationCredentialId before making API calls - Make one batched freebusy query per delegation credential group - Reduces total API calls while respecting credential boundaries - Maintains existing caching behavior per group - Updated both getAvailability and getAvailabilityWithTimeZones methods - Added groupCalendarsByDelegationCredential helper method - Handles edge case when no calendars provided but fallbackToPrimary is true - Fixed linting issue: replaced hasOwnProperty with 'in' operator Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * test(googlecalendar): add test for delegation credential batching - Verify calendars are grouped by delegationCredentialId - Ensure exactly 3 API calls made for 3 delegation credential groups - Confirm all busy times from different groups are properly returned - Fix type-safety issues by replacing 'as any' with proper type constraints - Fix ESLint warnings: unused variables and any types in mock functions Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * fix(googlecalendar): ensure fallback logic works with empty calendar groups - Handle empty calendar groups by ensuring at least one iteration - Add test for chunking groups larger than 50 calendars - Verify all delegation credential batching logic works correctly Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * fix: fix remaining type errors from merge conflict resolution - Changed getCacheOrFetchAvailability to getFreeBusyData in getAvailabilityWithTimeZones - Removed orphaned merge conflict marker in test file Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * fix: reset test file to original PR version and update method name - Reset CalendarService.test.ts to original PR version (0e9eb9e97a) - Updated getCacheOrFetchAvailability to getFreeBusyData to match main branch Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * fix: reset test file to main branch version The original PR's test file had tests for caching features that have been removed from main. Reset to main's version to fix type errors. Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * test(googlecalendar): add tests for delegation credential batching logic Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * Revamp devin's implementation * Remove comsoles.log * fix(tests): update delegation credential batching tests to match reimplementation - Remove tests for private methods that no longer exist (groupCalendarsByDelegationCredential, chunkArray) - Update getAvailability test to verify calendar fetching without expecting multiple API calls per delegation credential - Keep existing tests for fallback to primary calendar and non-google calendar handling Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * Remove dead code * improve documentation * docs: add README explaining Google Calendar availability batching feature Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * docs: translate README to English Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * docs: move README to calendar-batch package with comprehensive documentation - Remove README from googlecalendar lib (wrong location) - Add comprehensive README to packages/features/calendar-batch/ - Document CalendarBatchService and CalendarBatchWrapper - Explain how getCalendar() integrates with batching - Include architecture, data model, and performance considerations Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * fix: address cubic-dev-ai review comments - Remove misleading comment from getCalendar.ts cache block - Fix typo 'optmization' -> 'optimization' in comment - Add comprehensive tests for CalendarBatchWrapper batching behavior - Test separate calls for calendars without delegationCredentialId - Test batching calendars with same delegationCredentialId - Test chunking into groups of 50 for API limits - Test mixed calendars handling - Test fallbackToPrimary with empty array - Test result flattening from batched calls - Test pass-through methods delegation Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * fix: add shouldServeCache param to CalendarBatchWrapper and verify batching call count - Fix CalendarBatchWrapper.getAvailability signature to match Calendar interface (add shouldServeCache param) - Update CalendarBatchWrapper tests to pass shouldServeCache parameter - Add integration test in CalendarService.test.ts that verifies CalendarBatchWrapper makes separate API calls for different delegationCredentialIds (call count assertion) - This fixes the getCalendarsEvents test failures caused by signature mismatch Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * Improve CalendarBatchImplementation * test: add shouldServeCache forwarding and order-independent batching verification tests Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * test: make CalendarBatchWrapper tests order-independent Refactored tests to avoid relying on Promise.all execution order: - 'should make separate calls for calendars without delegationCredentialId' now uses set comparison instead of toHaveBeenNthCalledWith - 'should batch calendars with the same delegationCredentialId together' now finds calls by delegation credential instead of call order This addresses Sean's review comment about potential flakiness due to Promise.all not guaranteeing execution order of parallel promises. Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * Remove hardcoded ID * test: add comprehensive tests for resolveCalendarServeStrategy Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * feat: use Promise.allSettled for partial failure handling in CalendarBatchWrapper - Changed Promise.all to Promise.allSettled in getAvailability and getAvailabilityWithTimeZones - Returns partial results when some batches fail instead of failing entirely - Logs warnings for failed batches with error details - Added tests for partial failure scenarios Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * test: add comprehensive tests for getAvailabilityWithTimeZones - Added batching behavior tests (separate calls, batching by delegationCredentialId, chunking) - Added partial failure handling tests (partial results, all fail, no throw) - Added edge case test for underlying calendar not implementing the method - Total: 24 tests now covering both getAvailability and getAvailabilityWithTimeZones Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * fix: add required serviceAccountKey fields to delegatedTo mock objects Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * fix: add missing client_id and private_key to serviceAccountKey mock Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
This commit is contained in:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
keith@cal.com <keithwillcode@gmail.com>
parent
ed0c9392a9
commit
253c33df33
@@ -0,0 +1,408 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { Calendar } from "@calcom/types/Calendar";
|
||||
import type { CredentialForCalendarService } from "@calcom/types/Credential";
|
||||
|
||||
// Type for test results that may include wrapper markers
|
||||
interface TestCalendarResult extends Calendar {
|
||||
__isCacheWrapper?: boolean;
|
||||
__isBatchWrapper?: boolean;
|
||||
}
|
||||
|
||||
// Type for mock delegation credential - matches CredentialForCalendarService.delegatedTo
|
||||
interface MockDelegationCredential {
|
||||
id?: string;
|
||||
serviceAccountKey: {
|
||||
client_email?: string;
|
||||
tenant_id?: string;
|
||||
client_id: string;
|
||||
private_key: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Type for mock features repository
|
||||
interface MockFeaturesRepository {
|
||||
checkIfFeatureIsEnabledGlobally: ReturnType<typeof vi.fn>;
|
||||
checkIfUserHasFeatureNonHierarchical: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
// Mock the dependencies before importing the module under test
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/flags/features.repository", () => ({
|
||||
FeaturesRepository: vi.fn().mockImplementation(() => ({
|
||||
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(false),
|
||||
checkIfUserHasFeatureNonHierarchical: vi.fn().mockResolvedValue(false),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/CalendarSubscriptionService", () => ({
|
||||
CalendarSubscriptionService: {
|
||||
CALENDAR_SUBSCRIPTION_CACHE_FEATURE: "calendar-subscription-cache",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository", () => ({
|
||||
CalendarCacheEventRepository: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService", () => ({
|
||||
CalendarCacheEventService: {
|
||||
isCalendarTypeSupported: vi.fn().mockReturnValue(false),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheWrapper", () => ({
|
||||
CalendarCacheWrapper: vi.fn().mockImplementation(({ originalCalendar }) => ({
|
||||
...originalCalendar,
|
||||
__isCacheWrapper: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-batch/lib/CalendarBatchService", () => ({
|
||||
CalendarBatchService: {
|
||||
isSupported: vi.fn().mockReturnValue(false),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-batch/lib/CalendarBatchWrapper", () => ({
|
||||
CalendarBatchWrapper: vi.fn().mockImplementation(({ originalCalendar }) => ({
|
||||
...originalCalendar,
|
||||
__isBatchWrapper: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/logger", () => ({
|
||||
default: {
|
||||
getSubLogger: () => ({
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the CalendarServiceMap
|
||||
vi.mock("../../calendar.services.generated", () => ({
|
||||
CalendarServiceMap: {
|
||||
googlecalendar: Promise.resolve({
|
||||
default: vi.fn().mockImplementation(() => createMockCalendar()),
|
||||
}),
|
||||
office365calendar: Promise.resolve({
|
||||
default: vi.fn().mockImplementation(() => createMockCalendar()),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
import { CalendarBatchService } from "@calcom/features/calendar-batch/lib/CalendarBatchService";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { CalendarCacheWrapper } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheWrapper";
|
||||
import { CalendarBatchWrapper } from "@calcom/features/calendar-batch/lib/CalendarBatchWrapper";
|
||||
|
||||
import { getCalendar } from "../getCalendar";
|
||||
|
||||
function createMockCalendar(): Calendar {
|
||||
return {
|
||||
getAvailability: vi.fn().mockResolvedValue([]),
|
||||
createEvent: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
deleteEvent: vi.fn(),
|
||||
listCalendars: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as Calendar;
|
||||
}
|
||||
|
||||
function createMockCredential(
|
||||
overrides: Partial<CredentialForCalendarService> & { delegatedTo?: MockDelegationCredential | null } = {}
|
||||
): CredentialForCalendarService {
|
||||
return {
|
||||
id: 1,
|
||||
type: "google_calendar",
|
||||
key: { access_token: "test-token" },
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
appId: "google-calendar",
|
||||
invalid: false,
|
||||
delegatedTo: null,
|
||||
...overrides,
|
||||
} as CredentialForCalendarService;
|
||||
}
|
||||
|
||||
describe("getCalendar", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveCalendarServeStrategy", () => {
|
||||
test("should return CalendarCacheWrapper when cache is supported and enabled", async () => {
|
||||
const mockCredential = createMockCredential({ type: "google_calendar" });
|
||||
|
||||
// Enable cache support
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(true);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(false);
|
||||
|
||||
const result = (await getCalendar(mockCredential, true)) as TestCalendarResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.__isCacheWrapper).toBe(true);
|
||||
expect(CalendarCacheWrapper).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return CalendarBatchWrapper when cache is not supported but batch is supported", async () => {
|
||||
const mockCredential = createMockCredential({
|
||||
type: "google_calendar",
|
||||
delegatedTo: {
|
||||
id: "delegation-1",
|
||||
serviceAccountKey: { client_id: "test-client-id", private_key: "test-private-key" },
|
||||
},
|
||||
});
|
||||
|
||||
// Disable cache, enable batch
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(false);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(true);
|
||||
|
||||
const result = (await getCalendar(mockCredential, false)) as TestCalendarResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.__isBatchWrapper).toBe(true);
|
||||
expect(CalendarBatchWrapper).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return CalendarBatchWrapper when cache is supported but explicitly disabled", async () => {
|
||||
const mockCredential = createMockCredential({
|
||||
type: "google_calendar",
|
||||
delegatedTo: {
|
||||
id: "delegation-1",
|
||||
serviceAccountKey: { client_id: "test-client-id", private_key: "test-private-key" },
|
||||
},
|
||||
});
|
||||
|
||||
// Cache supported but disabled via shouldServeCache=false, batch enabled
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(true);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(true);
|
||||
|
||||
const result = (await getCalendar(mockCredential, false)) as TestCalendarResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.__isBatchWrapper).toBe(true);
|
||||
expect(CalendarBatchWrapper).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return original calendar when neither cache nor batch is supported", async () => {
|
||||
const mockCredential = createMockCredential({ type: "google_calendar" });
|
||||
|
||||
// Disable both cache and batch
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(false);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(false);
|
||||
|
||||
const result = (await getCalendar(mockCredential, false)) as TestCalendarResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.__isCacheWrapper).toBeUndefined();
|
||||
expect(result.__isBatchWrapper).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should prioritize cache wrapper over batch wrapper when both are supported", async () => {
|
||||
const mockCredential = createMockCredential({
|
||||
type: "google_calendar",
|
||||
delegatedTo: {
|
||||
id: "delegation-1",
|
||||
serviceAccountKey: { client_id: "test-client-id", private_key: "test-private-key" },
|
||||
},
|
||||
});
|
||||
|
||||
// Enable both cache and batch
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(true);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(true);
|
||||
|
||||
const result = (await getCalendar(mockCredential, true)) as TestCalendarResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
// Cache should take precedence
|
||||
expect(result.__isCacheWrapper).toBe(true);
|
||||
expect(result.__isBatchWrapper).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should determine shouldServeCache from feature flags when not provided", async () => {
|
||||
const mockCredential = createMockCredential({ type: "google_calendar" });
|
||||
|
||||
// Mock FeaturesRepository to return true for both checks
|
||||
const mockFeaturesRepository: MockFeaturesRepository = {
|
||||
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
|
||||
checkIfUserHasFeatureNonHierarchical: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
vi.mocked(FeaturesRepository).mockImplementation(
|
||||
() => mockFeaturesRepository as unknown as InstanceType<typeof FeaturesRepository>
|
||||
);
|
||||
|
||||
// Enable cache support
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(true);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(false);
|
||||
|
||||
// Call without shouldServeCache parameter
|
||||
const result = await getCalendar(mockCredential);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(mockFeaturesRepository.checkIfFeatureIsEnabledGlobally).toHaveBeenCalled();
|
||||
expect(mockFeaturesRepository.checkIfUserHasFeatureNonHierarchical).toHaveBeenCalledWith(
|
||||
mockCredential.userId,
|
||||
"calendar-subscription-cache"
|
||||
);
|
||||
});
|
||||
|
||||
test("should not use cache when feature flag is disabled globally", async () => {
|
||||
const mockCredential = createMockCredential({ type: "google_calendar" });
|
||||
|
||||
// Mock FeaturesRepository to return false for global check
|
||||
const mockFeaturesRepository: MockFeaturesRepository = {
|
||||
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(false),
|
||||
checkIfUserHasFeatureNonHierarchical: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
vi.mocked(FeaturesRepository).mockImplementation(
|
||||
() => mockFeaturesRepository as unknown as InstanceType<typeof FeaturesRepository>
|
||||
);
|
||||
|
||||
// Enable cache support at service level
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(true);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(false);
|
||||
|
||||
// Call without shouldServeCache parameter
|
||||
const result = (await getCalendar(mockCredential)) as TestCalendarResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
// Should not be cache wrapper because feature flag is disabled
|
||||
expect(result.__isCacheWrapper).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should not use cache when user does not have feature enabled", async () => {
|
||||
const mockCredential = createMockCredential({ type: "google_calendar" });
|
||||
|
||||
// Mock FeaturesRepository to return true globally but false for user
|
||||
const mockFeaturesRepository: MockFeaturesRepository = {
|
||||
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
|
||||
checkIfUserHasFeatureNonHierarchical: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
vi.mocked(FeaturesRepository).mockImplementation(
|
||||
() => mockFeaturesRepository as unknown as InstanceType<typeof FeaturesRepository>
|
||||
);
|
||||
|
||||
// Enable cache support at service level
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(true);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(false);
|
||||
|
||||
// Call without shouldServeCache parameter
|
||||
const result = (await getCalendar(mockCredential)) as TestCalendarResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
// Should not be cache wrapper because user doesn't have feature
|
||||
expect(result.__isCacheWrapper).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CalendarBatchService.isSupported integration", () => {
|
||||
test("should use batch wrapper for google_calendar with delegatedTo credential", async () => {
|
||||
const mockCredential = createMockCredential({
|
||||
type: "google_calendar",
|
||||
delegatedTo: {
|
||||
id: "delegation-credential-id",
|
||||
serviceAccountKey: {
|
||||
client_email: "test@test.iam.gserviceaccount.com",
|
||||
client_id: "test-client-id",
|
||||
private_key: "test-private-key",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Disable cache, enable batch
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(false);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(true);
|
||||
|
||||
const result = (await getCalendar(mockCredential, false)) as TestCalendarResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.__isBatchWrapper).toBe(true);
|
||||
expect(CalendarBatchService.isSupported).toHaveBeenCalledWith(mockCredential);
|
||||
});
|
||||
|
||||
test("should not use batch wrapper for google_calendar without delegatedTo credential", async () => {
|
||||
const mockCredential = createMockCredential({
|
||||
type: "google_calendar",
|
||||
delegatedTo: null,
|
||||
});
|
||||
|
||||
// Disable cache, batch will return false for non-delegated
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(false);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(false);
|
||||
|
||||
const result = (await getCalendar(mockCredential, false)) as TestCalendarResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.__isBatchWrapper).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should not use batch wrapper for non-google calendar types", async () => {
|
||||
const mockCredential = createMockCredential({
|
||||
type: "office365_calendar",
|
||||
delegatedTo: {
|
||||
id: "delegation-1",
|
||||
serviceAccountKey: { client_id: "test-client-id", private_key: "test-private-key" },
|
||||
},
|
||||
});
|
||||
|
||||
// Disable cache, batch returns false for non-google
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(false);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(false);
|
||||
|
||||
const result = (await getCalendar(mockCredential, false)) as TestCalendarResult;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.__isBatchWrapper).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("should return null when credential is null", async () => {
|
||||
const result = await getCalendar(null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null when credential.key is missing", async () => {
|
||||
const mockCredential = createMockCredential();
|
||||
Object.assign(mockCredential, { key: null });
|
||||
|
||||
const result = await getCalendar(mockCredential);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should handle _other_calendar suffix in credential type", async () => {
|
||||
const mockCredential = createMockCredential({
|
||||
type: "google_calendar_other_calendar",
|
||||
});
|
||||
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(false);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(false);
|
||||
|
||||
const result = await getCalendar(mockCredential, false);
|
||||
|
||||
// Should still work by stripping the suffix
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("should handle _crm suffix in credential type", async () => {
|
||||
const mockCredential = createMockCredential({
|
||||
type: "google_calendar_crm",
|
||||
});
|
||||
|
||||
vi.mocked(CalendarCacheEventService.isCalendarTypeSupported).mockReturnValue(false);
|
||||
vi.mocked(CalendarBatchService.isSupported).mockReturnValue(false);
|
||||
|
||||
const result = await getCalendar(mockCredential, false);
|
||||
|
||||
// Should still work by stripping the suffix
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CalendarBatchService } from "@calcom/features/calendar-batch/lib/CalendarBatchService";
|
||||
import { CalendarBatchWrapper } from "@calcom/features/calendar-batch/lib/CalendarBatchWrapper";
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
@@ -42,6 +44,20 @@ export const getCalendar = async (
|
||||
log.warn(`calendar of type ${calendarType} is not implemented`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
const originalCalendar = new CalendarService(credential as any);
|
||||
return resolveCalendarServeStrategy(originalCalendar, credential, shouldServeCache);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve best calendar strategy for current calendar and credential
|
||||
*/
|
||||
const resolveCalendarServeStrategy = async (
|
||||
originalCalendar: Calendar,
|
||||
credential: CredentialForCalendarService,
|
||||
shouldServeCache?: boolean
|
||||
): Promise<Calendar> => {
|
||||
// if shouldServeCache is not supplied, determine on the fly.
|
||||
if (typeof shouldServeCache === "undefined") {
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
@@ -58,20 +74,26 @@ export const getCalendar = async (
|
||||
);
|
||||
shouldServeCache = isCalendarSubscriptionCacheEnabled && isCalendarSubscriptionCacheEnabledForUser;
|
||||
}
|
||||
if (CalendarCacheEventService.isCalendarTypeSupported(calendarType) && shouldServeCache) {
|
||||
log.info(`Calendar Cache is enabled, using CalendarCacheService for credential ${credential.id}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const originalCalendar = new CalendarService(credential as any);
|
||||
if (originalCalendar) {
|
||||
// return cacheable calendar
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
return new CalendarCacheWrapper({
|
||||
originalCalendar,
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
}
|
||||
if (CalendarCacheEventService.isCalendarTypeSupported(credential.type) && shouldServeCache) {
|
||||
log.info("Calendar Cache is enabled, using CalendarCacheService for credential", {
|
||||
credentialId: credential.id,
|
||||
});
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
return new CalendarCacheWrapper({
|
||||
originalCalendar: originalCalendar as unknown as Calendar,
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
} else if (CalendarBatchService.isSupported(credential)) {
|
||||
// If calendar cache isn't supported, we try calendar batch as the second layer of optimization
|
||||
log.info("Calendar Batch is supported, using CalendarBatchService for credential", {
|
||||
credentialId: credential.id,
|
||||
});
|
||||
return new CalendarBatchWrapper({ originalCalendar: originalCalendar as unknown as Calendar });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new CalendarService(credential as any);
|
||||
// Ended up returning unoptimized original calendar
|
||||
log.info("Calendar Cache and Batch aren't supported, serving regular calendar for credential", {
|
||||
credentialId: credential.id,
|
||||
});
|
||||
return originalCalendar;
|
||||
};
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
import type { calendar_v3 } from "@googleapis/calendar";
|
||||
import type { GaxiosResponse } from "googleapis-common";
|
||||
import { RRule } from "rrule";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { MeetLocationType } from "@calcom/app-store/constants";
|
||||
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";
|
||||
@@ -34,12 +32,6 @@ interface GoogleCalError extends Error {
|
||||
code?: number;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const ONE_MONTH_IN_MS = 30 * MS_PER_DAY;
|
||||
|
||||
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<calendar_v3.Schema$Event> =>
|
||||
typeof error === "object" && !!error && Object.prototype.hasOwnProperty.call(error, "config");
|
||||
|
||||
@@ -120,50 +112,6 @@ export default class GoogleCalendarService implements Calendar {
|
||||
return attendees;
|
||||
};
|
||||
|
||||
private async stopWatchingCalendarsInGoogle(
|
||||
channels: { googleChannelResourceId: string | null; googleChannelId: string | null }[]
|
||||
) {
|
||||
const calendar = await this.authedCalendar();
|
||||
logger.debug(`Unsubscribing from calendars ${channels.map((c) => c.googleChannelId).join(", ")}`);
|
||||
const uniqueChannels = uniqueBy(channels, ["googleChannelId", "googleChannelResourceId"]);
|
||||
await Promise.allSettled(
|
||||
uniqueChannels.map(({ googleChannelResourceId, googleChannelId }) =>
|
||||
calendar.channels
|
||||
.stop({
|
||||
requestBody: {
|
||||
resourceId: googleChannelResourceId,
|
||||
id: googleChannelId,
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(JSON.stringify(err));
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async startWatchingCalendarsInGoogle({ calendarId }: { calendarId: string }) {
|
||||
const calendar = await this.authedCalendar();
|
||||
logger.debug(`Subscribing to calendar ${calendarId}`, safeStringify({ GOOGLE_WEBHOOK_URL }));
|
||||
|
||||
const res = await calendar.events.watch({
|
||||
// Calendar identifier. To retrieve calendar IDs call the calendarList.list method. If you want to access the primary calendar of the currently logged in user, use the "primary" keyword.
|
||||
calendarId,
|
||||
requestBody: {
|
||||
// A UUID or similar unique string that identifies this channel.
|
||||
id: uuid(),
|
||||
type: "web_hook",
|
||||
address: GOOGLE_WEBHOOK_URL,
|
||||
token: process.env.GOOGLE_WEBHOOK_TOKEN,
|
||||
params: {
|
||||
// The time-to-live in seconds for the notification channel. Default is 604800 seconds.
|
||||
ttl: `${Math.round(ONE_MONTH_IN_MS / 1000)}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async createEvent(
|
||||
calEvent: CalendarServiceEvent,
|
||||
credentialId: number,
|
||||
@@ -452,9 +400,7 @@ export default class GoogleCalendarService implements Calendar {
|
||||
return apiResponse.json;
|
||||
}
|
||||
|
||||
async getFreeBusyResult(
|
||||
args: FreeBusyArgs,
|
||||
): Promise<calendar_v3.Schema$FreeBusyResponse> {
|
||||
async getFreeBusyResult(args: FreeBusyArgs): Promise<calendar_v3.Schema$FreeBusyResponse> {
|
||||
return await this.fetchAvailability(args);
|
||||
}
|
||||
|
||||
@@ -471,9 +417,7 @@ export default class GoogleCalendarService implements Calendar {
|
||||
return validCals[0];
|
||||
}
|
||||
|
||||
async getFreeBusyData(
|
||||
args: FreeBusyArgs,
|
||||
): Promise<(EventBusyDate & { id: string })[] | null> {
|
||||
async getFreeBusyData(args: FreeBusyArgs): Promise<(EventBusyDate & { id: string })[] | null> {
|
||||
const freeBusyResult = await this.getFreeBusyResult(args);
|
||||
if (!freeBusyResult.calendars) return null;
|
||||
|
||||
@@ -604,11 +548,12 @@ export default class GoogleCalendarService implements Calendar {
|
||||
|
||||
/**
|
||||
* Fetches availability data using the cache-or-fetch pattern
|
||||
*
|
||||
*/
|
||||
private async fetchAvailabilityData(
|
||||
calendarIds: string[],
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
dateTo: string
|
||||
): Promise<EventBusyDate[]> {
|
||||
// More efficient date difference calculation using native Date objects
|
||||
// Use Math.floor to match dayjs diff behavior (truncates, doesn't round up)
|
||||
@@ -619,13 +564,11 @@ 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.getFreeBusyData(
|
||||
{
|
||||
timeMin: dateFrom,
|
||||
timeMax: dateTo,
|
||||
items: calendarIds.map((id) => ({ id })),
|
||||
}
|
||||
);
|
||||
const freeBusyData = await this.getFreeBusyData({
|
||||
timeMin: dateFrom,
|
||||
timeMax: dateTo,
|
||||
items: calendarIds.map((id) => ({ id })),
|
||||
});
|
||||
|
||||
if (!freeBusyData) throw new Error("No response from google calendar");
|
||||
return freeBusyData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end }));
|
||||
@@ -647,13 +590,11 @@ export default class GoogleCalendarService implements Calendar {
|
||||
currentEndTime = originalEndTime;
|
||||
}
|
||||
|
||||
const chunkData = await this.getFreeBusyData(
|
||||
{
|
||||
timeMin: new Date(currentStartTime).toISOString(),
|
||||
timeMax: new Date(currentEndTime).toISOString(),
|
||||
items: calendarIds.map((id) => ({ id })),
|
||||
}
|
||||
);
|
||||
const chunkData = await this.getFreeBusyData({
|
||||
timeMin: new Date(currentStartTime).toISOString(),
|
||||
timeMax: new Date(currentEndTime).toISOString(),
|
||||
items: calendarIds.map((id) => ({ id })),
|
||||
});
|
||||
|
||||
if (chunkData) {
|
||||
busyData.push(...chunkData.map((freeBusy) => ({ start: freeBusy.start, end: freeBusy.end })));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import oAuthManagerMock, {
|
||||
defaultMockOAuthManager,
|
||||
setFullMockOAuthManagerRequest,
|
||||
@@ -671,3 +673,197 @@ describe("createEvent", () => {
|
||||
log.info("createEvent recurring event test passed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Delegation Credential Batching", () => {
|
||||
test("getAvailability should fetch availability for selected calendars", async () => {
|
||||
const calendarService = new CalendarService(mockCredential);
|
||||
setFullMockOAuthManagerRequest();
|
||||
|
||||
const mockedBusyTimes = [
|
||||
{ start: "2024-01-01T10:00:00Z", end: "2024-01-01T11:00:00Z" },
|
||||
{ start: "2024-01-02T14:00:00Z", end: "2024-01-02T15:00:00Z" },
|
||||
];
|
||||
|
||||
calendarListMock.mockImplementation(() => {
|
||||
return {
|
||||
data: {
|
||||
items: [
|
||||
{ id: "calendar1@test.com" },
|
||||
{ id: "calendar2@test.com" },
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
freebusyQueryMock.mockImplementation(() => {
|
||||
return {
|
||||
data: {
|
||||
calendars: {
|
||||
"calendar1@test.com": { busy: [mockedBusyTimes[0]] },
|
||||
"calendar2@test.com": { busy: [mockedBusyTimes[1]] },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const selectedCalendars = [
|
||||
{
|
||||
externalId: "calendar1@test.com",
|
||||
integration: "google_calendar",
|
||||
delegationCredentialId: "delegation-1",
|
||||
},
|
||||
{
|
||||
externalId: "calendar2@test.com",
|
||||
integration: "google_calendar",
|
||||
delegationCredentialId: "delegation-2",
|
||||
},
|
||||
];
|
||||
|
||||
const availability = await calendarService.getAvailability(
|
||||
"2024-01-01",
|
||||
"2024-01-31",
|
||||
selectedCalendars,
|
||||
false
|
||||
);
|
||||
|
||||
// CalendarService makes a single API call with all calendars
|
||||
// Batching by delegationCredentialId is handled by CalendarBatchWrapper
|
||||
expect(freebusyQueryMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Should return combined results from all calendars
|
||||
expect(availability).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("CalendarBatchWrapper should make separate API calls for different delegationCredentialIds", async () => {
|
||||
// Import CalendarBatchWrapper for integration test
|
||||
const { CalendarBatchWrapper } = await import("@calcom/features/calendar-batch/lib/CalendarBatchWrapper");
|
||||
|
||||
const calendarService = new CalendarService(mockCredential);
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: calendarService });
|
||||
setFullMockOAuthManagerRequest();
|
||||
|
||||
const mockedBusyTimes1 = [{ start: "2024-01-01T10:00:00Z", end: "2024-01-01T11:00:00Z" }];
|
||||
const mockedBusyTimes2 = [{ start: "2024-01-02T14:00:00Z", end: "2024-01-02T15:00:00Z" }];
|
||||
|
||||
calendarListMock.mockImplementation(() => {
|
||||
return {
|
||||
data: {
|
||||
items: [
|
||||
{ id: "calendar1@test.com" },
|
||||
{ id: "calendar2@test.com" },
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let callCount = 0;
|
||||
freebusyQueryMock.mockImplementation(() => {
|
||||
callCount++;
|
||||
const busyTimes = callCount === 1 ? mockedBusyTimes1 : mockedBusyTimes2;
|
||||
return {
|
||||
data: {
|
||||
calendars: {
|
||||
"calendar@test.com": { busy: busyTimes },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const selectedCalendars = [
|
||||
{
|
||||
externalId: "calendar1@test.com",
|
||||
integration: "google_calendar",
|
||||
delegationCredentialId: "delegation-1",
|
||||
},
|
||||
{
|
||||
externalId: "calendar2@test.com",
|
||||
integration: "google_calendar",
|
||||
delegationCredentialId: "delegation-2",
|
||||
},
|
||||
];
|
||||
|
||||
const availability = await wrapper.getAvailability(
|
||||
"2024-01-01",
|
||||
"2024-01-31",
|
||||
selectedCalendars,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
// CalendarBatchWrapper should make 2 API calls (one per delegation credential group)
|
||||
expect(freebusyQueryMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Should return combined results from both groups
|
||||
expect(availability).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("getAvailability should fallback to primary calendar when no calendars selected and fallbackToPrimary is true", async () => {
|
||||
const calendarService = new CalendarService(mockCredential);
|
||||
setFullMockOAuthManagerRequest();
|
||||
|
||||
const mockedBusyTimes = [
|
||||
{ start: "2024-01-01T10:00:00Z", end: "2024-01-01T11:00:00Z" },
|
||||
];
|
||||
|
||||
calendarListMock.mockImplementation(() => {
|
||||
return {
|
||||
data: {
|
||||
items: [
|
||||
{ id: "primary@test.com", primary: true },
|
||||
{ id: "secondary@test.com" },
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
freebusyQueryMock.mockImplementation(() => {
|
||||
return {
|
||||
data: {
|
||||
calendars: {
|
||||
"primary@test.com": { busy: mockedBusyTimes },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Empty selected calendars with fallbackToPrimary = true
|
||||
const availability = await calendarService.getAvailability(
|
||||
"2024-01-01",
|
||||
"2024-01-31",
|
||||
[],
|
||||
true
|
||||
);
|
||||
|
||||
// Should have called FreeBusy API
|
||||
expect(freebusyQueryMock).toHaveBeenCalled();
|
||||
|
||||
// Should return busy times from primary calendar
|
||||
expect(availability).toEqual(mockedBusyTimes);
|
||||
});
|
||||
|
||||
test("getAvailability should return empty array when only non-google calendars are selected", async () => {
|
||||
const calendarService = new CalendarService(mockCredential);
|
||||
setFullMockOAuthManagerRequest();
|
||||
|
||||
const selectedCalendars = [
|
||||
{
|
||||
externalId: "calendar1@outlook.com",
|
||||
integration: "office365_calendar",
|
||||
delegationCredentialId: null,
|
||||
},
|
||||
];
|
||||
|
||||
const availability = await calendarService.getAvailability(
|
||||
"2024-01-01",
|
||||
"2024-01-31",
|
||||
selectedCalendars,
|
||||
false
|
||||
);
|
||||
|
||||
// Should return empty array since no google calendars
|
||||
expect(availability).toEqual([]);
|
||||
|
||||
// Should not have called FreeBusy API
|
||||
expect(freebusyQueryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Calendar Batch Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The Calendar Batch feature optimizes Google Calendar availability queries by intelligently batching FreeBusy API calls based on delegation credentials. This reduces the number of API calls while respecting Google's API limits.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
#### 1. CalendarBatchService
|
||||
|
||||
Location: `lib/CalendarBatchService.ts`
|
||||
|
||||
A simple utility class that determines which calendar types support batching optimization.
|
||||
|
||||
```typescript
|
||||
class CalendarBatchService {
|
||||
static isCalendarTypeSupported(type: string | null): boolean
|
||||
}
|
||||
```
|
||||
|
||||
Currently, only `google_calendar` is supported. This gate is used by `getCalendar()` to decide whether to wrap the calendar service with batching capabilities.
|
||||
|
||||
#### 2. CalendarBatchWrapper
|
||||
|
||||
Location: `lib/CalendarBatchWrapper.ts`
|
||||
|
||||
A decorator that wraps the original calendar service and optimizes availability queries. It implements the `Calendar` interface, passing through most methods directly to the underlying calendar while intercepting `getAvailability()` and `getAvailabilityWithTimeZones()` for optimization.
|
||||
|
||||
**Key Constants:**
|
||||
- `CALENDAR_BATCH_MAX_SIZE = 50`: Google Calendar FreeBusy API limit for items per request
|
||||
|
||||
**Key Methods:**
|
||||
|
||||
##### `splitCalendars(selectedCalendars)`
|
||||
|
||||
Divides calendars into two groups based on their credential type:
|
||||
|
||||
1. **ownCredentials**: Calendars WITHOUT `delegationCredentialId`
|
||||
- These are processed one-by-one because each may represent a different OAuth token
|
||||
- Conservative approach to avoid mixing credentials
|
||||
|
||||
2. **delegatedCredentials**: Calendars WITH `delegationCredentialId`
|
||||
- Grouped by their `delegationCredentialId`
|
||||
- Calendars in the same group share the same delegated credential and can be safely batched together
|
||||
|
||||
##### `partition(items, size)`
|
||||
|
||||
Splits an array into chunks of the specified size (50 for Google API compliance).
|
||||
|
||||
##### `getAvailability(dateFrom, dateTo, selectedCalendars, fallbackToPrimary)`
|
||||
|
||||
The main optimization method:
|
||||
|
||||
1. Splits calendars using `splitCalendars()`
|
||||
2. Creates parallel tasks:
|
||||
- One task per own credential calendar (processed individually)
|
||||
- For delegated credentials: groups by `delegationCredentialId`, partitions each group into batches of 50, creates one task per batch
|
||||
3. Executes all tasks in parallel with `Promise.all()`
|
||||
4. Flattens and returns combined results
|
||||
|
||||
**Edge Case:** If no calendars are provided, a single call is made with an empty array to honor `fallbackToPrimary`.
|
||||
|
||||
### Integration with getCalendar
|
||||
|
||||
Location: `packages/app-store/_utils/getCalendar.ts`
|
||||
|
||||
The `getCalendar()` factory function decides which wrapper to use:
|
||||
|
||||
```
|
||||
Decision Flow:
|
||||
1. Create original calendar service from CalendarServiceMap
|
||||
2. Check if CalendarCacheWrapper should be used (feature flag based)
|
||||
- If yes, return CalendarCacheWrapper (cache takes precedence)
|
||||
3. Check if CalendarBatchService.isCalendarTypeSupported(calendarType)
|
||||
- If yes, return CalendarBatchWrapper
|
||||
4. Otherwise, return unoptimized original calendar
|
||||
```
|
||||
|
||||
**Important:** Cache wrapper takes precedence over batch wrapper. If calendar caching is enabled for a user, batching is not applied.
|
||||
|
||||
## How It Works with Google Calendar
|
||||
|
||||
### Two Levels of Chunking
|
||||
|
||||
The system has two independent chunking mechanisms:
|
||||
|
||||
1. **CalendarBatchWrapper (this feature)**: Chunks calendars into groups of 50 per API call based on delegation credentials
|
||||
|
||||
2. **GoogleCalendarService.fetchAvailabilityData()**: Chunks time periods into 90-day windows (Google FreeBusy API limitation)
|
||||
|
||||
This means a single `getAvailability()` call can result in multiple Google API calls:
|
||||
- Multiple calls for different credential groups (batch wrapper)
|
||||
- Multiple calls for long date ranges > 90 days (Google service)
|
||||
|
||||
### Example Flow
|
||||
|
||||
For a user with:
|
||||
- 2 personal calendars (no delegation)
|
||||
- 75 delegated calendars under delegation credential "A"
|
||||
- 30 delegated calendars under delegation credential "B"
|
||||
|
||||
The batch wrapper creates:
|
||||
- 2 tasks for personal calendars (one each)
|
||||
- 2 tasks for delegation "A" (50 + 25 calendars)
|
||||
- 1 task for delegation "B" (30 calendars)
|
||||
|
||||
Total: 5 parallel API calls instead of 107 sequential calls.
|
||||
|
||||
## Data Model
|
||||
|
||||
### IntegrationCalendar
|
||||
|
||||
The `IntegrationCalendar` interface extends `SelectedCalendar` from Prisma and includes:
|
||||
|
||||
- `externalId`: The calendar's external identifier
|
||||
- `credentialId`: Reference to the Credential used
|
||||
- `delegationCredentialId`: Reference to a DelegationCredential (if using domain-wide delegation)
|
||||
|
||||
### DelegationCredential
|
||||
|
||||
A `DelegationCredential` represents a shared credential that allows accessing multiple users' calendars within an organization (e.g., Google Workspace domain-wide delegation). Calendars sharing the same `delegationCredentialId` can be batched together because they use the same authentication context.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Parallel Execution**: All batched tasks run in parallel via `Promise.all()`
|
||||
|
||||
2. **Conservative Batching**: Own credentials are never batched together to avoid potential token mixing issues
|
||||
|
||||
3. **Respects API Limits**: Never exceeds 50 calendars per FreeBusy request
|
||||
|
||||
4. **Pass-through for Non-Availability Methods**: `createEvent`, `updateEvent`, `deleteEvent`, and other methods are not batched - they pass directly to the underlying calendar service
|
||||
|
||||
## Supported Calendar Types
|
||||
|
||||
Currently only `google_calendar` supports batching. To add support for other calendar types, update `CalendarBatchService.isCalendarTypeSupported()`.
|
||||
@@ -0,0 +1,27 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
/**
|
||||
* Utility class for Calendar Batch Service
|
||||
* Should serve as provide customizable config per calendar type
|
||||
*/
|
||||
import type { CredentialForCalendarService } from "@calcom/types/Credential";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["CalendarBatchService"] });
|
||||
|
||||
export class CalendarBatchService {
|
||||
static isSupported(credential: CredentialForCalendarService): boolean {
|
||||
// only supports google calendar for now
|
||||
if (credential.type !== "google_calendar") {
|
||||
log.info("Only Google Calendar is supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
// only supports delegated credentials
|
||||
if (!credential.delegatedTo) {
|
||||
log.info("Only Delegated Credential is supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import {
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
CalendarServiceEvent,
|
||||
EventBusyDate,
|
||||
IntegrationCalendar,
|
||||
NewCalendarEventType,
|
||||
SelectedCalendarEventTypeIds,
|
||||
} from "@calcom/types/Calendar";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["CalendarBatchWrapper"] });
|
||||
|
||||
type DelegatedGroups = Record<string, IntegrationCalendar[]>;
|
||||
|
||||
/**
|
||||
* CalendarBatchWrapper wraps original calendar to process availability in
|
||||
* batches instead of individual calls.
|
||||
*/
|
||||
export class CalendarBatchWrapper implements Calendar {
|
||||
static CALENDAR_BATCH_MAX_SIZE = 50;
|
||||
|
||||
constructor(
|
||||
private deps: {
|
||||
originalCalendar: Calendar;
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Splits selected calendars into two independent credential flows.
|
||||
*
|
||||
* - ownCredentials:
|
||||
* Calendars without delegationCredentialId.
|
||||
* Each one may represent a different underlying token,
|
||||
* so they must be processed one-by-one.
|
||||
*
|
||||
* - delegatedCredentials:
|
||||
* Calendars grouped by delegationCredentialId.
|
||||
* Calendars in the same group share the same delegated credential
|
||||
* and can be safely batched.
|
||||
*/
|
||||
private splitCalendars(selectedCalendars: IntegrationCalendar[]) {
|
||||
const ownCredentials: IntegrationCalendar[] = [];
|
||||
const delegatedCredentials: DelegatedGroups = {};
|
||||
|
||||
for (const selectedCalendar of selectedCalendars) {
|
||||
const delegationCredentialId = selectedCalendar.delegationCredentialId;
|
||||
|
||||
if (!delegationCredentialId) {
|
||||
ownCredentials.push(selectedCalendar);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!delegatedCredentials[delegationCredentialId]) {
|
||||
delegatedCredentials[delegationCredentialId] = [];
|
||||
}
|
||||
|
||||
delegatedCredentials[delegationCredentialId].push(selectedCalendar);
|
||||
}
|
||||
|
||||
return { ownCredentials, delegatedCredentials };
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions items into fixed-size batches.
|
||||
* Google Calendar freebusy enforces a hard limit of CalendarBatchWrapper.CALENDAR_BATCH_MAX_SIZE
|
||||
*/
|
||||
private partition<T>(items: T[], size: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
result.push(items.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getCredentialId?(): number {
|
||||
return this.deps.originalCalendar.getCredentialId?.() ?? -1;
|
||||
}
|
||||
|
||||
createEvent(
|
||||
event: CalendarServiceEvent,
|
||||
credentialId: number,
|
||||
externalCalendarId?: string
|
||||
): Promise<NewCalendarEventType> {
|
||||
return this.deps.originalCalendar.createEvent(event, credentialId, externalCalendarId);
|
||||
}
|
||||
|
||||
updateEvent(
|
||||
uid: string,
|
||||
event: CalendarServiceEvent,
|
||||
externalCalendarId?: string | null
|
||||
): Promise<NewCalendarEventType | NewCalendarEventType[]> {
|
||||
return this.deps.originalCalendar.updateEvent(uid, event, externalCalendarId);
|
||||
}
|
||||
|
||||
deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string | null): Promise<unknown> {
|
||||
return this.deps.originalCalendar.deleteEvent(uid, event, externalCalendarId);
|
||||
}
|
||||
|
||||
async getAvailability(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
selectedCalendars: IntegrationCalendar[],
|
||||
shouldServeCache?: boolean,
|
||||
fallbackToPrimary?: boolean
|
||||
): Promise<EventBusyDate[]> {
|
||||
const { ownCredentials, delegatedCredentials } = this.splitCalendars(selectedCalendars);
|
||||
|
||||
log.info("Loading free/busy from Google Calendar", {
|
||||
ownCredentials: ownCredentials.length,
|
||||
delegatedCredentials: Object.values(delegatedCredentials).reduce(
|
||||
(sum, calendars) => sum + calendars.length,
|
||||
0
|
||||
),
|
||||
});
|
||||
|
||||
const tasks: Promise<EventBusyDate[]>[] = [];
|
||||
|
||||
/**
|
||||
* Own credentials are processed one-by-one because we cannot
|
||||
* safely assume they share the same underlying token.
|
||||
*/
|
||||
for (let i = 0; i < ownCredentials.length; i++) {
|
||||
const selectedCalendar = ownCredentials[i];
|
||||
tasks.push(
|
||||
this.deps.originalCalendar.getAvailability(
|
||||
dateFrom,
|
||||
dateTo,
|
||||
[selectedCalendar],
|
||||
shouldServeCache,
|
||||
fallbackToPrimary
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegated credentials are grouped by delegationCredentialId.
|
||||
* Each group can be batched up to the Google API limit.
|
||||
*/
|
||||
const delegationIds = Object.keys(delegatedCredentials);
|
||||
for (let i = 0; i < delegationIds.length; i++) {
|
||||
const delegationCredentialId = delegationIds[i];
|
||||
const delegationCredentialCalendars = delegatedCredentials[delegationCredentialId];
|
||||
const partition = this.partition(
|
||||
delegationCredentialCalendars,
|
||||
CalendarBatchWrapper.CALENDAR_BATCH_MAX_SIZE
|
||||
);
|
||||
|
||||
for (let j = 0; j < partition.length; j++) {
|
||||
tasks.push(
|
||||
this.deps.originalCalendar.getAvailability(
|
||||
dateFrom,
|
||||
dateTo,
|
||||
partition[j],
|
||||
shouldServeCache,
|
||||
fallbackToPrimary
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When no calendars are provided, perform a single call
|
||||
* so fallbackToPrimary can be honored.
|
||||
*/
|
||||
if (tasks.length === 0) {
|
||||
tasks.push(
|
||||
this.deps.originalCalendar.getAvailability(dateFrom, dateTo, [], shouldServeCache, fallbackToPrimary)
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(tasks);
|
||||
|
||||
// Log any failed batches but continue with successful ones
|
||||
const failedResults = results.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected"
|
||||
);
|
||||
if (failedResults.length > 0) {
|
||||
log.warn("Some calendar availability batches failed", {
|
||||
failedCount: failedResults.length,
|
||||
totalCount: results.length,
|
||||
errors: failedResults.map((r) => r.reason),
|
||||
});
|
||||
}
|
||||
|
||||
// Return only successful results
|
||||
const successfulResults = results
|
||||
.filter((result): result is PromiseFulfilledResult<EventBusyDate[]> => result.status === "fulfilled")
|
||||
.map((result) => result.value);
|
||||
|
||||
return successfulResults.flat();
|
||||
}
|
||||
|
||||
async getAvailabilityWithTimeZones(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
selectedCalendars: IntegrationCalendar[],
|
||||
fallbackToPrimary?: boolean
|
||||
): Promise<{ start: Date | string; end: Date | string; timeZone: string }[]> {
|
||||
const { ownCredentials, delegatedCredentials } = this.splitCalendars(selectedCalendars);
|
||||
const tasks: Promise<{ start: Date | string; end: Date | string; timeZone: string }[]>[] = [];
|
||||
|
||||
const calendar = this.deps.originalCalendar;
|
||||
|
||||
/**
|
||||
* Timezone-aware availability is optional.
|
||||
* If not implemented by the underlying calendar, fail fast.
|
||||
*/
|
||||
if (!calendar.getAvailabilityWithTimeZones) {
|
||||
log.warn("getAvailabilityWithTimeZones is not implemented on underlying calendar");
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Own credentials are processed one-by-one for the same reason
|
||||
* as in getAvailability.
|
||||
*/
|
||||
for (let i = 0; i < ownCredentials.length; i++) {
|
||||
const selectedCalendar = ownCredentials[i];
|
||||
tasks.push(
|
||||
calendar.getAvailabilityWithTimeZones(dateFrom, dateTo, [selectedCalendar], fallbackToPrimary)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegated credentials are processed in isolated batches.
|
||||
*/
|
||||
const delegationIds = Object.keys(delegatedCredentials);
|
||||
for (let i = 0; i < delegationIds.length; i++) {
|
||||
const delegationCredentialId = delegationIds[i];
|
||||
const delegationCredentialCalendars = delegatedCredentials[delegationCredentialId];
|
||||
const partition = this.partition(
|
||||
delegationCredentialCalendars,
|
||||
CalendarBatchWrapper.CALENDAR_BATCH_MAX_SIZE
|
||||
);
|
||||
|
||||
for (let j = 0; j < partition.length; j++) {
|
||||
tasks.push(calendar.getAvailabilityWithTimeZones(dateFrom, dateTo, partition[j], fallbackToPrimary));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures fallbackToPrimary is honored even when
|
||||
* no calendars were explicitly selected.
|
||||
*/
|
||||
if (tasks.length === 0) {
|
||||
tasks.push(calendar.getAvailabilityWithTimeZones(dateFrom, dateTo, [], fallbackToPrimary));
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(tasks);
|
||||
|
||||
// Log any failed batches but continue with successful ones
|
||||
const failedResults = results.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected"
|
||||
);
|
||||
if (failedResults.length > 0) {
|
||||
log.warn("Some calendar availability with timezones batches failed", {
|
||||
failedCount: failedResults.length,
|
||||
totalCount: results.length,
|
||||
errors: failedResults.map((r) => r.reason),
|
||||
});
|
||||
}
|
||||
|
||||
// Return only successful results
|
||||
type AvailabilityWithTimeZone = { start: Date | string; end: Date | string; timeZone: string };
|
||||
const successfulResults = results
|
||||
.filter(
|
||||
(result): result is PromiseFulfilledResult<AvailabilityWithTimeZone[]> => result.status === "fulfilled"
|
||||
)
|
||||
.map((result) => result.value);
|
||||
|
||||
return successfulResults.flat();
|
||||
}
|
||||
|
||||
fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise<unknown> {
|
||||
return this.deps.originalCalendar.fetchAvailabilityAndSetCache?.(selectedCalendars) || Promise.resolve();
|
||||
}
|
||||
|
||||
listCalendars(event?: CalendarEvent): Promise<IntegrationCalendar[]> {
|
||||
return this.deps.originalCalendar.listCalendars(event);
|
||||
}
|
||||
|
||||
testDelegationCredentialSetup?(): Promise<boolean> {
|
||||
return this.deps.originalCalendar.testDelegationCredentialSetup?.() || Promise.resolve(false);
|
||||
}
|
||||
|
||||
watchCalendar?(options: {
|
||||
calendarId: string;
|
||||
eventTypeIds: SelectedCalendarEventTypeIds;
|
||||
}): Promise<unknown> {
|
||||
return this.deps.originalCalendar.watchCalendar?.(options) || Promise.resolve();
|
||||
}
|
||||
|
||||
unwatchCalendar?(options: {
|
||||
calendarId: string;
|
||||
eventTypeIds: SelectedCalendarEventTypeIds;
|
||||
}): Promise<void> {
|
||||
return this.deps.originalCalendar.unwatchCalendar?.(options) || Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { Calendar, CalendarEvent, CalendarServiceEvent, EventBusyDate, IntegrationCalendar } from "@calcom/types/Calendar";
|
||||
|
||||
import { CalendarBatchWrapper } from "../CalendarBatchWrapper";
|
||||
|
||||
describe("CalendarBatchWrapper", () => {
|
||||
let mockOriginalCalendar: Calendar;
|
||||
let getAvailabilityMock: ReturnType<typeof vi.fn>;
|
||||
let getAvailabilityWithTimeZonesMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
getAvailabilityMock = vi.fn().mockResolvedValue([]);
|
||||
getAvailabilityWithTimeZonesMock = vi.fn().mockResolvedValue([]);
|
||||
|
||||
mockOriginalCalendar = {
|
||||
getAvailability: getAvailabilityMock,
|
||||
getAvailabilityWithTimeZones: getAvailabilityWithTimeZonesMock,
|
||||
createEvent: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
deleteEvent: vi.fn(),
|
||||
listCalendars: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as Calendar;
|
||||
});
|
||||
|
||||
describe("getAvailability batching behavior", () => {
|
||||
test("should make separate calls for calendars without delegationCredentialId", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar" },
|
||||
{ externalId: "cal2@test.com", integration: "google_calendar" },
|
||||
{ externalId: "cal3@test.com", integration: "google_calendar" },
|
||||
];
|
||||
|
||||
getAvailabilityMock.mockResolvedValue([{ start: "2024-01-01T10:00:00Z", end: "2024-01-01T11:00:00Z" }]);
|
||||
|
||||
await wrapper.getAvailability("2024-01-01", "2024-01-31", selectedCalendars, undefined, false);
|
||||
|
||||
// Each calendar without delegationCredentialId should be processed separately
|
||||
expect(getAvailabilityMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Verify each call has exactly one calendar (order-independent)
|
||||
const allCalls = getAvailabilityMock.mock.calls as [
|
||||
string,
|
||||
string,
|
||||
IntegrationCalendar[],
|
||||
boolean | undefined,
|
||||
boolean
|
||||
][];
|
||||
const calendarIdsFromCalls = allCalls.map((call) => {
|
||||
expect(call[0]).toBe("2024-01-01");
|
||||
expect(call[1]).toBe("2024-01-31");
|
||||
expect(call[2]).toHaveLength(1);
|
||||
expect(call[3]).toBe(undefined);
|
||||
expect(call[4]).toBe(false);
|
||||
return call[2][0].externalId;
|
||||
});
|
||||
|
||||
// Verify all expected calendars were called (as a set, order doesn't matter)
|
||||
expect(new Set(calendarIdsFromCalls)).toEqual(
|
||||
new Set(["cal1@test.com", "cal2@test.com", "cal3@test.com"])
|
||||
);
|
||||
});
|
||||
|
||||
test("should batch calendars with the same delegationCredentialId together", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "cal2@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "cal3@test.com", integration: "google_calendar", delegationCredentialId: "delegation-2" },
|
||||
];
|
||||
|
||||
await wrapper.getAvailability("2024-01-01", "2024-01-31", selectedCalendars, undefined, false);
|
||||
|
||||
// Should make 2 calls: one for delegation-1 (batched), one for delegation-2
|
||||
expect(getAvailabilityMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Verify calls in an order-independent way
|
||||
const allCalls = getAvailabilityMock.mock.calls as [
|
||||
string,
|
||||
string,
|
||||
IntegrationCalendar[],
|
||||
boolean | undefined,
|
||||
boolean
|
||||
][];
|
||||
|
||||
// Find the call for delegation-1 (should have 2 calendars batched)
|
||||
const delegation1Call = allCalls.find(
|
||||
(call) => call[2].length === 2 && call[2][0].delegationCredentialId === "delegation-1"
|
||||
);
|
||||
expect(delegation1Call).toBeDefined();
|
||||
expect(delegation1Call![0]).toBe("2024-01-01");
|
||||
expect(delegation1Call![1]).toBe("2024-01-31");
|
||||
const delegation1Ids = new Set(delegation1Call![2].map((c) => c.externalId));
|
||||
expect(delegation1Ids).toEqual(new Set(["cal1@test.com", "cal2@test.com"]));
|
||||
expect(delegation1Call![3]).toBe(undefined);
|
||||
expect(delegation1Call![4]).toBe(false);
|
||||
|
||||
// Find the call for delegation-2 (should have 1 calendar)
|
||||
const delegation2Call = allCalls.find(
|
||||
(call) => call[2].length === 1 && call[2][0].delegationCredentialId === "delegation-2"
|
||||
);
|
||||
expect(delegation2Call).toBeDefined();
|
||||
expect(delegation2Call![0]).toBe("2024-01-01");
|
||||
expect(delegation2Call![1]).toBe("2024-01-31");
|
||||
expect(delegation2Call![2][0].externalId).toBe("cal3@test.com");
|
||||
expect(delegation2Call![3]).toBe(undefined);
|
||||
expect(delegation2Call![4]).toBe(false);
|
||||
});
|
||||
|
||||
test("should chunk delegated calendars into groups of 50 for API limits", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
// Create 120 calendars with the same delegationCredentialId
|
||||
const selectedCalendars: IntegrationCalendar[] = Array.from({ length: 120 }, (_, i) => ({
|
||||
externalId: `cal${i}@test.com`,
|
||||
integration: "google_calendar",
|
||||
delegationCredentialId: "delegation-1",
|
||||
}));
|
||||
|
||||
await wrapper.getAvailability("2024-01-01", "2024-01-31", selectedCalendars, undefined, false);
|
||||
|
||||
// Should make 3 calls: 50 + 50 + 20
|
||||
expect(getAvailabilityMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
// First chunk should have 50 calendars
|
||||
const firstCallCalendars = getAvailabilityMock.mock.calls[0][2];
|
||||
expect(firstCallCalendars).toHaveLength(50);
|
||||
|
||||
// Second chunk should have 50 calendars
|
||||
const secondCallCalendars = getAvailabilityMock.mock.calls[1][2];
|
||||
expect(secondCallCalendars).toHaveLength(50);
|
||||
|
||||
// Third chunk should have 20 calendars
|
||||
const thirdCallCalendars = getAvailabilityMock.mock.calls[2][2];
|
||||
expect(thirdCallCalendars).toHaveLength(20);
|
||||
});
|
||||
|
||||
test("should handle mixed calendars with and without delegationCredentialId", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "own1@test.com", integration: "google_calendar" },
|
||||
{ externalId: "delegated1@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "delegated2@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "own2@test.com", integration: "google_calendar" },
|
||||
];
|
||||
|
||||
await wrapper.getAvailability("2024-01-01", "2024-01-31", selectedCalendars, undefined, false);
|
||||
|
||||
// Should make 3 calls: 2 for own credentials (one each), 1 for delegation-1 (batched)
|
||||
expect(getAvailabilityMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("should make a single call with empty array when no calendars provided to honor fallbackToPrimary", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
await wrapper.getAvailability("2024-01-01", "2024-01-31", [], undefined, true);
|
||||
|
||||
// Should make exactly 1 call with empty array so fallbackToPrimary can be honored
|
||||
expect(getAvailabilityMock).toHaveBeenCalledTimes(1);
|
||||
expect(getAvailabilityMock).toHaveBeenCalledWith("2024-01-01", "2024-01-31", [], undefined, true);
|
||||
});
|
||||
|
||||
test("should flatten and combine results from all batched calls", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "cal2@test.com", integration: "google_calendar", delegationCredentialId: "delegation-2" },
|
||||
];
|
||||
|
||||
const busyTimes1: EventBusyDate[] = [{ start: new Date("2024-01-01T10:00:00Z"), end: new Date("2024-01-01T11:00:00Z") }];
|
||||
const busyTimes2: EventBusyDate[] = [{ start: new Date("2024-01-02T14:00:00Z"), end: new Date("2024-01-02T15:00:00Z") }];
|
||||
|
||||
getAvailabilityMock
|
||||
.mockResolvedValueOnce(busyTimes1)
|
||||
.mockResolvedValueOnce(busyTimes2);
|
||||
|
||||
const result = await wrapper.getAvailability("2024-01-01", "2024-01-31", selectedCalendars, undefined, false);
|
||||
|
||||
// Should return combined results from both calls
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual([...busyTimes1, ...busyTimes2]);
|
||||
});
|
||||
|
||||
test("should forward shouldServeCache parameter to original calendar", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
];
|
||||
|
||||
// Test with shouldServeCache = true
|
||||
await wrapper.getAvailability("2024-01-01", "2024-01-31", selectedCalendars, true, false);
|
||||
expect(getAvailabilityMock).toHaveBeenLastCalledWith(
|
||||
"2024-01-01",
|
||||
"2024-01-31",
|
||||
selectedCalendars,
|
||||
true,
|
||||
false
|
||||
);
|
||||
|
||||
getAvailabilityMock.mockClear();
|
||||
|
||||
// Test with shouldServeCache = false
|
||||
await wrapper.getAvailability("2024-01-01", "2024-01-31", selectedCalendars, false, true);
|
||||
expect(getAvailabilityMock).toHaveBeenLastCalledWith(
|
||||
"2024-01-01",
|
||||
"2024-01-31",
|
||||
selectedCalendars,
|
||||
false,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("should correctly group calendars by delegationCredentialId (order-independent verification)", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "own1@test.com", integration: "google_calendar" },
|
||||
{ externalId: "del1-cal1@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "del2-cal1@test.com", integration: "google_calendar", delegationCredentialId: "delegation-2" },
|
||||
{ externalId: "del1-cal2@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "own2@test.com", integration: "google_calendar" },
|
||||
];
|
||||
|
||||
await wrapper.getAvailability("2024-01-01", "2024-01-31", selectedCalendars, undefined, false);
|
||||
|
||||
// Should make 4 calls: 2 for own credentials (one each), 1 for delegation-1 (batched), 1 for delegation-2
|
||||
expect(getAvailabilityMock).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Collect all calendars from all calls
|
||||
const allCallCalendars = getAvailabilityMock.mock.calls.map(
|
||||
(call: [string, string, IntegrationCalendar[], boolean | undefined, boolean | undefined]) => call[2]
|
||||
);
|
||||
|
||||
// Verify own credentials are processed one-by-one (each call has exactly 1 calendar without delegationCredentialId)
|
||||
const ownCredentialCalls = allCallCalendars.filter(
|
||||
(cals: IntegrationCalendar[]) => cals.length === 1 && !cals[0].delegationCredentialId
|
||||
);
|
||||
expect(ownCredentialCalls).toHaveLength(2);
|
||||
const ownCalendarIds = ownCredentialCalls.flat().map((c: IntegrationCalendar) => c.externalId);
|
||||
expect(ownCalendarIds).toContain("own1@test.com");
|
||||
expect(ownCalendarIds).toContain("own2@test.com");
|
||||
|
||||
// Verify delegation-1 calendars are batched together
|
||||
const delegation1Call = allCallCalendars.find(
|
||||
(cals: IntegrationCalendar[]) => cals.length > 0 && cals[0].delegationCredentialId === "delegation-1"
|
||||
);
|
||||
expect(delegation1Call).toBeDefined();
|
||||
expect(delegation1Call).toHaveLength(2);
|
||||
const delegation1Ids = delegation1Call!.map((c: IntegrationCalendar) => c.externalId);
|
||||
expect(delegation1Ids).toContain("del1-cal1@test.com");
|
||||
expect(delegation1Ids).toContain("del1-cal2@test.com");
|
||||
|
||||
// Verify delegation-2 calendar is in its own call
|
||||
const delegation2Call = allCallCalendars.find(
|
||||
(cals: IntegrationCalendar[]) => cals.length > 0 && cals[0].delegationCredentialId === "delegation-2"
|
||||
);
|
||||
expect(delegation2Call).toBeDefined();
|
||||
expect(delegation2Call).toHaveLength(1);
|
||||
expect(delegation2Call![0].externalId).toBe("del2-cal1@test.com");
|
||||
|
||||
// Verify total union equals expected calendars
|
||||
const allCalendarIds = allCallCalendars.flat().map((c: IntegrationCalendar) => c.externalId);
|
||||
expect(allCalendarIds).toHaveLength(5);
|
||||
expect(new Set(allCalendarIds)).toEqual(new Set(selectedCalendars.map((c) => c.externalId)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("partial failure handling", () => {
|
||||
test("should return partial results when some batches fail", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "cal2@test.com", integration: "google_calendar", delegationCredentialId: "delegation-2" },
|
||||
{ externalId: "cal3@test.com", integration: "google_calendar", delegationCredentialId: "delegation-3" },
|
||||
];
|
||||
|
||||
const successfulBusyTimes: EventBusyDate[] = [
|
||||
{ start: new Date("2024-01-01T10:00:00Z"), end: new Date("2024-01-01T11:00:00Z") },
|
||||
];
|
||||
|
||||
// First call succeeds, second fails, third succeeds
|
||||
getAvailabilityMock
|
||||
.mockResolvedValueOnce(successfulBusyTimes)
|
||||
.mockRejectedValueOnce(new Error("API rate limit exceeded"))
|
||||
.mockResolvedValueOnce(successfulBusyTimes);
|
||||
|
||||
const result = await wrapper.getAvailability("2024-01-01", "2024-01-31", selectedCalendars, undefined, false);
|
||||
|
||||
// Should return results from successful batches only (2 successful calls)
|
||||
expect(result).toHaveLength(2);
|
||||
expect(getAvailabilityMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("should return empty array when all batches fail", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "cal2@test.com", integration: "google_calendar", delegationCredentialId: "delegation-2" },
|
||||
];
|
||||
|
||||
// All calls fail
|
||||
getAvailabilityMock
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockRejectedValueOnce(new Error("API error"));
|
||||
|
||||
const result = await wrapper.getAvailability("2024-01-01", "2024-01-31", selectedCalendars, undefined, false);
|
||||
|
||||
// Should return empty array when all batches fail
|
||||
expect(result).toHaveLength(0);
|
||||
expect(getAvailabilityMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("should not throw when some batches fail", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar" },
|
||||
{ externalId: "cal2@test.com", integration: "google_calendar" },
|
||||
];
|
||||
|
||||
// First call fails, second succeeds
|
||||
getAvailabilityMock
|
||||
.mockRejectedValueOnce(new Error("Permission denied"))
|
||||
.mockResolvedValueOnce([{ start: new Date("2024-01-01T10:00:00Z"), end: new Date("2024-01-01T11:00:00Z") }]);
|
||||
|
||||
// Should not throw, should return partial results
|
||||
await expect(
|
||||
wrapper.getAvailability("2024-01-01", "2024-01-31", selectedCalendars, undefined, false)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
const result = await wrapper.getAvailability("2024-01-01", "2024-01-31", selectedCalendars, undefined, false);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pass-through methods", () => {
|
||||
test("createEvent should delegate to original calendar", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
const mockEvent = { title: "Test Event" } as unknown as CalendarServiceEvent;
|
||||
|
||||
await wrapper.createEvent(mockEvent, 1, "calendar@test.com");
|
||||
|
||||
expect(mockOriginalCalendar.createEvent).toHaveBeenCalledWith(mockEvent, 1, "calendar@test.com");
|
||||
});
|
||||
|
||||
test("updateEvent should delegate to original calendar", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
const mockEvent = { title: "Updated Event" } as unknown as CalendarServiceEvent;
|
||||
|
||||
await wrapper.updateEvent("event-uid", mockEvent, "calendar@test.com");
|
||||
|
||||
expect(mockOriginalCalendar.updateEvent).toHaveBeenCalledWith("event-uid", mockEvent, "calendar@test.com");
|
||||
});
|
||||
|
||||
test("deleteEvent should delegate to original calendar", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
const mockEvent = { title: "Event to Delete" } as unknown as CalendarEvent;
|
||||
|
||||
await wrapper.deleteEvent("event-uid", mockEvent, "calendar@test.com");
|
||||
|
||||
expect(mockOriginalCalendar.deleteEvent).toHaveBeenCalledWith("event-uid", mockEvent, "calendar@test.com");
|
||||
});
|
||||
|
||||
test("listCalendars should delegate to original calendar", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
await wrapper.listCalendars();
|
||||
|
||||
expect(mockOriginalCalendar.listCalendars).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailabilityWithTimeZones batching behavior", () => {
|
||||
test("should make separate calls for calendars without delegationCredentialId", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar" },
|
||||
{ externalId: "cal2@test.com", integration: "google_calendar" },
|
||||
];
|
||||
|
||||
await wrapper.getAvailabilityWithTimeZones("2024-01-01", "2024-01-31", selectedCalendars, false);
|
||||
|
||||
// Each calendar without delegationCredentialId should be processed separately
|
||||
expect(getAvailabilityWithTimeZonesMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("should batch calendars with the same delegationCredentialId together", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "cal2@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "cal3@test.com", integration: "google_calendar", delegationCredentialId: "delegation-2" },
|
||||
];
|
||||
|
||||
await wrapper.getAvailabilityWithTimeZones("2024-01-01", "2024-01-31", selectedCalendars, false);
|
||||
|
||||
// Should make 2 calls: one for delegation-1 (batched), one for delegation-2
|
||||
expect(getAvailabilityWithTimeZonesMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Verify calls in an order-independent way
|
||||
const allCalls = getAvailabilityWithTimeZonesMock.mock.calls as [
|
||||
string,
|
||||
string,
|
||||
IntegrationCalendar[],
|
||||
boolean
|
||||
][];
|
||||
|
||||
// Find the call for delegation-1 (should have 2 calendars batched)
|
||||
const delegation1Call = allCalls.find(
|
||||
(call) => call[2].length === 2 && call[2][0].delegationCredentialId === "delegation-1"
|
||||
);
|
||||
expect(delegation1Call).toBeDefined();
|
||||
const delegation1Ids = new Set(delegation1Call![2].map((c) => c.externalId));
|
||||
expect(delegation1Ids).toEqual(new Set(["cal1@test.com", "cal2@test.com"]));
|
||||
|
||||
// Find the call for delegation-2 (should have 1 calendar)
|
||||
const delegation2Call = allCalls.find(
|
||||
(call) => call[2].length === 1 && call[2][0].delegationCredentialId === "delegation-2"
|
||||
);
|
||||
expect(delegation2Call).toBeDefined();
|
||||
expect(delegation2Call![2][0].externalId).toBe("cal3@test.com");
|
||||
});
|
||||
|
||||
test("should chunk delegated calendars into groups of 50 for API limits", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
// Create 120 calendars with the same delegationCredentialId
|
||||
const selectedCalendars: IntegrationCalendar[] = Array.from({ length: 120 }, (_, i) => ({
|
||||
externalId: `cal${i}@test.com`,
|
||||
integration: "google_calendar",
|
||||
delegationCredentialId: "delegation-1",
|
||||
}));
|
||||
|
||||
await wrapper.getAvailabilityWithTimeZones("2024-01-01", "2024-01-31", selectedCalendars, false);
|
||||
|
||||
// Should make 3 calls: 50 + 50 + 20
|
||||
expect(getAvailabilityWithTimeZonesMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("should make a single call with empty array when no calendars provided to honor fallbackToPrimary", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
await wrapper.getAvailabilityWithTimeZones("2024-01-01", "2024-01-31", [], true);
|
||||
|
||||
// Should make exactly 1 call with empty array so fallbackToPrimary can be honored
|
||||
expect(getAvailabilityWithTimeZonesMock).toHaveBeenCalledTimes(1);
|
||||
expect(getAvailabilityWithTimeZonesMock).toHaveBeenCalledWith("2024-01-01", "2024-01-31", [], true);
|
||||
});
|
||||
|
||||
test("should flatten and combine results from all batched calls", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "cal2@test.com", integration: "google_calendar", delegationCredentialId: "delegation-2" },
|
||||
];
|
||||
|
||||
type AvailabilityWithTimeZone = { start: Date | string; end: Date | string; timeZone: string };
|
||||
const busyTimes1: AvailabilityWithTimeZone[] = [
|
||||
{ start: new Date("2024-01-01T10:00:00Z"), end: new Date("2024-01-01T11:00:00Z"), timeZone: "America/New_York" },
|
||||
];
|
||||
const busyTimes2: AvailabilityWithTimeZone[] = [
|
||||
{ start: new Date("2024-01-02T14:00:00Z"), end: new Date("2024-01-02T15:00:00Z"), timeZone: "Europe/London" },
|
||||
];
|
||||
|
||||
getAvailabilityWithTimeZonesMock
|
||||
.mockResolvedValueOnce(busyTimes1)
|
||||
.mockResolvedValueOnce(busyTimes2);
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2024-01-01", "2024-01-31", selectedCalendars, false);
|
||||
|
||||
// Should return combined results from both calls
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual([...busyTimes1, ...busyTimes2]);
|
||||
});
|
||||
|
||||
test("should return empty array when underlying calendar does not implement getAvailabilityWithTimeZones", async () => {
|
||||
const calendarWithoutMethod = {
|
||||
getAvailability: getAvailabilityMock,
|
||||
createEvent: vi.fn(),
|
||||
updateEvent: vi.fn(),
|
||||
deleteEvent: vi.fn(),
|
||||
listCalendars: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as Calendar;
|
||||
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: calendarWithoutMethod });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar" },
|
||||
];
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2024-01-01", "2024-01-31", selectedCalendars, false);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailabilityWithTimeZones partial failure handling", () => {
|
||||
test("should return partial results when some batches fail", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "cal2@test.com", integration: "google_calendar", delegationCredentialId: "delegation-2" },
|
||||
{ externalId: "cal3@test.com", integration: "google_calendar", delegationCredentialId: "delegation-3" },
|
||||
];
|
||||
|
||||
type AvailabilityWithTimeZone = { start: Date | string; end: Date | string; timeZone: string };
|
||||
const successfulBusyTimes: AvailabilityWithTimeZone[] = [
|
||||
{ start: new Date("2024-01-01T10:00:00Z"), end: new Date("2024-01-01T11:00:00Z"), timeZone: "UTC" },
|
||||
];
|
||||
|
||||
// First call succeeds, second fails, third succeeds
|
||||
getAvailabilityWithTimeZonesMock
|
||||
.mockResolvedValueOnce(successfulBusyTimes)
|
||||
.mockRejectedValueOnce(new Error("API rate limit exceeded"))
|
||||
.mockResolvedValueOnce(successfulBusyTimes);
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2024-01-01", "2024-01-31", selectedCalendars, false);
|
||||
|
||||
// Should return results from successful batches only (2 successful calls)
|
||||
expect(result).toHaveLength(2);
|
||||
expect(getAvailabilityWithTimeZonesMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("should return empty array when all batches fail", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar", delegationCredentialId: "delegation-1" },
|
||||
{ externalId: "cal2@test.com", integration: "google_calendar", delegationCredentialId: "delegation-2" },
|
||||
];
|
||||
|
||||
// All calls fail
|
||||
getAvailabilityWithTimeZonesMock
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockRejectedValueOnce(new Error("API error"));
|
||||
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2024-01-01", "2024-01-31", selectedCalendars, false);
|
||||
|
||||
// Should return empty array when all batches fail
|
||||
expect(result).toHaveLength(0);
|
||||
expect(getAvailabilityWithTimeZonesMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("should not throw when some batches fail", async () => {
|
||||
const wrapper = new CalendarBatchWrapper({ originalCalendar: mockOriginalCalendar });
|
||||
|
||||
const selectedCalendars: IntegrationCalendar[] = [
|
||||
{ externalId: "cal1@test.com", integration: "google_calendar" },
|
||||
{ externalId: "cal2@test.com", integration: "google_calendar" },
|
||||
];
|
||||
|
||||
type AvailabilityWithTimeZone = { start: Date | string; end: Date | string; timeZone: string };
|
||||
const successResult: AvailabilityWithTimeZone[] = [
|
||||
{ start: new Date("2024-01-01T10:00:00Z"), end: new Date("2024-01-01T11:00:00Z"), timeZone: "UTC" },
|
||||
];
|
||||
|
||||
// First call fails, second succeeds
|
||||
getAvailabilityWithTimeZonesMock
|
||||
.mockRejectedValueOnce(new Error("Permission denied"))
|
||||
.mockResolvedValueOnce(successResult);
|
||||
|
||||
// Should not throw, should return partial results
|
||||
const result = await wrapper.getAvailabilityWithTimeZones("2024-01-01", "2024-01-31", selectedCalendars, false);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].timeZone).toBe("UTC");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user