-
Notifications
You must be signed in to change notification settings - Fork 11.1k
feat: Calendar Cache #23876
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Calendar Cache #23876
Changes from 58 commits
78ba17e
6b6d00d
79de50b
11d1bd2
fc5b3f9
4daf6d6
f027bfc
118a88a
47a231d
da76f39
c6b45ea
55d484b
36eadd9
1d2e0f1
c5f8fab
739e3bc
d9891c1
e325552
72f30ae
088aa3f
10b8607
14a8c6a
b6dc0bf
30b2ad2
a683faa
b486347
075e055
b1ef7b6
0b8d5d6
7c267ad
5e8ed4f
2605eee
68bde1e
c556e66
6066bf0
d34b878
f147197
a967b42
95bf75f
fdbf170
43e892d
35b4f6b
5238b0d
153a85e
f707d1e
a251399
57c1133
87d4e0d
cf10b33
81ffd24
9d514ab
0b887e1
893d1e5
1e430fe
3928bbc
9d564e4
0a5b3f6
9dbf30a
a05a3c6
6184978
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| import { NextRequest } from "next/server"; | ||
| import { describe, test, expect, vi, beforeEach } from "vitest"; | ||
|
|
||
| import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; | ||
|
|
||
| vi.mock("next/server", () => ({ | ||
| NextRequest: class MockNextRequest { | ||
| url: string; | ||
| method: string; | ||
| nextUrl: { searchParams: URLSearchParams }; | ||
| private _headers: Map<string, string>; | ||
|
|
||
| constructor(url: string, options: { method?: string } = {}) { | ||
| this.url = url; | ||
| this.method = options.method || "GET"; | ||
| this._headers = new Map(); | ||
| this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") }; | ||
| } | ||
|
|
||
| headers = { | ||
| get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null, | ||
| set: (key: string, value: string): void => { | ||
| this._headers.set(key.toLowerCase(), value); | ||
| }, | ||
| has: (key: string): boolean => this._headers.has(key.toLowerCase()), | ||
| }; | ||
| }, | ||
| NextResponse: { | ||
| json: vi.fn((body, init) => ({ | ||
| json: vi.fn().mockResolvedValue(body), | ||
| status: init?.status || 200, | ||
| })), | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"); | ||
| vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository"); | ||
| vi.mock("@calcom/lib/logger", () => ({ | ||
| default: { | ||
| getSubLogger: vi.fn(() => ({ | ||
| info: vi.fn(), | ||
| error: vi.fn(), | ||
| })), | ||
| }, | ||
| })); | ||
| vi.mock("@calcom/lib/server/perfObserver", () => ({ | ||
| performance: { | ||
| mark: vi.fn(), | ||
| measure: vi.fn(), | ||
| }, | ||
| })); | ||
| vi.mock("@sentry/nextjs", () => ({ | ||
| wrapApiHandlerWithSentry: vi.fn((handler) => handler), | ||
| captureException: vi.fn(), | ||
| })); | ||
| vi.mock("@calcom/lib/server/getServerErrorFromUnknown", () => ({ | ||
| getServerErrorFromUnknown: vi.fn((error) => ({ | ||
| message: error instanceof Error ? error.message : "Unknown error", | ||
| statusCode: 500, | ||
| url: "test-url", | ||
| method: "GET", | ||
| })), | ||
| })); | ||
| vi.mock("../../defaultResponderForAppDir", () => ({ | ||
| defaultResponderForAppDir: vi.fn((handler) => handler), | ||
| })); | ||
| vi.mock("@calcom/prisma", () => ({ | ||
| prisma: {}, | ||
| })); | ||
|
|
||
| const mockCalendarCacheEventService = vi.mocked(CalendarCacheEventService); | ||
|
|
||
| describe("/api/cron/calendar-subscriptions-cleanup", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| vi.stubEnv("CRON_API_KEY", "test-cron-key"); | ||
| vi.stubEnv("CRON_SECRET", "test-cron-secret"); | ||
| }); | ||
|
|
||
| describe("Authentication", () => { | ||
| test("should return 403 when no API key is provided", async () => { | ||
| const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); | ||
|
|
||
| const { GET } = await import("../route"); | ||
| const response = await GET(request, { params: Promise.resolve({}) }); | ||
|
|
||
| expect(response.status).toBe(403); | ||
| const body = await response.json(); | ||
| expect(body.message).toBe("Forbiden"); | ||
|
Check failure on line 89 in apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts
|
||
| }); | ||
|
|
||
| test("should return 403 when invalid API key is provided", async () => { | ||
| const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); | ||
| request.headers.set("authorization", "invalid-key"); | ||
|
|
||
| const { GET } = await import("../route"); | ||
| const response = await GET(request, { params: Promise.resolve({}) }); | ||
|
|
||
| expect(response.status).toBe(403); | ||
| const body = await response.json(); | ||
| expect(body.message).toBe("Forbiden"); | ||
|
Check failure on line 101 in apps/web/app/api/cron/calendar-subscriptions-cleanup/__tests__/route.test.ts
|
||
| }); | ||
|
|
||
| test("should accept CRON_API_KEY in authorization header", async () => { | ||
| const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); | ||
| request.headers.set("authorization", "test-cron-key"); | ||
|
|
||
| const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined); | ||
| mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; | ||
|
|
||
| const { GET } = await import("../route"); | ||
| const response = await GET(request, { params: Promise.resolve({}) }); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(mockCleanupStaleCache).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("should accept CRON_SECRET as Bearer token", async () => { | ||
| const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); | ||
| request.headers.set("authorization", "Bearer test-cron-secret"); | ||
|
|
||
| const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined); | ||
| mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; | ||
|
|
||
| const { GET } = await import("../route"); | ||
| const response = await GET(request, { params: Promise.resolve({}) }); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(mockCleanupStaleCache).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("should accept API key as query parameter", async () => { | ||
| const request = new NextRequest( | ||
| "http://localhost/api/cron/calendar-subscriptions-cleanup?apiKey=test-cron-key" | ||
| ); | ||
|
|
||
| const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined); | ||
| mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; | ||
|
|
||
| const { GET } = await import("../route"); | ||
| const response = await GET(request, { params: Promise.resolve({}) }); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(mockCleanupStaleCache).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("Cleanup functionality", () => { | ||
| test("should successfully cleanup stale cache", async () => { | ||
| const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); | ||
| request.headers.set("authorization", "test-cron-key"); | ||
|
|
||
| const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined); | ||
| mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; | ||
|
|
||
| const { GET } = await import("../route"); | ||
| const response = await GET(request, { params: Promise.resolve({}) }); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| const body = await response.json(); | ||
| expect(body.ok).toBe(true); | ||
| expect(mockCleanupStaleCache).toHaveBeenCalledOnce(); | ||
| }); | ||
|
|
||
| test("should handle cleanup errors gracefully", async () => { | ||
| const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); | ||
| request.headers.set("authorization", "test-cron-key"); | ||
|
|
||
| const mockError = new Error("Database connection failed"); | ||
| const mockCleanupStaleCache = vi.fn().mockRejectedValue(mockError); | ||
| mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; | ||
|
|
||
| const { GET } = await import("../route"); | ||
| const response = await GET(request, { params: Promise.resolve({}) }); | ||
|
|
||
| expect(response.status).toBe(500); | ||
| const body = await response.json(); | ||
| expect(body.message).toBe("Database connection failed"); | ||
| }); | ||
|
|
||
| test("should handle non-Error exceptions", async () => { | ||
| const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); | ||
| request.headers.set("authorization", "test-cron-key"); | ||
|
|
||
| const mockCleanupStaleCache = vi.fn().mockRejectedValue("String error"); | ||
| mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; | ||
|
|
||
| const { GET } = await import("../route"); | ||
| const response = await GET(request, { params: Promise.resolve({}) }); | ||
|
|
||
| expect(response.status).toBe(500); | ||
| const body = await response.json(); | ||
| expect(body.message).toBe("Unknown error"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("Service instantiation", () => { | ||
| test("should instantiate CalendarCacheEventService with correct dependencies", async () => { | ||
| const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup"); | ||
| request.headers.set("authorization", "test-cron-key"); | ||
|
|
||
| const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined); | ||
| mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache; | ||
|
|
||
| const { GET } = await import("../route"); | ||
| await GET(request, { params: Promise.resolve({}) }); | ||
|
|
||
| expect(mockCalendarCacheEventService).toHaveBeenCalledWith({ | ||
| calendarCacheEventRepository: expect.any(Object), | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import type { NextRequest } from "next/server"; | ||
| import { NextResponse } from "next/server"; | ||
|
|
||
| import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository"; | ||
| import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService"; | ||
| import { prisma } from "@calcom/prisma"; | ||
| import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir"; | ||
|
|
||
| /** | ||
| * Cron webhook | ||
| * Cleanup stale calendar cache | ||
| * | ||
| * @param request | ||
| * @returns | ||
| */ | ||
| async function getHandler(request: NextRequest) { | ||
| const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey"); | ||
|
|
||
| if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) { | ||
| return NextResponse.json({ message: "Forbidden" }, { status: 403 }); | ||
| } | ||
|
|
||
| // instantiate dependencies | ||
| const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want to go di container here? Think so, but up to you. |
||
| const calendarCacheEventService = new CalendarCacheEventService({ | ||
| calendarCacheEventRepository, | ||
| }); | ||
|
|
||
| try { | ||
| await calendarCacheEventService.cleanupStaleCache(); | ||
| return NextResponse.json({ ok: true }); | ||
| } catch (e: unknown) { | ||
| const message = e instanceof Error ? e.message : "Unknown error"; | ||
| console.error(`[calendar-subscriptions-cleanup] ${message}:`, e); | ||
| return NextResponse.json({ message }, { status: 500 }); | ||
| } | ||
| } | ||
|
|
||
| export const GET = defaultResponderForAppDir(getHandler); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wouldn't call these Authorization headers, these are webhook secrets; I'd do the simplest possible check, then a direct match. Not even going "Not authenticated", just go full on Forbidden.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed! I just kept this way to stay consistent with the rest of our webhooks and cron-tester. But changed to 403 instead of 401. Let me know what you think