Files
calendar/packages/features/calendar-subscription/lib/__tests__/CalendarSubscriptionService.test.ts
T
Volnei MunhozandGitHub 2ac310456e fix: Selected calendar delegation credentials (#24190)
* Fix selected calendar delegation credentials
2025-10-01 14:20:36 +00:00

423 lines
15 KiB
TypeScript

import "../__mocks__/delegationCredential";
import { describe, test, expect, vi, beforeEach } from "vitest";
import type { AdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
import type { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
import type { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
import type { ISelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository.interface";
import type { SelectedCalendar } from "@calcom/prisma/client";
import { CalendarSubscriptionService } from "../CalendarSubscriptionService";
const mockSelectedCalendar: SelectedCalendar = {
id: "test-calendar-id",
userId: 1,
credentialId: 1,
integration: "google_calendar",
externalId: "test@example.com",
eventTypeId: null,
delegationCredentialId: null,
domainWideDelegationCredentialId: null,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
error: null,
lastErrorAt: null,
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
channelId: "test-channel-id",
channelKind: "web_hook",
channelResourceId: "test-resource-id",
channelResourceUri: "test-resource-uri",
channelExpiration: new Date(Date.now() + 86400000),
syncSubscribedAt: new Date(),
syncToken: "test-sync-token",
syncedAt: new Date(),
syncErrorAt: null,
syncErrorCount: 0,
};
const mockCredential = {
id: 1,
key: { access_token: "test-token" },
user: { email: "test@example.com" },
delegatedTo: null,
};
const mockSubscriptionResult = {
provider: "google_calendar" as const,
id: "test-channel-id",
resourceId: "test-resource-id",
resourceUri: "test-resource-uri",
expiration: new Date(Date.now() + 86400000),
};
const mockEvents = {
provider: "google_calendar" as const,
syncToken: "new-sync-token",
items: [
{
id: "event-1",
iCalUID: "event-1@cal.com",
start: new Date(),
end: new Date(Date.now() + 3600000),
busy: true,
summary: "Test Event",
description: "Test Description",
location: "Test Location",
status: "confirmed",
isAllDay: false,
timeZone: "UTC",
recurringEventId: null,
originalStartDate: null,
createdAt: new Date(),
updatedAt: new Date(),
etag: "test-etag",
kind: "calendar#event",
},
],
};
describe("CalendarSubscriptionService", () => {
let service: CalendarSubscriptionService;
let mockAdapterFactory: AdapterFactory;
let mockSelectedCalendarRepository: ISelectedCalendarRepository;
let mockFeaturesRepository: FeaturesRepository;
let mockCalendarCacheEventService: CalendarCacheEventService;
let mockCalendarSyncService: CalendarSyncService;
let mockAdapter: {
subscribe: ReturnType<typeof vi.fn>;
unsubscribe: ReturnType<typeof vi.fn>;
validate: ReturnType<typeof vi.fn>;
extractChannelId: ReturnType<typeof vi.fn>;
fetchEvents: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
mockAdapter = {
subscribe: vi.fn().mockResolvedValue(mockSubscriptionResult),
unsubscribe: vi.fn().mockResolvedValue(undefined),
validate: vi.fn().mockResolvedValue(true),
extractChannelId: vi.fn().mockResolvedValue("test-channel-id"),
fetchEvents: vi.fn().mockResolvedValue(mockEvents),
};
mockAdapterFactory = {
get: vi.fn().mockReturnValue(mockAdapter),
getProviders: vi.fn().mockReturnValue(["google_calendar", "office365_calendar"]),
};
mockSelectedCalendarRepository = {
findById: vi.fn().mockResolvedValue(mockSelectedCalendar),
findByChannelId: vi.fn().mockResolvedValue(mockSelectedCalendar),
findNextSubscriptionBatch: vi.fn().mockResolvedValue([mockSelectedCalendar]),
updateSyncStatus: vi.fn().mockResolvedValue(mockSelectedCalendar),
updateSubscription: vi.fn().mockResolvedValue(mockSelectedCalendar),
};
mockFeaturesRepository = {
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
checkIfUserHasFeature: vi.fn().mockResolvedValue(true),
};
mockCalendarCacheEventService = {
handleEvents: vi.fn().mockResolvedValue(undefined),
cleanupCache: vi.fn().mockResolvedValue(undefined),
cleanupStaleCache: vi.fn().mockResolvedValue(undefined),
};
mockCalendarSyncService = {
handleEvents: vi.fn().mockResolvedValue(undefined),
};
service = new CalendarSubscriptionService({
adapterFactory: mockAdapterFactory,
selectedCalendarRepository: mockSelectedCalendarRepository,
featuresRepository: mockFeaturesRepository,
calendarCacheEventService: mockCalendarCacheEventService,
calendarSyncService: mockCalendarSyncService,
});
const { getCredentialForSelectedCalendar } = await import("../__mocks__/delegationCredential");
getCredentialForSelectedCalendar.mockResolvedValue(mockCredential);
});
describe("subscribe", () => {
test("should successfully subscribe to a calendar", async () => {
await service.subscribe("test-calendar-id");
expect(mockSelectedCalendarRepository.findById).toHaveBeenCalledWith("test-calendar-id");
expect(mockAdapterFactory.get).toHaveBeenCalledWith("google_calendar");
expect(mockAdapter.subscribe).toHaveBeenCalledWith(mockSelectedCalendar, mockCredential);
expect(mockSelectedCalendarRepository.updateSubscription).toHaveBeenCalledWith("test-calendar-id", {
channelId: "test-channel-id",
channelResourceId: "test-resource-id",
channelResourceUri: "test-resource-uri",
channelKind: "google_calendar",
channelExpiration: mockSubscriptionResult.expiration,
syncSubscribedAt: expect.any(Date),
});
});
test("should return early if selected calendar not found", async () => {
mockSelectedCalendarRepository.findById.mockResolvedValue(null);
await service.subscribe("non-existent-id");
expect(mockAdapter.subscribe).not.toHaveBeenCalled();
expect(mockSelectedCalendarRepository.updateSubscription).not.toHaveBeenCalled();
});
test("should return early if selected calendar has no credentialId or delegationCredentialId", async () => {
mockSelectedCalendarRepository.findById.mockResolvedValue({
...mockSelectedCalendar,
credentialId: null,
delegationCredentialId: null,
});
await service.subscribe("test-calendar-id");
expect(mockAdapter.subscribe).not.toHaveBeenCalled();
expect(mockSelectedCalendarRepository.updateSubscription).not.toHaveBeenCalled();
});
});
describe("unsubscribe", () => {
test("should successfully unsubscribe from a calendar", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
await service.unsubscribe("test-calendar-id");
expect(mockSelectedCalendarRepository.findById).toHaveBeenCalledWith("test-calendar-id");
expect(mockAdapter.unsubscribe).toHaveBeenCalledWith(mockSelectedCalendar, mockCredential);
expect(mockSelectedCalendarRepository.updateSubscription).toHaveBeenCalledWith("test-calendar-id", {
syncSubscribedAt: null,
});
expect(mockCalendarCacheEventService.cleanupCache).toHaveBeenCalledWith(mockSelectedCalendar);
});
test("should not cleanup cache if cache is disabled", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(false);
await service.unsubscribe("test-calendar-id");
expect(mockCalendarCacheEventService.cleanupCache).not.toHaveBeenCalled();
});
test("should return early if selected calendar not found", async () => {
mockSelectedCalendarRepository.findById.mockResolvedValue(null);
await service.unsubscribe("non-existent-id");
expect(mockAdapter.unsubscribe).not.toHaveBeenCalled();
});
});
describe("processWebhook", () => {
test("should successfully process a valid webhook", async () => {
const mockRequest = new Request("http://example.com");
await service.processWebhook("google_calendar", mockRequest);
expect(mockAdapter.validate).toHaveBeenCalledWith(mockRequest);
expect(mockAdapter.extractChannelId).toHaveBeenCalledWith(mockRequest);
expect(mockSelectedCalendarRepository.findByChannelId).toHaveBeenCalledWith("test-channel-id");
});
test("should throw error for invalid webhook", async () => {
const mockRequest = new Request("http://example.com");
mockAdapter.validate.mockResolvedValue(false);
await expect(service.processWebhook("google_calendar", mockRequest)).rejects.toThrow(
"Invalid webhook request"
);
});
test("should throw error for missing channel ID", async () => {
const mockRequest = new Request("http://example.com");
mockAdapter.extractChannelId.mockResolvedValue(null);
await expect(service.processWebhook("google_calendar", mockRequest)).rejects.toThrow(
"Missing channel ID in webhook"
);
});
test("should return null for old subscription", async () => {
const mockRequest = new Request("http://example.com");
mockSelectedCalendarRepository.findByChannelId.mockResolvedValue(null);
const _result = await service.processWebhook("google_calendar", mockRequest);
expect(_result).toBeNull();
});
});
describe("processEvents", () => {
test("should process events when both cache and sync are enabled", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true);
await service.processEvents(mockSelectedCalendar);
expect(mockAdapter.fetchEvents).toHaveBeenCalledWith(mockSelectedCalendar, mockCredential);
expect(mockSelectedCalendarRepository.updateSyncStatus).toHaveBeenCalledWith(mockSelectedCalendar.id, {
syncToken: "new-sync-token",
syncedAt: expect.any(Date),
syncErrorAt: null,
syncErrorCount: 0,
});
expect(mockCalendarCacheEventService.handleEvents).toHaveBeenCalledWith(
mockSelectedCalendar,
mockEvents.items
);
expect(mockCalendarSyncService.handleEvents).toHaveBeenCalledWith(
mockSelectedCalendar,
mockEvents.items
);
});
test("should not process cache when cache is disabled globally", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true);
await service.processEvents(mockSelectedCalendar);
expect(mockCalendarCacheEventService.handleEvents).not.toHaveBeenCalled();
expect(mockCalendarSyncService.handleEvents).toHaveBeenCalled();
});
test("should not process cache when cache is disabled for user", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(false);
await service.processEvents(mockSelectedCalendar);
expect(mockCalendarCacheEventService.handleEvents).not.toHaveBeenCalled();
expect(mockCalendarSyncService.handleEvents).toHaveBeenCalled();
});
test("should return early when both cache and sync are disabled", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false);
await service.processEvents(mockSelectedCalendar);
expect(mockAdapter.fetchEvents).not.toHaveBeenCalled();
expect(mockCalendarCacheEventService.handleEvents).not.toHaveBeenCalled();
expect(mockCalendarSyncService.handleEvents).not.toHaveBeenCalled();
});
test("should handle API errors and update sync status", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true);
const apiError = new Error("API Error");
mockAdapter.fetchEvents.mockRejectedValue(apiError);
await expect(service.processEvents(mockSelectedCalendar)).rejects.toThrow("API Error");
expect(mockSelectedCalendarRepository.updateSyncStatus).toHaveBeenCalledWith(mockSelectedCalendar.id, {
syncErrorAt: expect.any(Date),
syncErrorCount: {
increment: 1,
},
});
});
test("should return early when no events are fetched", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true);
mockAdapter.fetchEvents.mockResolvedValue({
...mockEvents,
items: [],
});
await service.processEvents(mockSelectedCalendar);
expect(mockCalendarCacheEventService.handleEvents).not.toHaveBeenCalled();
expect(mockCalendarSyncService.handleEvents).not.toHaveBeenCalled();
});
test("should return early when selected calendar has no credentialId or delegationCredentialId", async () => {
const calendarWithoutCredential = {
...mockSelectedCalendar,
credentialId: null,
delegationCredentialId: null,
};
await service.processEvents(calendarWithoutCredential);
expect(mockAdapter.fetchEvents).not.toHaveBeenCalled();
});
});
describe("checkForNewSubscriptions", () => {
test("should process new subscriptions", async () => {
const subscribeSpy = vi.spyOn(service, "subscribe").mockResolvedValue(undefined);
await service.checkForNewSubscriptions();
expect(mockSelectedCalendarRepository.findNextSubscriptionBatch).toHaveBeenCalledWith({
take: 100,
integrations: ["google_calendar", "office365_calendar"],
});
expect(subscribeSpy).toHaveBeenCalledWith(mockSelectedCalendar.id);
});
});
describe("feature flag methods", () => {
test("isCacheEnabled should check global cache feature", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
const result = await service.isCacheEnabled();
expect(result).toBe(true);
expect(mockFeaturesRepository.checkIfFeatureIsEnabledGlobally).toHaveBeenCalledWith(
"calendar-subscription-cache"
);
});
test("isCacheEnabledForUser should check user cache feature", async () => {
mockFeaturesRepository.checkIfUserHasFeature.mockResolvedValue(true);
const result = await service.isCacheEnabledForUser(1);
expect(result).toBe(true);
expect(mockFeaturesRepository.checkIfUserHasFeature).toHaveBeenCalledWith(
1,
"calendar-subscription-cache"
);
});
test("isSyncEnabled should check global sync feature", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
const result = await service.isSyncEnabled();
expect(result).toBe(true);
expect(mockFeaturesRepository.checkIfFeatureIsEnabledGlobally).toHaveBeenCalledWith(
"calendar-subscription-sync"
);
});
});
});