Skip to content

Commit e6b2116

Browse files
volneidevin-ai-integration[bot]emrysalkeithwillcode
authored
feat: Calendar Cache and Sync (calcom#23876)
* feat: calendar cache and sync - wip * Add env.example * refactor on CalendarCacheEventService * remove test console.log * Fix type checks errors * chore: remove pt comment * add route.ts * chore: fix tests * Improve cache impl * chore: update recurring event id * chore: small improvements * calendar cache improvements * Fix remove dynamic imports * Add cleanup stale cache * Fix tests * add event update * type fixes * feat: add comprehensive tests for new calendar subscription API routes - Add tests for /api/cron/calendar-subscriptions-cleanup route (9 tests) - Add tests for /api/cron/calendar-subscriptions route (10 tests) - Add tests for /api/webhooks/calendar-subscription/[provider] route (11 tests) - Add missing feature flags for calendar-subscription-cache and calendar-subscription-sync - All 30 tests pass with comprehensive coverage of authentication, feature flags, error handling, and service instantiation Tests cover: - Authentication scenarios (API key validation, Bearer tokens, query parameters) - Feature flag combinations (cache/sync enabled/disabled states) - Success and error handling (including non-Error exceptions) - Service instantiation with proper dependency injection - Provider validation for webhook endpoints Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * feat: add comprehensive tests for calendar subscription services, repositories, and adapters - Add unit tests for CalendarSubscriptionService with subscription, webhook, and event processing - Add unit tests for CalendarCacheEventService with cache operations and cleanup - Add unit tests for CalendarSyncService with Cal.com event filtering and booking operations - Add unit tests for CalendarCacheEventRepository with CRUD operations - Add unit tests for SelectedCalendarRepository with calendar selection management - Add unit tests for GoogleCalendarSubscriptionAdapter with subscription and event fetching - Add unit tests for Office365CalendarSubscriptionAdapter with placeholder implementation - Add unit tests for AdaptersFactory with provider management and adapter creation - Fix lint issues by removing explicit 'any' type casting and unused variables - All tests follow Cal.com conventions using Vitest framework with proper mocking Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * fix: improve calendar-subscriptions-cleanup test performance by adding missing mocks - Add comprehensive mocks for defaultResponderForAppDir, logger, performance monitoring, and Sentry - Fix slow test execution (933ms -> <100ms) caused by missing dependency mocks - Ensure consistent test performance across different environments Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * Fix tests * Fix tests * type fix * Fix coderabbit comments * Fix types * Fix test * Update apps/web/app/api/cron/calendar-subscriptions/route.ts Co-authored-by: Alex van Andel <me@alexvanandel.com> * Fixes by first review * feat: add database migrations for calendar cache and sync fields - Add CalendarCacheEventStatus enum with confirmed, tentative, cancelled values - Add new fields to SelectedCalendar: channelId, channelKind, channelResourceId, channelResourceUri, channelExpiration, syncSubscribedAt, syncToken, syncedAt, syncErrorAt, syncErrorCount - Create CalendarCacheEvent table with foreign key to SelectedCalendar - Add necessary indexes and constraints for performance and data integrity Fixes database schema issues causing e2e test failures with 'column does not exist' errors. Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * only google-calendar for now * docs: add Calendar Cache and Sync feature documentation - Add comprehensive feature overview and motivation - Document feature flags with SQL examples - Include SQL examples for enabling features for users and teams - Reference technical documentation files Addresses PR calcom#23876 documentation requirements Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * docs: update calendar subscription README with comprehensive documentation - Undo incorrect changes to main README.md - Update packages/features/calendar-subscription/README.md with: - Feature overview and motivation - Environment variables section - Complete feature flags documentation with SQL examples - SQL examples for enabling features for users and teams - Detailed architecture documentation Addresses PR calcom#23876 documentation requirements Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> * fix docs * Fix test to available calendars * Fix test to available calendars * add migration and sync boilerplate * fix typo * remove double log * sync boilerplate --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com>
1 parent 855a020 commit e6b2116

42 files changed

Lines changed: 4723 additions & 42 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@ GOOGLE_WEBHOOK_TOKEN=
144144
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
145145
GOOGLE_WEBHOOK_URL=
146146

147+
# Token to verify incoming webhooks from Microsoft Calendar
148+
MICROSOFT_WEBHOOK_TOKEN=
149+
150+
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
151+
MICROSOFT_WEBHOOK_URL=
152+
147153
# Inbox to send user feedback
148154
SEND_FEEDBACK_EMAIL=
149155

apps/api/v1/test/lib/selected-calendars/_post.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createMocks } from "node-mocks-http";
66
import { describe, expect, test } from "vitest";
77

88
import { HttpError } from "@calcom/lib/http-error";
9+
import type { User } from "@calcom/prisma/client";
910

1011
import handler from "../../../pages/api/selected-calendars/_post";
1112

