Files
Benny JooGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ab21c7f805 refactor: Cal.diy (#28903)
* 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>
2026-04-15 09:52:36 -03:00

1119 lines
36 KiB
TypeScript

import { setupAndTeardown } from "@calcom/testing/lib/bookingScenario/setupAndTeardown";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { CRM } from "@calcom/types/CrmService";
import type { TFunction } from "i18next";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { z } from "zod";
import { CrmFieldType, DateFieldType, WhenToWrite } from "../../_lib/crm-enums";
import type { appDataSchema } from "../zod";
type AppOptions = z.infer<typeof appDataSchema>;
// Create hoisted mocks that will be available before module imports
const {
mockHubspotClient,
mockGetAppKeysFromSlug,
mockTrackingRepository,
mockBookingRepository,
mockCheckIfFreeEmailDomain,
}: {
mockHubspotClient: {
crm: {
properties: { coreApi: { getAll: ReturnType<typeof vi.fn> } };
contacts: {
searchApi: { doSearch: ReturnType<typeof vi.fn> };
basicApi: {
create: ReturnType<typeof vi.fn>;
getById: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
};
objects: {
meetings: {
basicApi: {
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
archive: ReturnType<typeof vi.fn>;
};
};
};
associations: { batchApi: { create: ReturnType<typeof vi.fn> } };
owners: { ownersApi: { getPage: ReturnType<typeof vi.fn>; getById: ReturnType<typeof vi.fn> } };
};
oauth: { tokensApi: { createToken: ReturnType<typeof vi.fn> } };
setAccessToken: ReturnType<typeof vi.fn>;
};
mockGetAppKeysFromSlug: ReturnType<typeof vi.fn>;
mockTrackingRepository: { findByBookingUid: ReturnType<typeof vi.fn> };
mockBookingRepository: { findBookingByUid: ReturnType<typeof vi.fn> };
mockCheckIfFreeEmailDomain: ReturnType<typeof vi.fn>;
} = vi.hoisted(() => {
const mockHubspotClient = {
crm: {
properties: {
coreApi: {
getAll: vi.fn(),
},
},
contacts: {
searchApi: {
doSearch: vi.fn(),
},
basicApi: {
create: vi.fn(),
getById: vi.fn(),
update: vi.fn(),
},
},
objects: {
meetings: {
basicApi: {
create: vi.fn(),
update: vi.fn(),
archive: vi.fn(),
},
},
},
associations: {
batchApi: {
create: vi.fn(),
},
},
owners: {
ownersApi: {
getPage: vi.fn(),
getById: vi.fn(),
},
},
},
oauth: {
tokensApi: {
createToken: vi.fn(),
},
},
setAccessToken: vi.fn(),
};
const mockGetAppKeysFromSlug = vi.fn().mockResolvedValue({
client_id: "mock_client_id",
client_secret: "mock_client_secret",
});
const mockTrackingRepository = {
findByBookingUid: vi.fn(),
};
const mockBookingRepository = {
findBookingByUid: vi.fn(),
};
const mockCheckIfFreeEmailDomain = vi.fn();
return {
mockHubspotClient,
mockGetAppKeysFromSlug,
mockTrackingRepository,
mockBookingRepository,
mockCheckIfFreeEmailDomain,
};
});
vi.mock("@hubspot/api-client", () => {
return {
Client: class {
crm = mockHubspotClient.crm;
oauth = mockHubspotClient.oauth;
setAccessToken = mockHubspotClient.setAccessToken;
},
};
});
vi.mock("../../_utils/getAppKeysFromSlug", () => ({
default: mockGetAppKeysFromSlug,
}));
vi.mock("@calcom/prisma", () => ({
default: {
credential: {
update: vi.fn(),
},
},
}));
vi.mock("@calcom/features/bookings/repositories/PrismaTrackingRepository", () => ({
PrismaTrackingRepository: class {
findByBookingUid = mockTrackingRepository.findByBookingUid;
},
}));
vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({
BookingRepository: class {
findBookingByUid = mockBookingRepository.findBookingByUid;
},
}));
vi.mock("@calcom/features/watchlist/lib/freeEmailDomainCheck/checkIfFreeEmailDomain", () => ({
checkIfFreeEmailDomain: mockCheckIfFreeEmailDomain,
}));
import BuildCrmService from "./CrmService";
describe("HubspotCalendarService", () => {
let service: CRM & { getAppOptions: () => AppOptions };
setupAndTeardown();
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-01-15T10:00:00.000Z"));
// Re-apply mock implementation for getAppKeysFromSlug (needed after clearAllMocks)
mockGetAppKeysFromSlug.mockResolvedValue({
client_id: "mock_client_id",
client_secret: "mock_client_secret",
});
const mockCredential: CredentialPayload = {
id: 1,
type: "hubspot_other_calendar",
key: {
accessToken: "mock_token",
refreshToken: "mock_refresh",
expiryDate: Date.now() + 3600000,
tokenType: "Bearer",
},
userId: 1,
appId: "hubspot",
teamId: null,
invalid: false,
user: {
email: "[email protected]",
},
delegationCredentialId: null,
encryptedKey: null,
};
service = BuildCrmService(mockCredential, {}) as CRM & { getAppOptions: () => AppOptions };
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
function mockAppOptions(appOptions: AppOptions): void {
const appOptionsSpy = vi.spyOn(service, "getAppOptions");
appOptionsSpy.mockReturnValue(appOptions);
}
// Helper to create a mock CalendarEvent
function createMockEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
type: "test-event",
title: "Test Meeting",
startTime: "2024-01-20T14:00:00.000Z",
endTime: "2024-01-20T15:00:00.000Z",
uid: "booking-123",
responses: {
name: { label: "Name", value: "John Doe" },
email: { label: "Email", value: "[email protected]" },
},
// customInputs is required by getLabelValueMapFromResponses when userFieldsResponses is not present
customInputs: {},
organizer: {
email: "[email protected]",
name: "Organizer",
timeZone: "America/New_York",
language: {
translate: ((key: string) => key) as TFunction,
locale: "en",
},
},
attendees: [
{
email: "[email protected]",
name: "Attendee",
timeZone: "America/New_York",
language: {
translate: ((key: string) => key) as TFunction,
locale: "en",
},
},
],
...overrides,
};
}
// Helper to setup common mocks for createEvent tests
function setupCreateEventMocks(): void {
// Mock owner lookup - return empty results (no owner found)
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({ results: [] });
// Mock meeting creation
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
});
// Mock association creation
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
results: [],
});
}
describe("getContacts", () => {
it("should return contacts when found", async () => {
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [
{ id: "contact-1", properties: { email: "[email protected]" } },
{ id: "contact-2", properties: { email: "[email protected]" } },
],
});
const result = await service.getContacts({ emails: ["[email protected]", "[email protected]"] });
expect(result).toEqual([
{ id: "contact-1", email: "[email protected]" },
{ id: "contact-2", email: "[email protected]" },
]);
});
it("should handle single email string input", async () => {
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [{ id: "contact-1", properties: { email: "[email protected]" } }],
});
const result = await service.getContacts({ emails: "[email protected]" });
expect(result).toEqual([{ id: "contact-1", email: "[email protected]" }]);
});
it("should return empty array when no contacts found", async () => {
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [],
});
const result = await service.getContacts({ emails: "[email protected]" });
expect(result).toEqual([]);
});
describe("forRoundRobinSkip param is passed", () => {
it("should return contact with owner info when forRoundRobinSkip is true", async () => {
mockAppOptions({ roundRobinLeadSkip: true });
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [
{
id: "contact-1",
properties: {
email: "[email protected]",
hubspot_owner_id: "12345",
},
},
],
});
mockHubspotClient.crm.owners.ownersApi.getById.mockResolvedValueOnce({
id: "12345",
email: "[email protected]",
});
const result = await service.getContacts({
emails: "[email protected]",
forRoundRobinSkip: true,
});
expect(result).toEqual([
{
id: "contact-1",
email: "[email protected]",
ownerId: "12345",
ownerEmail: "[email protected]",
recordType: "CONTACT",
},
]);
expect(mockHubspotClient.crm.contacts.searchApi.doSearch).toHaveBeenCalledWith(
expect.objectContaining({
properties: ["hs_object_id", "email", "hubspot_owner_id"],
})
);
expect(mockHubspotClient.crm.owners.ownersApi.getById).toHaveBeenCalledWith(12345);
});
it("should return contact without ownerEmail when contact has no owner", async () => {
mockAppOptions({ roundRobinLeadSkip: true });
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [
{
id: "contact-1",
properties: {
email: "[email protected]",
hubspot_owner_id: null,
},
},
],
});
const result = await service.getContacts({
emails: "[email protected]",
forRoundRobinSkip: true,
});
expect(result).toEqual([
{
id: "contact-1",
email: "[email protected]",
ownerId: null,
ownerEmail: undefined,
recordType: "CONTACT",
},
]);
expect(mockHubspotClient.crm.owners.ownersApi.getById).not.toHaveBeenCalled();
});
it("should skip owner lookup for free email domains when ifFreeEmailDomainSkipOwnerCheck is enabled", async () => {
mockAppOptions({ roundRobinLeadSkip: true, ifFreeEmailDomainSkipOwnerCheck: true });
mockCheckIfFreeEmailDomain.mockResolvedValueOnce(true);
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [
{
id: "contact-1",
properties: {
email: "[email protected]",
},
},
],
});
const result = await service.getContacts({
emails: "[email protected]",
forRoundRobinSkip: true,
});
expect(result).toEqual([
{
id: "contact-1",
email: "[email protected]",
},
]);
expect(mockCheckIfFreeEmailDomain).toHaveBeenCalledWith({ email: "[email protected]" });
expect(mockHubspotClient.crm.contacts.searchApi.doSearch).toHaveBeenCalledWith(
expect.objectContaining({
properties: ["hs_object_id", "email"],
})
);
expect(mockHubspotClient.crm.owners.ownersApi.getById).not.toHaveBeenCalled();
});
it("should include owner lookup for business email domains even when ifFreeEmailDomainSkipOwnerCheck is enabled", async () => {
mockAppOptions({ roundRobinLeadSkip: true, ifFreeEmailDomainSkipOwnerCheck: true });
mockCheckIfFreeEmailDomain.mockResolvedValueOnce(false);
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [
{
id: "contact-1",
properties: {
email: "[email protected]",
hubspot_owner_id: "45678",
},
},
],
});
mockHubspotClient.crm.owners.ownersApi.getById.mockResolvedValueOnce({
id: "45678",
email: "[email protected]",
});
const result = await service.getContacts({
emails: "[email protected]",
forRoundRobinSkip: true,
});
expect(result).toEqual([
{
id: "contact-1",
email: "[email protected]",
ownerId: "45678",
ownerEmail: "[email protected]",
recordType: "CONTACT",
},
]);
expect(mockCheckIfFreeEmailDomain).toHaveBeenCalledWith({ email: "[email protected]" });
expect(mockHubspotClient.crm.contacts.searchApi.doSearch).toHaveBeenCalledWith(
expect.objectContaining({
properties: ["hs_object_id", "email", "hubspot_owner_id"],
})
);
});
it("should handle owner lookup API error gracefully", async () => {
mockAppOptions({ roundRobinLeadSkip: true });
mockHubspotClient.crm.contacts.searchApi.doSearch.mockResolvedValueOnce({
results: [
{
id: "contact-1",
properties: {
email: "[email protected]",
hubspot_owner_id: "99999",
},
},
],
});
mockHubspotClient.crm.owners.ownersApi.getById.mockRejectedValueOnce(new Error("Owner not found"));
const result = await service.getContacts({
emails: "[email protected]",
forRoundRobinSkip: true,
});
expect(result).toEqual([
{
id: "contact-1",
email: "[email protected]",
ownerId: "99999",
ownerEmail: undefined,
recordType: "CONTACT",
},
]);
});
});
});
describe("createContacts", () => {
it("should create contacts successfully", async () => {
mockHubspotClient.crm.contacts.basicApi.create.mockResolvedValueOnce({
id: "new-contact-1",
properties: { email: "[email protected]", firstname: "New", lastname: "Contact" },
});
const result = await service.createContacts([{ name: "New Contact", email: "[email protected]" }]);
expect(result).toEqual([{ id: "new-contact-1", email: "[email protected]" }]);
expect(mockHubspotClient.crm.contacts.basicApi.create).toHaveBeenCalledWith({
properties: {
firstname: "New",
lastname: "Contact",
email: "[email protected]",
},
});
});
it("should handle existing contact error gracefully", async () => {
mockHubspotClient.crm.contacts.basicApi.create.mockRejectedValueOnce({
body: { message: "Contact already exists. Existing ID: existing-contact-123" },
});
const result = await service.createContacts([
{ name: "Existing Contact", email: "[email protected]" },
]);
expect(result).toEqual([{ id: "existing-contact-123", email: "[email protected]" }]);
});
});
describe("createEvent", () => {
it("should create a meeting without custom fields when feature is disabled", async () => {
mockAppOptions({});
setupCreateEventMocks();
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
// Verify meeting was created with standard properties only
expect(mockHubspotClient.crm.objects.meetings.basicApi.create).toHaveBeenCalledWith({
properties: expect.objectContaining({
hs_meeting_title: "Test Meeting",
hs_meeting_outcome: "SCHEDULED",
}),
});
// Verify no custom fields were included
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties).not.toHaveProperty("custom_name");
});
it("should include custom text field with static value", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
custom_source: {
fieldType: CrmFieldType.TEXT,
value: "Cal.diy Booking",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
// Mock field validation - field exists on HubSpot
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "custom_source", type: "string" }],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.custom_source).toBe("Cal.diy Booking");
});
it("should include custom text field with booking response placeholder", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
attendee_name: {
fieldType: CrmFieldType.TEXT,
value: "{name}",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "attendee_name", type: "string" }],
});
const event = createMockEvent({
responses: {
name: { label: "Name", value: "John Doe" },
},
});
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.attendee_name).toBe("John Doe");
});
it("should include custom text field with UTM tracking value", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
utm_source_field: {
fieldType: CrmFieldType.TEXT,
value: "{utm:source}",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "utm_source_field", type: "string" }],
});
mockTrackingRepository.findByBookingUid.mockResolvedValueOnce({
utm_source: "google_ads",
utm_medium: "cpc",
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.utm_source_field).toBe("google_ads");
});
it("should include checkbox field with boolean value", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
is_confirmed: {
fieldType: CrmFieldType.CHECKBOX,
value: true,
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "is_confirmed", type: "bool" }],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.is_confirmed).toBe(true);
});
it("should include date field with booking start date", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
meeting_date: {
fieldType: CrmFieldType.DATE,
value: DateFieldType.BOOKING_START_DATE,
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "meeting_date", type: "date" }],
});
const event = createMockEvent({ startTime: "2024-01-20T14:00:00.000Z" });
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.meeting_date).toBe("2024-01-20T14:00:00.000Z");
});
it("should include date field with booking created date", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
created_date: {
fieldType: CrmFieldType.DATE,
value: DateFieldType.BOOKING_CREATED_DATE,
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "created_date", type: "date" }],
});
mockBookingRepository.findBookingByUid.mockResolvedValueOnce({
id: 1,
uid: "booking-123",
createdAt: new Date("2024-01-10T09:00:00.000Z"),
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.created_date).toBe("2024-01-10T09:00:00.000Z");
});
it("should include phone field with static value", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
contact_phone: {
fieldType: CrmFieldType.PHONE,
value: "+1234567890",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "contact_phone", type: "phone" }],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.contact_phone).toBe("+1234567890");
});
it("should skip fields that do not exist on HubSpot meeting object", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
existing_field: {
fieldType: CrmFieldType.TEXT,
value: "exists",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
nonexistent_field: {
fieldType: CrmFieldType.TEXT,
value: "does not exist",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
// Only return existing_field from HubSpot properties
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "existing_field", type: "string" }],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.existing_field).toBe("exists");
expect(createCall.properties).not.toHaveProperty("nonexistent_field");
});
it("should handle multiple custom fields of different types", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
text_field: {
fieldType: CrmFieldType.TEXT,
value: "Static Text",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
name_field: {
fieldType: CrmFieldType.TEXT,
value: "{name}",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
checkbox_field: {
fieldType: CrmFieldType.CHECKBOX,
value: true,
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
date_field: {
fieldType: CrmFieldType.DATE,
value: DateFieldType.BOOKING_START_DATE,
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [
{ name: "text_field", type: "string" },
{ name: "name_field", type: "string" },
{ name: "checkbox_field", type: "bool" },
{ name: "date_field", type: "date" },
],
});
const event = createMockEvent({
responses: { name: { label: "Name", value: "Jane Smith" } },
startTime: "2024-02-15T10:00:00.000Z",
});
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.text_field).toBe("Static Text");
expect(createCall.properties.name_field).toBe("Jane Smith");
expect(createCall.properties.checkbox_field).toBe(true);
expect(createCall.properties.date_field).toBe("2024-02-15T10:00:00.000Z");
});
it("should skip fields with unknown placeholders that cannot be resolved", async () => {
mockAppOptions({
onBookingWriteToEventObject: true,
onBookingWriteToEventObjectFields: {
unknown_field: {
fieldType: CrmFieldType.TEXT,
value: "{unknown_placeholder}",
whenToWrite: WhenToWrite.EVERY_BOOKING,
},
},
});
setupCreateEventMocks();
mockHubspotClient.crm.properties.coreApi.getAll.mockResolvedValueOnce({
results: [{ name: "unknown_field", type: "string" }],
});
const event = createMockEvent({
responses: { name: { label: "Name", value: "John" } },
});
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
// Field with null value should be filtered out and not sent to HubSpot
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties).not.toHaveProperty("unknown_field");
});
it("should associate meeting with contacts", async () => {
mockAppOptions({});
setupCreateEventMocks();
const event = createMockEvent();
const contacts = [
{ id: "contact-1", email: "[email protected]" },
{ id: "contact-2", email: "[email protected]" },
];
await service.createEvent(event, contacts);
expect(mockHubspotClient.crm.associations.batchApi.create).toHaveBeenCalledWith(
"meetings",
"contacts",
{
inputs: [
{ _from: { id: "meeting-123" }, to: { id: "contact-1" }, type: "meeting_event_to_contact" },
{ _from: { id: "meeting-123" }, to: { id: "contact-2" }, type: "meeting_event_to_contact" },
],
}
);
});
it("should set contact owner when setOrganizerAsOwner is enabled and organizer email matches", async () => {
mockAppOptions({ setOrganizerAsOwner: true });
// Mock owner lookup - return matching owner
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({
results: [{ id: "owner-123", email: "[email protected]" }],
});
// Mock contact owner lookup - return no existing owner
mockHubspotClient.crm.contacts.basicApi.getById.mockResolvedValue({
id: "contact-1",
properties: { hubspot_owner_id: null },
});
// Mock contact update
mockHubspotClient.crm.contacts.basicApi.update.mockResolvedValue({
id: "contact-1",
properties: { hubspot_owner_id: "owner-123" },
});
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
});
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
results: [],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
// Verify contact owner was set
expect(mockHubspotClient.crm.contacts.basicApi.update).toHaveBeenCalledWith("contact-1", {
properties: { hubspot_owner_id: "owner-123" },
});
// Verify meeting was created with hubspot_owner_id (meeting owner is always set from organizer)
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.hubspot_owner_id).toBe("owner-123");
});
it("should overwrite existing contact owner when overwriteContactOwner is true", async () => {
mockAppOptions({ setOrganizerAsOwner: true, overwriteContactOwner: true });
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({
results: [{ id: "owner-123", email: "[email protected]" }],
});
mockHubspotClient.crm.contacts.basicApi.update.mockResolvedValue({
id: "contact-1",
properties: { hubspot_owner_id: "owner-123" },
});
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
});
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
results: [],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
expect(mockHubspotClient.crm.contacts.basicApi.update).toHaveBeenCalledWith("contact-1", {
properties: { hubspot_owner_id: "owner-123" },
});
expect(mockHubspotClient.crm.contacts.basicApi.getById).not.toHaveBeenCalled();
});
it("should not overwrite existing contact owner when overwriteContactOwner is false", async () => {
mockAppOptions({ setOrganizerAsOwner: true, overwriteContactOwner: false });
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({
results: [{ id: "owner-123", email: "[email protected]" }],
});
mockHubspotClient.crm.contacts.basicApi.getById.mockResolvedValue({
id: "contact-1",
properties: { hubspot_owner_id: "existing-owner-456" },
});
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
});
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
results: [],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
expect(mockHubspotClient.crm.contacts.basicApi.getById).toHaveBeenCalledWith("contact-1", [
"hubspot_owner_id",
]);
expect(mockHubspotClient.crm.contacts.basicApi.update).not.toHaveBeenCalled();
});
it("should not set contact owner when organizer has no matching HubSpot owner", async () => {
mockAppOptions({ setOrganizerAsOwner: true });
mockHubspotClient.crm.owners.ownersApi.getPage.mockResolvedValue({ results: [] });
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
});
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
results: [],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "[email protected]" }];
await service.createEvent(event, contacts);
expect(mockHubspotClient.crm.contacts.basicApi.getById).not.toHaveBeenCalled();
expect(mockHubspotClient.crm.contacts.basicApi.update).not.toHaveBeenCalled();
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.hubspot_owner_id).toBeUndefined();
});
it("should create meeting successfully when owner lookup fails due to missing scope", async () => {
mockAppOptions({});
// Mock owner lookup to fail with 403 missing scope error (simulating owners.read scope not granted)
mockHubspotClient.crm.owners.ownersApi.getPage.mockRejectedValue({
code: 403,
body: {
status: "error",
message: "This app hasn't been granted all required scopes to make this call.",
category: "MISSING_SCOPES",
},
});
mockHubspotClient.crm.objects.meetings.basicApi.create.mockResolvedValue({
id: "meeting-123",
properties: {},
});
mockHubspotClient.crm.associations.batchApi.create.mockResolvedValue({
results: [],
});
const event = createMockEvent();
const contacts = [{ id: "contact-1", email: "[email protected]" }];
// Should not throw and meeting should be created successfully
const result = await service.createEvent(event, contacts);
expect(result).toEqual({
uid: "meeting-123",
id: "meeting-123",
type: "hubspot_other_calendar",
password: "",
url: "",
additionalInfo: { contacts, associatedMeeting: { results: [] } },
});
// Verify meeting was created without owner
const createCall = mockHubspotClient.crm.objects.meetings.basicApi.create.mock.calls[0][0];
expect(createCall.properties.hubspot_owner_id).toBeUndefined();
// Verify meeting creation and association still happened
expect(mockHubspotClient.crm.objects.meetings.basicApi.create).toHaveBeenCalled();
expect(mockHubspotClient.crm.associations.batchApi.create).toHaveBeenCalled();
});
});
describe("updateEvent", () => {
it("should update meeting with standard properties", async () => {
mockHubspotClient.crm.objects.meetings.basicApi.update.mockResolvedValue({
id: "meeting-123",
properties: {},
});
const event = createMockEvent({
title: "Updated Meeting",
startTime: "2024-01-25T14:00:00.000Z",
endTime: "2024-01-25T15:00:00.000Z",
});
await service.updateEvent("meeting-123", event);
expect(mockHubspotClient.crm.objects.meetings.basicApi.update).toHaveBeenCalledWith("meeting-123", {
properties: expect.objectContaining({
hs_meeting_title: "Updated Meeting",
hs_meeting_outcome: "RESCHEDULED",
}),
});
});
});
describe("deleteEvent", () => {
it("should cancel meeting when organizer has not changed", async () => {
mockHubspotClient.crm.objects.meetings.basicApi.update.mockResolvedValue({
id: "meeting-123",
properties: {},
});
const event = createMockEvent({ hasOrganizerChanged: false });
await service.deleteEvent("meeting-123", event);
expect(mockHubspotClient.crm.objects.meetings.basicApi.update).toHaveBeenCalledWith("meeting-123", {
properties: {
hs_meeting_outcome: "CANCELED",
},
});
});
it("should archive meeting when organizer has changed", async () => {
mockHubspotClient.crm.objects.meetings.basicApi.archive.mockResolvedValue(undefined);
const event = createMockEvent({ hasOrganizerChanged: true });
await service.deleteEvent("meeting-123", event);
expect(mockHubspotClient.crm.objects.meetings.basicApi.archive).toHaveBeenCalledWith("meeting-123");
});
});
});