mirror of
https://github.com/hanzoai/calendar.git
synced 2026-09-24 22:42:24 +00:00
* feat: Cal.diy — community-driven MIT-licensed fork of Cal.com This squashed commit contains all Cal.diy changes applied on top of calcom/cal.com main: - Rebrand Cal.com to Cal.diy across the entire codebase - Remove Enterprise Edition (EE) features, license checks, and AGPL restrictions - Switch license from AGPL-3.0 to MIT - Remove docs/ directory (migrated to Nextra at cal.diy) - Remove dead code: org tests, EE tips, platform nav, premium username, SAML/SSO, etc. - Clean up .env.example for self-hosted Cal.diy - Update Docker image references to calcom/cal.diy - Update README, CONTRIBUTING.md, and issue templates for Cal.diy community fork - Add PR welcome bot for Cal.diy contributors - Fix API v2 breaking changes oasdiff ignore entries - Replace Blacksmith CI runners with default GitHub Actions 3893 files changed, 20789 insertions(+), 411020 deletions(-) Co-Authored-By: [email protected] <[email protected]> * refactor: remove org-specific /organizations/:orgId endpoints from API v2 atoms controllers (#1701) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: revert Cal.diy Inc to Cal.com, Inc. in license files, copyright notices, and package metadata (#1702) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * rip out org related comments in api v2 --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
525 lines
15 KiB
TypeScript
525 lines
15 KiB
TypeScript
import "../__mocks__/CalendarAuth";
|
|
|
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
import dayjs from "@calcom/dayjs";
|
|
import type { SelectedCalendar } from "@calcom/prisma/client";
|
|
import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential";
|
|
|
|
import { GoogleCalendarSubscriptionAdapter } from "../GoogleCalendarSubscription.adapter";
|
|
|
|
const addMonthsFromNow = (months: number) => {
|
|
const date = dayjs();
|
|
return date.add(months, "months").startOf("day");
|
|
};
|
|
|
|
const today = dayjs().startOf("day");
|
|
|
|
const oneWeekFromNow = today.add(7, "days");
|
|
|
|
const eventEndTime = oneWeekFromNow.add(1, "hours");
|
|
|
|
const channelExpirationDate = new Date(Date.now() + 86400000);
|
|
|
|
vi.mock("uuid", () => ({
|
|
v4: vi.fn().mockReturnValue("test-uuid"),
|
|
}));
|
|
|
|
const mockSelectedCalendar: SelectedCalendar = {
|
|
id: "test-calendar-id",
|
|
userId: 1,
|
|
credentialId: 1,
|
|
integration: "google_calendar",
|
|
externalId: "[email protected]",
|
|
eventTypeId: null,
|
|
delegationCredentialId: null,
|
|
googleChannelId: null,
|
|
googleChannelKind: null,
|
|
googleChannelResourceId: null,
|
|
googleChannelResourceUri: null,
|
|
googleChannelExpiration: null,
|
|
error: null,
|
|
lastErrorAt: null,
|
|
watchAttempts: 0,
|
|
maxAttempts: 3,
|
|
unwatchAttempts: 0,
|
|
createdAt: today.toDate(),
|
|
updatedAt: today.toDate(),
|
|
channelId: "test-channel-id",
|
|
channelKind: "web_hook",
|
|
channelResourceId: "test-resource-id",
|
|
channelResourceUri: "test-resource-uri",
|
|
channelExpiration: channelExpirationDate,
|
|
syncSubscribedAt: today.toDate(),
|
|
syncSubscribedErrorAt: null,
|
|
syncSubscribedErrorCount: 0,
|
|
syncToken: "test-sync-token",
|
|
syncedAt: today.toDate(),
|
|
syncErrorAt: null,
|
|
syncErrorCount: 0,
|
|
};
|
|
|
|
const mockCredential = {
|
|
id: 1,
|
|
key: { access_token: "test-token" },
|
|
user: { email: "[email protected]" },
|
|
delegatedTo: null,
|
|
type: null,
|
|
teamId: null,
|
|
} as unknown as CredentialForCalendarServiceWithEmail;
|
|
|
|
describe("GoogleCalendarSubscriptionAdapter", () => {
|
|
let adapter: GoogleCalendarSubscriptionAdapter;
|
|
let mockClient: {
|
|
events: {
|
|
watch: ReturnType<typeof vi.fn>;
|
|
list: ReturnType<typeof vi.fn>;
|
|
};
|
|
channels: {
|
|
stop: ReturnType<typeof vi.fn>;
|
|
};
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
process.env.GOOGLE_WEBHOOK_TOKEN = "test-webhook-token";
|
|
process.env.GOOGLE_WEBHOOK_URL = "https://example.com";
|
|
|
|
mockClient = {
|
|
events: {
|
|
watch: vi.fn(),
|
|
list: vi.fn(),
|
|
},
|
|
channels: {
|
|
stop: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const { CalendarAuth } = await import("../__mocks__/CalendarAuth");
|
|
vi.mocked(CalendarAuth).mockImplementation(function () {
|
|
return {
|
|
getClient: vi.fn().mockResolvedValue(mockClient),
|
|
};
|
|
});
|
|
|
|
adapter = new GoogleCalendarSubscriptionAdapter();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("validate", () => {
|
|
test("should validate webhook with correct token", async () => {
|
|
const mockRequest = {
|
|
headers: {
|
|
get: vi.fn().mockReturnValue("test-webhook-token"),
|
|
},
|
|
} as unknown as Request;
|
|
|
|
const result = await adapter.validate(mockRequest);
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockRequest.headers.get).toHaveBeenCalledWith("X-Goog-Channel-Token");
|
|
});
|
|
|
|
test("should reject webhook with incorrect token", async () => {
|
|
const mockRequest = {
|
|
headers: {
|
|
get: vi.fn().mockReturnValue("wrong-token"),
|
|
},
|
|
} as unknown as Request;
|
|
|
|
const result = await adapter.validate(mockRequest);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test("should reject webhook when token is not configured", async () => {
|
|
delete process.env.GOOGLE_WEBHOOK_TOKEN;
|
|
adapter = new GoogleCalendarSubscriptionAdapter();
|
|
|
|
const mockRequest = {
|
|
headers: {
|
|
get: vi.fn().mockReturnValue("test-webhook-token"),
|
|
},
|
|
} as unknown as Request;
|
|
|
|
const result = await adapter.validate(mockRequest);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test("should reject webhook with missing token header", async () => {
|
|
const mockRequest = {
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
} as unknown as Request;
|
|
|
|
const result = await adapter.validate(mockRequest);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("extractChannelId", () => {
|
|
test("should extract channel ID from webhook headers", async () => {
|
|
const mockRequest = {
|
|
headers: {
|
|
get: vi.fn().mockReturnValue("test-channel-id"),
|
|
},
|
|
} as unknown as Request;
|
|
|
|
const result = await adapter.extractChannelId(mockRequest);
|
|
|
|
expect(result).toBe("test-channel-id");
|
|
expect(mockRequest.headers.get).toHaveBeenCalledWith("X-Goog-Channel-ID");
|
|
});
|
|
|
|
test("should return null when channel ID is missing", async () => {
|
|
const mockRequest = {
|
|
headers: {
|
|
get: vi.fn().mockReturnValue(null),
|
|
},
|
|
} as unknown as Request;
|
|
|
|
const result = await adapter.extractChannelId(mockRequest);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("subscribe", () => {
|
|
test("should successfully subscribe to calendar", async () => {
|
|
const mockWatchResponse = {
|
|
data: {
|
|
id: "test-channel-id",
|
|
resourceId: "test-resource-id",
|
|
resourceUri: "test-resource-uri",
|
|
expiration: String(channelExpirationDate.getTime()),
|
|
},
|
|
};
|
|
|
|
mockClient.events.watch.mockResolvedValue(mockWatchResponse);
|
|
|
|
const result = await adapter.subscribe(mockSelectedCalendar, mockCredential);
|
|
|
|
expect(mockClient.events.watch).toHaveBeenCalledWith({
|
|
calendarId: "[email protected]",
|
|
requestBody: {
|
|
id: "test-uuid",
|
|
type: "web_hook",
|
|
address: "https://example.com/api/webhooks/calendar-subscription/google_calendar",
|
|
token: "test-webhook-token",
|
|
params: {
|
|
ttl: "2592000",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
provider: "google_calendar",
|
|
id: "test-channel-id",
|
|
resourceId: "test-resource-id",
|
|
resourceUri: "test-resource-uri",
|
|
expiration: channelExpirationDate,
|
|
});
|
|
});
|
|
|
|
test("should handle expiration as ISO string", async () => {
|
|
const mockExpirationDate = new Date(Date.now() + 10000000);
|
|
const mockWatchResponse = {
|
|
data: {
|
|
id: "test-channel-id",
|
|
resourceId: "test-resource-id",
|
|
resourceUri: "test-resource-uri",
|
|
expiration: mockExpirationDate.toISOString(),
|
|
},
|
|
};
|
|
|
|
mockClient.events.watch.mockResolvedValue(mockWatchResponse);
|
|
|
|
const result = await adapter.subscribe(mockSelectedCalendar, mockCredential);
|
|
|
|
expect(result.expiration).toEqual(mockExpirationDate);
|
|
});
|
|
|
|
test("should handle missing expiration", async () => {
|
|
const mockWatchResponse = {
|
|
data: {
|
|
id: "test-channel-id",
|
|
resourceId: "test-resource-id",
|
|
resourceUri: "test-resource-uri",
|
|
},
|
|
};
|
|
|
|
mockClient.events.watch.mockResolvedValue(mockWatchResponse);
|
|
|
|
const result = await adapter.subscribe(mockSelectedCalendar, mockCredential);
|
|
|
|
expect(result.expiration).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("unsubscribe", () => {
|
|
test("should successfully unsubscribe from calendar", async () => {
|
|
mockClient.channels.stop.mockResolvedValue({});
|
|
|
|
await adapter.unsubscribe(mockSelectedCalendar, mockCredential);
|
|
|
|
expect(mockClient.channels.stop).toHaveBeenCalledWith({
|
|
requestBody: {
|
|
id: "test-channel-id",
|
|
resourceId: "test-resource-id",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("should handle unsubscribe errors", async () => {
|
|
const error = new Error("Unsubscribe failed");
|
|
mockClient.channels.stop.mockRejectedValue(error);
|
|
|
|
await expect(adapter.unsubscribe(mockSelectedCalendar, mockCredential)).rejects.toThrow(
|
|
"Unsubscribe failed"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("fetchEvents", () => {
|
|
const commonEventData = {
|
|
iCalUID: "[email protected]",
|
|
summary: "Test Event",
|
|
description: "Test Description",
|
|
location: "Test Location",
|
|
status: "confirmed",
|
|
transparency: "opaque",
|
|
kind: "calendar#event",
|
|
etag: "test-etag",
|
|
created: today.toISOString(),
|
|
updated: today.toISOString(),
|
|
};
|
|
|
|
test("should fetch events with sync token", async () => {
|
|
const mockEventsResponse = {
|
|
data: {
|
|
nextSyncToken: "new-sync-token",
|
|
items: [
|
|
{
|
|
id: "event-1",
|
|
...commonEventData,
|
|
start: {
|
|
dateTime: oneWeekFromNow.toISOString(),
|
|
timeZone: "UTC",
|
|
},
|
|
end: {
|
|
dateTime: eventEndTime.toISOString(),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
mockClient.events.list.mockResolvedValue(mockEventsResponse);
|
|
|
|
const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential);
|
|
|
|
expect(mockClient.events.list).toHaveBeenCalledWith({
|
|
calendarId: "[email protected]",
|
|
pageToken: undefined,
|
|
singleEvents: true,
|
|
syncToken: "test-sync-token",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
provider: "google_calendar",
|
|
syncToken: "new-sync-token",
|
|
items: [
|
|
{
|
|
id: "event-1",
|
|
iCalUID: "[email protected]",
|
|
start: oneWeekFromNow.toDate(),
|
|
end: eventEndTime.toDate(),
|
|
busy: true,
|
|
summary: "Test Event",
|
|
description: "Test Description",
|
|
location: "Test Location",
|
|
kind: "calendar#event",
|
|
etag: "test-etag",
|
|
status: "confirmed",
|
|
isAllDay: false,
|
|
timeZone: "UTC",
|
|
recurringEventId: null,
|
|
originalStartDate: null,
|
|
createdAt: today.toDate(),
|
|
updatedAt: today.toDate(),
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("should fetch events without sync token (initial sync) with a 3-month range", async () => {
|
|
const calendarWithoutSyncToken = {
|
|
...mockSelectedCalendar,
|
|
syncToken: null,
|
|
};
|
|
|
|
const mockEventsResponse = {
|
|
data: {
|
|
nextSyncToken: "initial-sync-token",
|
|
items: [],
|
|
},
|
|
};
|
|
|
|
mockClient.events.list.mockResolvedValue(mockEventsResponse);
|
|
|
|
const result = await adapter.fetchEvents(calendarWithoutSyncToken, mockCredential);
|
|
|
|
const expectedTimeMin = today.startOf("day").toISOString();
|
|
const expectedTimeMax = addMonthsFromNow(3).endOf("day").toISOString();
|
|
|
|
expect(mockClient.events.list).toHaveBeenCalledWith({
|
|
calendarId: "[email protected]",
|
|
pageToken: undefined,
|
|
singleEvents: true,
|
|
timeMin: expectedTimeMin,
|
|
timeMax: expectedTimeMax,
|
|
});
|
|
|
|
expect(result.syncToken).toBe("initial-sync-token");
|
|
});
|
|
|
|
test("should handle all-day events", async () => {
|
|
const allDayStart = oneWeekFromNow.startOf("day");
|
|
const allDayEnd = oneWeekFromNow.endOf("day");
|
|
|
|
const mockEventsResponse = {
|
|
data: {
|
|
nextSyncToken: "new-sync-token",
|
|
items: [
|
|
{
|
|
id: "event-1",
|
|
...commonEventData,
|
|
start: {
|
|
date: allDayStart.toISOString().split("T")[0],
|
|
},
|
|
end: {
|
|
date: allDayEnd.toISOString().split("T")[0],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
mockClient.events.list.mockResolvedValue(mockEventsResponse);
|
|
|
|
const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential);
|
|
|
|
expect(result.items[0].isAllDay).toBe(true);
|
|
expect(result.items[0].start).toEqual(new Date(allDayStart.toISOString().split("T")[0]));
|
|
expect(result.items[0].end).toEqual(new Date(allDayEnd.toISOString().split("T")[0]));
|
|
});
|
|
|
|
test("should handle free events (transparent)", async () => {
|
|
const mockEventsResponse = {
|
|
data: {
|
|
nextSyncToken: "new-sync-token",
|
|
items: [
|
|
{
|
|
id: "event-1",
|
|
...commonEventData,
|
|
summary: "Free Event",
|
|
start: {
|
|
dateTime: oneWeekFromNow.toISOString(),
|
|
},
|
|
end: {
|
|
dateTime: eventEndTime.toISOString(),
|
|
},
|
|
transparency: "transparent",
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
mockClient.events.list.mockResolvedValue(mockEventsResponse);
|
|
|
|
const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential);
|
|
|
|
expect(result.items[0].busy).toBe(false);
|
|
});
|
|
|
|
test("should handle pagination", async () => {
|
|
const date2 = new Date(oneWeekFromNow.toDate());
|
|
date2.setDate(date2.getDate() + 1);
|
|
|
|
const mockEventsResponse1 = {
|
|
data: {
|
|
nextPageToken: "page-2",
|
|
items: [
|
|
{
|
|
id: "event-1",
|
|
summary: "Event 1",
|
|
start: { dateTime: oneWeekFromNow.toISOString() },
|
|
end: { dateTime: eventEndTime.toISOString() },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockEventsResponse2 = {
|
|
data: {
|
|
nextSyncToken: "final-sync-token",
|
|
items: [
|
|
{
|
|
id: "event-2",
|
|
summary: "Event 2",
|
|
start: { dateTime: date2.toISOString() },
|
|
end: { dateTime: new Date(date2.getTime() + 3600000).toISOString() },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
mockClient.events.list
|
|
.mockResolvedValueOnce(mockEventsResponse1)
|
|
.mockResolvedValueOnce(mockEventsResponse2);
|
|
|
|
const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential);
|
|
|
|
expect(mockClient.events.list).toHaveBeenCalledTimes(2);
|
|
expect(result.items).toHaveLength(2);
|
|
expect(result.syncToken).toBe("final-sync-token");
|
|
});
|
|
|
|
test("should filter out events without ID", async () => {
|
|
const mockEventsResponse = {
|
|
data: {
|
|
nextSyncToken: "new-sync-token",
|
|
items: [
|
|
{
|
|
id: "event-1",
|
|
summary: "Valid Event",
|
|
start: { dateTime: oneWeekFromNow.toISOString() },
|
|
end: { dateTime: eventEndTime.toISOString() },
|
|
},
|
|
{
|
|
summary: "Invalid Event",
|
|
start: { dateTime: oneWeekFromNow.toISOString() },
|
|
end: { dateTime: eventEndTime.toISOString() },
|
|
},
|
|
{
|
|
id: "",
|
|
summary: "Empty ID Event",
|
|
start: { dateTime: oneWeekFromNow.toISOString() },
|
|
end: { dateTime: eventEndTime.toISOString() },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
mockClient.events.list.mockResolvedValue(mockEventsResponse);
|
|
|
|
const result = await adapter.fetchEvents(mockSelectedCalendar, mockCredential);
|
|
|
|
expect(result.items).toHaveLength(1);
|
|
expect(result.items[0].id).toBe("event-1");
|
|
});
|
|
});
|
|
});
|