@@ -72,7 +73,7 @@ describe("POST /api/selected-calendars", () => {
7273

7374
prismaMock.user.findFirstOrThrow.mockResolvedValue({
7475
id: 444444,
75-
} as any);
76+
} as User);
7677

7778
prismaMock.selectedCalendar.create.mockResolvedValue({
7879
credentialId: 1,
@@ -95,6 +96,16 @@ describe("POST /api/selected-calendars", () => {
9596
unwatchAttempts: 0,
9697
createdAt: new Date(),
9798
updatedAt: new Date(),
99+
channelId: null,
100+
channelKind: null,
101+
channelResourceId: null,
102+
channelResourceUri: null,
103+
channelExpiration: null,
104+
syncSubscribedAt: null,
105+
syncToken: null,
106+
syncedAt: null,
107+
syncErrorAt: null,
108+
syncErrorCount: null,
98109
});
99110

100111
await handler(req, res);
@@ -140,6 +151,16 @@ describe("POST /api/selected-calendars", () => {
140151
unwatchAttempts: 0,
141152
createdAt: new Date(),
142153
updatedAt: new Date(),
154+
channelId: null,
155+
channelKind: null,
156+
channelResourceId: null,
157+
channelResourceUri: null,
158+
channelExpiration: null,
159+
syncSubscribedAt: null,
160+
syncToken: null,
161+
syncedAt: null,
162+
syncErrorAt: null,
163+
syncErrorCount: null,
143164
});
144165

145166
await handler(req, res);
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { NextRequest } from "next/server";
2+
import { describe, test, expect, vi, beforeEach } from "vitest";
3+
4+
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
5+
6+
vi.mock("next/server", () => ({
7+
NextRequest: class MockNextRequest {
8+
url: string;
9+
method: string;
10+
nextUrl: { searchParams: URLSearchParams };
11+
private _headers: Map<string, string>;
12+
13+
constructor(url: string, options: { method?: string } = {}) {
14+
this.url = url;
15+
this.method = options.method || "GET";
16+
this._headers = new Map();
17+
this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") };
18+
}
19+
20+
headers = {
21+
get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null,
22+
set: (key: string, value: string): void => {
23+
this._headers.set(key.toLowerCase(), value);
24+
},
25+
has: (key: string): boolean => this._headers.has(key.toLowerCase()),
26+
};
27+
},
28+
NextResponse: {
29+
json: vi.fn((body, init) => ({
30+
json: vi.fn().mockResolvedValue(body),
31+
status: init?.status || 200,
32+
})),
33+
},
34+
}));
35+
36+
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService");
37+
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository");
38+
vi.mock("@calcom/lib/logger", () => ({
39+
default: {
40+
getSubLogger: vi.fn(() => ({
41+
info: vi.fn(),
42+
error: vi.fn(),
43+
})),
44+
},
45+
}));
46+
vi.mock("@calcom/lib/server/perfObserver", () => ({
47+
performance: {
48+
mark: vi.fn(),
49+
measure: vi.fn(),
50+
},
51+
}));
52+
vi.mock("@sentry/nextjs", () => ({
53+
wrapApiHandlerWithSentry: vi.fn((handler) => handler),
54+
captureException: vi.fn(),
55+
}));
56+
vi.mock("@calcom/lib/server/getServerErrorFromUnknown", () => ({
57+
getServerErrorFromUnknown: vi.fn((error) => ({
58+
message: error instanceof Error ? error.message : "Unknown error",
59+
statusCode: 500,
60+
url: "test-url",
61+
method: "GET",
62+
})),
63+
}));
64+
vi.mock("../../defaultResponderForAppDir", () => ({
65+
defaultResponderForAppDir: vi.fn((handler) => handler),
66+
}));
67+
vi.mock("@calcom/prisma", () => ({
68+
prisma: {},
69+
}));
70+
71+
const mockCalendarCacheEventService = vi.mocked(CalendarCacheEventService);
72+
73+
describe("/api/cron/calendar-subscriptions-cleanup", () => {
74+
beforeEach(() => {
75+
vi.clearAllMocks();
76+
vi.stubEnv("CRON_API_KEY", "test-cron-key");
77+
vi.stubEnv("CRON_SECRET", "test-cron-secret");
78+
});
79+
80+
describe("Authentication", () => {
81+
test("should return 403 when no API key is provided", async () => {
82+
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
83+
84+
const { GET } = await import("../route");
85+
const response = await GET(request, { params: Promise.resolve({}) });
86+
87+
expect(response.status).toBe(403);
88+
const body = await response.json();
89+
expect(body.message).toBe("Forbidden");
90+
});
91+
92+
test("should return 403 when invalid API key is provided", async () => {
93+
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
94+
request.headers.set("authorization", "invalid-key");
95+
96+
const { GET } = await import("../route");
97+
const response = await GET(request, { params: Promise.resolve({}) });
98+
99+
expect(response.status).toBe(403);
100+
const body = await response.json();
101+
expect(body.message).toBe("Forbidden");
102+
});
103+
104+
test("should accept CRON_API_KEY in authorization header", async () => {
105+
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
106+
request.headers.set("authorization", "test-cron-key");
107+
108+
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
109+
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
110+
111+
const { GET } = await import("../route");
112+
const response = await GET(request, { params: Promise.resolve({}) });
113+
114+
expect(response.status).toBe(200);
115+
expect(mockCleanupStaleCache).toHaveBeenCalled();
116+
});
117+
118+
test("should accept CRON_SECRET as Bearer token", async () => {
119+
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
120+
request.headers.set("authorization", "Bearer test-cron-secret");
121+
122+
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
123+
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
124+
125+
const { GET } = await import("../route");
126+
const response = await GET(request, { params: Promise.resolve({}) });
127+
128+
expect(response.status).toBe(200);
129+
expect(mockCleanupStaleCache).toHaveBeenCalled();
130+
});
131+
132+
test("should accept API key as query parameter", async () => {
133+
const request = new NextRequest(
134+
"http://localhost/api/cron/calendar-subscriptions-cleanup?apiKey=test-cron-key"
135+
);
136+
137+
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
138+
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
139+
140+
const { GET } = await import("../route");
141+
const response = await GET(request, { params: Promise.resolve({}) });
142+
143+
expect(response.status).toBe(200);
144+
expect(mockCleanupStaleCache).toHaveBeenCalled();
145+
});
146+
});
147+
148+
describe("Cleanup functionality", () => {
149+
test("should successfully cleanup stale cache", async () => {
150+
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
151+
request.headers.set("authorization", "test-cron-key");
152+
153+
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
154+
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
155+
156+
const { GET } = await import("../route");
157+
const response = await GET(request, { params: Promise.resolve({}) });
158+
159+
expect(response.status).toBe(200);
160+
const body = await response.json();
161+
expect(body.ok).toBe(true);
162+
expect(mockCleanupStaleCache).toHaveBeenCalledOnce();
163+
});
164+
165+
test("should handle cleanup errors gracefully", async () => {
166+
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
167+
request.headers.set("authorization", "test-cron-key");
168+
169+
const mockError = new Error("Database connection failed");
170+
const mockCleanupStaleCache = vi.fn().mockRejectedValue(mockError);
171+
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
172+
173+
const { GET } = await import("../route");
174+
const response = await GET(request, { params: Promise.resolve({}) });
175+
176+
expect(response.status).toBe(500);
177+
const body = await response.json();
178+
expect(body.message).toBe("Database connection failed");
179+
});
180+
181+
test("should handle non-Error exceptions", async () => {
182+
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
183+
request.headers.set("authorization", "test-cron-key");
184+
185+
const mockCleanupStaleCache = vi.fn().mockRejectedValue("String error");
186+
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
187+
188+
const { GET } = await import("../route");
189+
const response = await GET(request, { params: Promise.resolve({}) });
190+
191+
expect(response.status).toBe(500);
192+
const body = await response.json();
193+
expect(body.message).toBe("Unknown error");
194+
});
195+
});
196+
197+
describe("Service instantiation", () => {
198+
test("should instantiate CalendarCacheEventService with correct dependencies", async () => {
199+
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
200+
request.headers.set("authorization", "test-cron-key");
201+
202+
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
203+
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
204+
205+
const { GET } = await import("../route");
206+
await GET(request, { params: Promise.resolve({}) });
207+
208+
expect(mockCalendarCacheEventService).toHaveBeenCalledWith({
209+
calendarCacheEventRepository: expect.any(Object),
210+
});
211+
});
212+
});
213+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { NextRequest } from "next/server";
2+
import { NextResponse } from "next/server";
3+
4+
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
5+
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
6+
import { prisma } from "@calcom/prisma";
7+
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
8+
9+
/**
10+
* Cron webhook
11+
* Cleanup stale calendar cache
12+
*
13+
* @param request
14+
* @returns
15+
*/
16+
async function getHandler(request: NextRequest) {
17+
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
18+
19+
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
20+
return NextResponse.json({ message: "Forbidden" }, { status: 403 });
21+
}
22+
23+
// instantiate dependencies
24+
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
25+
const calendarCacheEventService = new CalendarCacheEventService({
26+
calendarCacheEventRepository,
27+
});
28+
29+
try {
30+
await calendarCacheEventService.cleanupStaleCache();
31+
return NextResponse.json({ ok: true });
32+
} catch (e: unknown) {
33+
const message = e instanceof Error ? e.message : "Unknown error";
34+
console.error(`[calendar-subscriptions-cleanup] ${message}:`, e);
35+
return NextResponse.json({ message }, { status: 500 });
36+
}
37+
}
38+
39+
export const GET = defaultResponderForAppDir(getHandler);

0 commit comments

Comments
 (0)