mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +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>
1119 lines
36 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|