feat(HighLevel Node): Add support for calendar items (#10820)

This commit is contained in:
Stanimira Rikova 2025-01-08 11:59:01 +02:00 committed by GitHub
parent 9dd068632b
commit 6e189fda77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2214 additions and 76 deletions

View file

@ -61,5 +61,15 @@ export class HighLevelOAuth2Api implements ICredentialType {
type: 'hidden',
default: 'body',
},
{
displayName:
'Make sure your credentials include the required OAuth scopes for all actions this node performs.',
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
hideOnCloud: true,
},
},
];
}

View file

@ -1,5 +1,5 @@
import type { ToISOTimeOptions } from 'luxon';
import { DateTime } from 'luxon';
import type { ToISOTimeOptions } from 'luxon';
import type {
DeclarativeRestApiSettings,
IDataObject,
@ -16,7 +16,7 @@ import type {
IPollFunctions,
IWebhookFunctions,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { ApplicationError, NodeApiError } from 'n8n-workflow';
const VALID_EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
@ -31,7 +31,7 @@ export function isPhoneValid(phone: string): boolean {
return VALID_PHONE_REGEX.test(String(phone));
}
function dateToIsoSupressMillis(dateTime: string) {
export function dateToIsoSupressMillis(dateTime: string) {
const options: ToISOTimeOptions = { suppressMilliseconds: true };
return DateTime.fromISO(dateTime).toISO(options);
}
@ -63,7 +63,7 @@ export async function dueDatePreSendAction(
);
}
const dueDate = dateToIsoSupressMillis(dueDateParam);
requestOptions.body = (requestOptions.body || {}) as object;
requestOptions.body = (requestOptions.body ?? {}) as object;
Object.assign(requestOptions.body, { dueDate });
return requestOptions;
}
@ -72,7 +72,7 @@ export async function contactIdentifierPreSendAction(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
requestOptions.body = (requestOptions.body || {}) as object;
requestOptions.body = (requestOptions.body ?? {}) as object;
let identifier = this.getNodeParameter('contactIdentifier', null) as string;
if (!identifier) {
const fields = this.getNodeParameter('updateFields') as { contactIdentifier: string };
@ -92,7 +92,7 @@ export async function validEmailAndPhonePreSendAction(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const body = (requestOptions.body || {}) as { email?: string; phone?: string };
const body = (requestOptions.body ?? {}) as { email?: string; phone?: string };
if (body.email && !isEmailValid(body.email)) {
const message = `email "${body.email}" has invalid format`;
@ -111,7 +111,7 @@ export async function dateTimeToEpochPreSendAction(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const qs = (requestOptions.qs || {}) as {
const qs = (requestOptions.qs ?? {}) as {
startDate?: string | number;
endDate?: string | number;
};
@ -133,22 +133,22 @@ export async function addLocationIdPreSendAction(
if (resource === 'contact') {
if (operation === 'getAll') {
requestOptions.qs = requestOptions.qs || {};
requestOptions.qs = requestOptions.qs ?? {};
Object.assign(requestOptions.qs, { locationId });
}
if (operation === 'create') {
requestOptions.body = requestOptions.body || {};
requestOptions.body = requestOptions.body ?? {};
Object.assign(requestOptions.body, { locationId });
}
}
if (resource === 'opportunity') {
if (operation === 'create') {
requestOptions.body = requestOptions.body || {};
requestOptions.body = requestOptions.body ?? {};
Object.assign(requestOptions.body, { locationId });
}
if (operation === 'getAll') {
requestOptions.qs = requestOptions.qs || {};
requestOptions.qs = requestOptions.qs ?? {};
Object.assign(requestOptions.qs, { location_id: locationId });
}
}
@ -179,7 +179,7 @@ export async function highLevelApiRequest(
method,
body,
qs,
url: url || `https://services.leadconnectorhq.com${resource}`,
url: url ?? `https://services.leadconnectorhq.com${resource}`,
json: true,
};
if (!Object.keys(body).length) {
@ -192,11 +192,42 @@ export async function highLevelApiRequest(
return await this.helpers.httpRequestWithAuthentication.call(this, 'highLevelOAuth2Api', options);
}
export const addNotePostReceiveAction = async function (
this: IExecuteSingleFunctions,
items: INodeExecutionData[],
response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
const note = this.getNodeParameter('additionalFields.notes', 0) as string;
if (!note) {
return items;
}
const contact: IDataObject = (response.body as IDataObject).contact as IDataObject;
// Ensure there is a valid response and extract contactId and userId
if (!response || !response.body || !contact) {
throw new ApplicationError('No response data available to extract contact ID and user ID.');
}
const contactId = contact.id;
const userId = contact.locationId;
const requestBody = {
userId,
body: note,
};
await highLevelApiRequest.call(this, 'POST', `/contacts/${contactId}/notes`, requestBody, {});
return items;
};
export async function taskUpdatePreSendAction(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const body = (requestOptions.body || {}) as { title?: string; dueDate?: string };
const body = (requestOptions.body ?? {}) as { title?: string; dueDate?: string };
if (!body.title || !body.dueDate) {
const contactId = this.getNodeParameter('contactId');
const taskId = this.getNodeParameter('taskId');
@ -214,7 +245,7 @@ export async function splitTagsPreSendAction(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const body = (requestOptions.body || {}) as IDataObject;
const body = (requestOptions.body ?? {}) as IDataObject;
if (body.tags) {
if (Array.isArray(body.tags)) return requestOptions;
body.tags = (body.tags as string).split(',').map((tag) => tag.trim());
@ -236,7 +267,7 @@ export async function highLevelApiPagination(
};
const rootProperty = resourceMapping[resource];
requestData.options.qs = requestData.options.qs || {};
requestData.options.qs = requestData.options.qs ?? {};
if (returnAll) requestData.options.qs.limit = 100;
let responseTotal = 0;
@ -344,11 +375,42 @@ export async function getUsers(this: ILoadOptionsFunctions): Promise<INodeProper
return options;
}
export async function getTimezones(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const responseData = await highLevelApiRequest.call(this, 'GET', '/timezones');
const timezones = responseData.timezones as string[];
return timezones.map((zone) => ({
name: zone,
value: zone,
})) as INodePropertyOptions[];
export async function addCustomFieldsPreSendAction(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const requestBody = requestOptions.body as IDataObject;
if (requestBody && requestBody.customFields) {
const rawCustomFields = requestBody.customFields as IDataObject;
// Define the structure of fieldId
interface FieldIdType {
value: unknown;
cachedResultName?: string;
}
// Ensure rawCustomFields.values is an array of objects with fieldId and fieldValue
if (rawCustomFields && Array.isArray(rawCustomFields.values)) {
const formattedCustomFields = rawCustomFields.values.map((field: unknown) => {
// Assert that field is of the expected shape
const typedField = field as { fieldId: FieldIdType; fieldValue: unknown };
const fieldId = typedField.fieldId;
if (typeof fieldId === 'object' && fieldId !== null && 'value' in fieldId) {
return {
id: fieldId.value,
key: fieldId.cachedResultName ?? 'default_key',
field_value: typedField.fieldValue,
};
} else {
throw new ApplicationError('Error processing custom fields.');
}
});
requestBody.customFields = formattedCustomFields;
}
}
return requestOptions;
}

View file

@ -1,4 +1,8 @@
import type {
IDataObject,
ILoadOptionsFunctions,
INodeListSearchItems,
INodeListSearchResult,
INodeProperties,
INodeType,
INodeTypeBaseDescription,
@ -6,6 +10,7 @@ import type {
} from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { calendarFields, calendarOperations } from './description/CalendarDescription';
import { contactFields, contactNotes, contactOperations } from './description/ContactDescription';
import { opportunityFields, opportunityOperations } from './description/OpportunityDescription';
import { taskFields, taskOperations } from './description/TaskDescription';
@ -13,7 +18,6 @@ import {
getContacts,
getPipelines,
getPipelineStages,
getTimezones,
getUsers,
highLevelApiPagination,
} from './GenericFunctions';
@ -37,6 +41,10 @@ const resources: INodeProperties[] = [
name: 'Task',
value: 'task',
},
{
name: 'Calendar',
value: 'calendar',
},
],
default: 'contact',
required: true,
@ -82,6 +90,8 @@ const versionDescription: INodeTypeDescription = {
...opportunityFields,
...taskOperations,
...taskFields,
...calendarOperations,
...calendarFields,
],
};
@ -101,7 +111,82 @@ export class HighLevelV2 implements INodeType {
getContacts,
getPipelineStages,
getUsers,
getTimezones,
},
listSearch: {
async searchCustomFields(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const { locationId } =
((await this.getCredentials('highLevelOAuth2Api'))?.oauthTokenData as IDataObject) ?? {};
const responseData: IDataObject = (await this.helpers.httpRequestWithAuthentication.call(
this,
'highLevelOAuth2Api',
{
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
url: `https://services.leadconnectorhq.com/locations/${locationId}/customFields?model=contact`,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Version: '2021-07-28',
},
},
)) as IDataObject;
const customFields = responseData.customFields as Array<{ name: string; id: string }>;
const results: INodeListSearchItems[] = customFields
.map((a) => ({
name: a.name,
value: a.id,
}))
.filter((a) => !filter || a.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
return 0;
});
return { results };
},
async searchTimezones(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const { locationId } =
((await this.getCredentials('highLevelOAuth2Api'))?.oauthTokenData as IDataObject) ?? {};
const responseData: IDataObject = (await this.helpers.httpRequestWithAuthentication.call(
this,
'highLevelOAuth2Api',
{
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
url: `https://services.leadconnectorhq.com/locations/${locationId}/timezones`,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Version: '2021-07-28',
},
},
)) as IDataObject;
const timezones = responseData.timeZones as string[];
const results: INodeListSearchItems[] = timezones
.map((zone) => ({
name: zone.trim(),
value: zone.trim(),
}))
.filter((zone) => !filter || zone.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
return 0;
});
return { results };
},
},
};
}

View file

@ -0,0 +1,387 @@
import type { INodeProperties } from 'n8n-workflow';
export const calendarOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: ['calendar'],
},
},
options: [
{
name: 'Book Appointment',
value: 'bookAppointment',
action: 'Book appointment in a calendar',
routing: {
request: {
method: 'POST',
url: '=/calendars/events/appointments',
},
},
},
{
name: 'Get Free Slots',
value: 'getFreeSlots',
action: 'Get free slots of a calendar',
routing: {
request: {
method: 'GET',
url: '=/calendars/{{$parameter.calendarId}}/free-slots',
},
},
},
],
default: 'bookAppointment',
noDataExpression: true,
},
];
const bookAppointmentProperties: INodeProperties[] = [
{
displayName: 'Calendar ID',
name: 'calendarId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['calendar'],
operation: ['bookAppointment'],
},
},
default: '',
routing: {
send: {
type: 'body',
property: 'calendarId',
},
},
},
{
displayName: 'Location ID',
name: 'locationId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['calendar'],
operation: ['bookAppointment'],
},
},
default: '',
routing: {
send: {
type: 'body',
property: 'locationId',
},
},
},
{
displayName: 'Contact ID',
name: 'contactId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['calendar'],
operation: ['bookAppointment'],
},
},
default: '',
routing: {
send: {
type: 'body',
property: 'contactId',
},
},
},
{
displayName: 'Start Time',
name: 'startTime',
type: 'string',
required: true,
description: 'Example: 2021-06-23T03:30:00+05:30',
displayOptions: {
show: {
resource: ['calendar'],
operation: ['bookAppointment'],
},
},
default: '',
routing: {
send: {
type: 'body',
property: 'startTime',
},
},
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['calendar'],
operation: ['bookAppointment'],
},
},
options: [
{
displayName: 'End Time',
name: 'endTime',
type: 'string',
description: 'Example: 2021-06-23T04:30:00+05:30',
default: '',
routing: {
send: {
type: 'body',
property: 'endTime',
},
},
},
{
displayName: 'Title',
name: 'title',
type: 'string',
default: '',
routing: {
send: {
type: 'body',
property: 'title',
},
},
},
{
displayName: 'Appointment Status',
name: 'appointmentStatus',
type: 'options',
default: 'new',
description:
'The status of the appointment. Allowed values: new, confirmed, cancelled, showed, noshow, invalid.',
options: [
{
name: 'Cancelled',
value: 'cancelled',
},
{
name: 'Confirmed',
value: 'confirmed',
},
{
name: 'Invalid',
value: 'invalid',
},
{
name: 'New',
value: 'new',
},
{
name: 'No Show',
value: 'noshow',
},
{
name: 'Showed',
value: 'showed',
},
],
routing: {
send: {
type: 'body',
property: 'appointmentStatus',
},
},
},
{
displayName: 'Assigned User ID',
name: 'assignedUserId',
type: 'string',
default: '',
routing: {
send: {
type: 'body',
property: 'assignedUserId',
},
},
},
{
displayName: 'Address',
name: 'address',
type: 'string',
default: '',
routing: {
send: {
type: 'body',
property: 'address',
},
},
},
{
displayName: 'Ignore Date Range',
name: 'ignoreDateRange',
type: 'boolean',
default: false,
routing: {
send: {
type: 'body',
property: 'ignoreDateRange',
},
},
},
{
displayName: 'Notify',
name: 'toNotify',
type: 'boolean',
default: true,
routing: {
send: {
type: 'body',
property: 'toNotify',
},
},
},
],
},
];
const getFreeSlotsProperties: INodeProperties[] = [
{
displayName: 'Calendar ID',
name: 'calendarId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['calendar'],
operation: ['getFreeSlots'],
},
},
default: '',
},
{
displayName: 'Start Date',
name: 'startDate',
type: 'number',
//type: 'dateTime' TODO
default: '',
required: true,
description: 'The start date for fetching free calendar slots. Example: 1548898600000.',
displayOptions: {
show: {
resource: ['calendar'],
operation: ['getFreeSlots'],
},
},
routing: {
send: {
type: 'query',
property: 'startDate',
},
},
},
{
displayName: 'End Date',
name: 'endDate',
type: 'number',
//type: 'dateTime' TODO
default: '',
required: true,
description: 'The end date for fetching free calendar slots. Example: 1601490599999.',
displayOptions: {
show: {
resource: ['calendar'],
operation: ['getFreeSlots'],
},
},
routing: {
send: {
type: 'query',
property: 'endDate',
},
},
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['calendar'],
operation: ['getFreeSlots'],
},
},
options: [
{
displayName: 'Timezone',
name: 'timezone',
type: 'string',
default: '',
description: 'The timezone to use for the returned slots. Example: America/Chihuahua.',
routing: {
send: {
type: 'query',
property: 'timezone',
},
},
},
{
displayName: 'User ID',
name: 'userId',
type: 'string',
default: '',
description: 'User ID to filter the slots (optional)',
routing: {
send: {
type: 'query',
property: 'userId',
},
},
},
{
displayName: 'User IDs',
name: 'userIds',
type: 'collection',
default: {},
options: [
{
displayName: 'User IDs',
name: 'userIds',
type: 'string',
default: '',
description: 'Comma-separated list of user IDs to filter the slots',
routing: {
send: {
type: 'query',
property: 'userIds',
},
},
},
],
},
{
displayName: 'Apply Look Busy',
name: 'enableLookBusy',
type: 'boolean',
default: false,
// eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether
description: 'Apply Look Busy to the slots',
routing: {
send: {
type: 'query',
property: 'enableLookBusy',
},
},
},
],
},
];
export const calendarFields: INodeProperties[] = [
...bookAppointmentProperties,
...getFreeSlotsProperties,
];

View file

@ -1,7 +1,9 @@
import type { INodeProperties } from 'n8n-workflow';
import {
addCustomFieldsPreSendAction,
addLocationIdPreSendAction,
addNotePostReceiveAction,
splitTagsPreSendAction,
validEmailAndPhonePreSendAction,
} from '../GenericFunctions';
@ -31,6 +33,7 @@ export const contactOperations: INodeProperties[] = [
validEmailAndPhonePreSendAction,
splitTagsPreSendAction,
addLocationIdPreSendAction,
addCustomFieldsPreSendAction,
],
},
output: {
@ -41,6 +44,7 @@ export const contactOperations: INodeProperties[] = [
property: 'contact',
},
},
addNotePostReceiveAction,
],
},
},
@ -165,44 +169,27 @@ const customFields: INodeProperties = {
{
displayName: 'Field Name or ID',
name: 'fieldId',
type: 'options',
required: true,
type: 'resourceLocator',
default: '',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptions: {
routing: {
request: {
url: '/custom-fields',
method: 'GET',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'customFields',
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.name}}',
value: '={{$responseItem.id}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
description: 'Choose from the list, or specify an ID using an expression',
modes: [
{
displayName: 'List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchCustomFields',
searchable: true,
},
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
placeholder: 'Enter Custom Field ID',
},
],
},
{
displayName: 'Field Value',
@ -211,15 +198,22 @@ const customFields: INodeProperties = {
default: '',
routing: {
send: {
value: '={{$value}}',
property: '=customField.{{$parent.fieldId}}',
type: 'body',
property: 'customFields',
value:
'={{ $parent.values.map(field => ({ fieldId: { id: field.fieldId.id }, field_value: field.fieldValue })) }}',
},
},
},
],
},
],
routing: {
send: {
type: 'body',
property: 'customFields',
},
},
};
const createProperties: INodeProperties[] = [
@ -391,6 +385,12 @@ const createProperties: INodeProperties[] = [
},
},
},
{
displayName: 'Note',
name: 'notes',
type: 'string',
default: '',
},
{
displayName: 'Tags',
name: 'tags',
@ -405,16 +405,29 @@ const createProperties: INodeProperties[] = [
},
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Timezone',
name: 'timezone',
type: 'options',
placeholder: 'Select Timezone',
type: 'resourceLocator',
default: '',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getTimezones',
},
description: 'Choose from the list, or specify a timezone using an expression',
modes: [
{
displayName: 'List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchTimezones',
searchable: true,
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
placeholder: 'Enter Timezone ID',
},
],
routing: {
send: {
type: 'body',
@ -608,16 +621,29 @@ const updateProperties: INodeProperties[] = [
},
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-options
displayName: 'Timezone',
name: 'timezone',
type: 'options',
placeholder: 'Select Timezone',
type: 'resourceLocator',
default: '',
description:
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>',
typeOptions: {
loadOptionsMethod: 'getTimezones',
},
description: 'Choose from the list, or specify a timezone using an expression',
modes: [
{
displayName: 'List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchTimezones',
searchable: true,
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
placeholder: 'Enter Timezone ID',
},
],
routing: {
send: {
type: 'body',
@ -700,9 +726,8 @@ const getAllProperties: INodeProperties[] = [
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 20,
default: 50,
routing: {
send: {
type: 'query',

View file

@ -0,0 +1,98 @@
import type { IDataObject, IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow';
import { addCustomFieldsPreSendAction } from '../GenericFunctions';
describe('addCustomFieldsPreSendAction', () => {
let mockThis: any;
beforeEach(() => {
mockThis = {
helpers: {
httpRequest: jest.fn(),
httpRequestWithAuthentication: jest.fn(),
requestWithAuthenticationPaginated: jest.fn(),
request: jest.fn(),
requestWithAuthentication: jest.fn(),
requestOAuth1: jest.fn(),
requestOAuth2: jest.fn(),
assertBinaryData: jest.fn(),
getBinaryDataBuffer: jest.fn(),
prepareBinaryData: jest.fn(),
setBinaryDataBuffer: jest.fn(),
copyBinaryFile: jest.fn(),
binaryToBuffer: jest.fn(),
binaryToString: jest.fn(),
getBinaryPath: jest.fn(),
getBinaryStream: jest.fn(),
getBinaryMetadata: jest.fn(),
createDeferredPromise: jest
.fn()
.mockReturnValue({ promise: Promise.resolve(), resolve: jest.fn(), reject: jest.fn() }),
},
};
});
it('should format custom fields correctly when provided', async () => {
const mockRequestOptions: IHttpRequestOptions = {
body: {
customFields: {
values: [
{
fieldId: { value: '123', cachedResultName: 'FieldName' },
fieldValue: 'TestValue',
},
{
fieldId: { value: '456' },
fieldValue: 'AnotherValue',
},
],
},
} as IDataObject,
url: '',
};
const result = await addCustomFieldsPreSendAction.call(
mockThis as IExecuteSingleFunctions,
mockRequestOptions,
);
expect((result.body as IDataObject).customFields).toEqual([
{ id: '123', key: 'FieldName', field_value: 'TestValue' },
{ id: '456', key: 'default_key', field_value: 'AnotherValue' },
]);
});
it('should not modify request body if customFields is not provided', async () => {
const mockRequestOptions: IHttpRequestOptions = {
body: {
otherField: 'SomeValue',
} as IDataObject,
url: '',
};
const result = await addCustomFieldsPreSendAction.call(
mockThis as IExecuteSingleFunctions,
mockRequestOptions,
);
expect(result).toEqual(mockRequestOptions);
});
it('should handle customFields with empty values', async () => {
const mockRequestOptions: IHttpRequestOptions = {
body: {
customFields: {
values: [],
},
} as IDataObject,
url: '',
};
const result = await addCustomFieldsPreSendAction.call(
mockThis as IExecuteSingleFunctions,
mockRequestOptions,
);
expect((result.body as IDataObject).customFields).toEqual([]);
});
});

View file

@ -0,0 +1,125 @@
import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow';
import { addLocationIdPreSendAction } from '../GenericFunctions';
describe('addLocationIdPreSendAction', () => {
let mockThis: Partial<IExecuteSingleFunctions>;
beforeEach(() => {
mockThis = {
getNodeParameter: jest.fn(),
getCredentials: jest.fn(),
};
});
it('should add locationId to query parameters for contact getAll operation', async () => {
(mockThis.getNodeParameter as jest.Mock)
.mockReturnValueOnce('contact')
.mockReturnValueOnce('getAll');
(mockThis.getCredentials as jest.Mock).mockResolvedValue({
oauthTokenData: { locationId: '123' },
});
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
qs: {},
};
const result = await addLocationIdPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.qs).toEqual({ locationId: '123' });
});
it('should add locationId to the body for contact create operation', async () => {
(mockThis.getNodeParameter as jest.Mock)
.mockReturnValueOnce('contact')
.mockReturnValueOnce('create');
(mockThis.getCredentials as jest.Mock).mockResolvedValue({
oauthTokenData: { locationId: '123' },
});
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {},
};
const result = await addLocationIdPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.body).toEqual({ locationId: '123' });
});
it('should add locationId to query parameters for opportunity getAll operation', async () => {
(mockThis.getNodeParameter as jest.Mock)
.mockReturnValueOnce('opportunity')
.mockReturnValueOnce('getAll');
(mockThis.getCredentials as jest.Mock).mockResolvedValue({
oauthTokenData: { locationId: '123' },
});
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
qs: {},
};
const result = await addLocationIdPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.qs).toEqual({ location_id: '123' });
});
it('should add locationId to the body for opportunity create operation', async () => {
(mockThis.getNodeParameter as jest.Mock)
.mockReturnValueOnce('opportunity')
.mockReturnValueOnce('create');
(mockThis.getCredentials as jest.Mock).mockResolvedValue({
oauthTokenData: { locationId: '123' },
});
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {},
};
const result = await addLocationIdPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.body).toEqual({ locationId: '123' });
});
it('should not modify requestOptions if no resource or operation matches', async () => {
(mockThis.getNodeParameter as jest.Mock)
.mockReturnValueOnce('unknown')
.mockReturnValueOnce('unknown');
(mockThis.getCredentials as jest.Mock).mockResolvedValue({
oauthTokenData: { locationId: '123' },
});
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {},
qs: {},
};
const result = await addLocationIdPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result).toEqual(requestOptions);
});
});

View file

@ -0,0 +1,116 @@
import { highLevelApiRequest } from '../GenericFunctions';
describe('GenericFunctions - highLevelApiRequest', () => {
let mockContext: any;
let mockHttpRequestWithAuthentication: jest.Mock;
beforeEach(() => {
mockHttpRequestWithAuthentication = jest.fn();
mockContext = {
helpers: {
httpRequestWithAuthentication: mockHttpRequestWithAuthentication,
},
};
});
test('should make a successful request with all parameters', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValueOnce(mockResponse);
const method = 'POST';
const resource = '/example-resource';
const body = { key: 'value' };
const qs = { query: 'test' };
const url = 'https://custom-url.example.com/api';
const option = { headers: { Authorization: 'Bearer test-token' } };
const result = await highLevelApiRequest.call(
mockContext,
method,
resource,
body,
qs,
url,
option,
);
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('highLevelOAuth2Api', {
headers: { Authorization: 'Bearer test-token' },
method: 'POST',
body: { key: 'value' },
qs: { query: 'test' },
url: 'https://custom-url.example.com/api',
json: true,
});
expect(result).toEqual(mockResponse);
});
test('should default to the base URL when no custom URL is provided', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValueOnce(mockResponse);
const method = 'GET';
const resource = '/default-resource';
const result = await highLevelApiRequest.call(mockContext, method, resource);
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('highLevelOAuth2Api', {
headers: {
'Content-Type': 'application/json',
Version: '2021-07-28',
},
method: 'GET',
url: 'https://services.leadconnectorhq.com/default-resource',
json: true,
});
expect(result).toEqual(mockResponse);
});
test('should remove the body property if it is empty', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValueOnce(mockResponse);
const method = 'DELETE';
const resource = '/example-resource';
const body = {};
const result = await highLevelApiRequest.call(mockContext, method, resource, body);
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('highLevelOAuth2Api', {
headers: {
'Content-Type': 'application/json',
Version: '2021-07-28',
},
method: 'DELETE',
url: 'https://services.leadconnectorhq.com/example-resource',
json: true,
});
expect(result).toEqual(mockResponse);
});
test('should remove the query string property if it is empty', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValueOnce(mockResponse);
const method = 'PATCH';
const resource = '/example-resource';
const qs = {};
const result = await highLevelApiRequest.call(mockContext, method, resource, {}, qs);
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('highLevelOAuth2Api', {
headers: {
'Content-Type': 'application/json',
Version: '2021-07-28',
},
method: 'PATCH',
url: 'https://services.leadconnectorhq.com/example-resource',
json: true,
});
expect(result).toEqual(mockResponse);
});
});

View file

@ -0,0 +1,130 @@
import type { IExecuteSingleFunctions, IHttpRequestOptions, INode } from 'n8n-workflow';
import { contactIdentifierPreSendAction, isEmailValid, isPhoneValid } from '../GenericFunctions';
jest.mock('../GenericFunctions', () => ({
...jest.requireActual('../GenericFunctions'),
isEmailValid: jest.fn(),
isPhoneValid: jest.fn(),
}));
describe('contactIdentifierPreSendAction', () => {
let mockThis: Partial<IExecuteSingleFunctions>;
beforeEach(() => {
mockThis = {
getNode: jest.fn(
() =>
({
id: 'mock-node-id',
name: 'mock-node',
typeVersion: 1,
type: 'n8n-nodes-base.mockNode',
position: [0, 0],
parameters: {},
}) as INode,
),
getNodeParameter: jest.fn((parameterName: string) => {
if (parameterName === 'contactIdentifier') return null;
if (parameterName === 'updateFields') return { contactIdentifier: 'default-identifier' };
return undefined;
}),
};
});
it('should add email to requestOptions.body if identifier is a valid email', async () => {
(isEmailValid as jest.Mock).mockReturnValue(true);
(isPhoneValid as jest.Mock).mockReturnValue(false);
(mockThis.getNodeParameter as jest.Mock).mockReturnValue('valid@example.com'); // Mock email
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {},
};
const result = await contactIdentifierPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.body).toEqual({ email: 'valid@example.com' });
});
it('should add phone to requestOptions.body if identifier is a valid phone', async () => {
(isEmailValid as jest.Mock).mockReturnValue(false);
(isPhoneValid as jest.Mock).mockReturnValue(true);
(mockThis.getNodeParameter as jest.Mock).mockReturnValue('1234567890'); // Mock phone
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {},
};
const result = await contactIdentifierPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.body).toEqual({ phone: '1234567890' });
});
it('should add contactId to requestOptions.body if identifier is neither email nor phone', async () => {
(isEmailValid as jest.Mock).mockReturnValue(false);
(isPhoneValid as jest.Mock).mockReturnValue(false);
(mockThis.getNodeParameter as jest.Mock).mockReturnValue('contact-id-123'); // Mock contactId
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {},
};
const result = await contactIdentifierPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.body).toEqual({ contactId: 'contact-id-123' });
});
it('should use updateFields.contactIdentifier if contactIdentifier is not provided', async () => {
(isEmailValid as jest.Mock).mockReturnValue(true);
(isPhoneValid as jest.Mock).mockReturnValue(false);
(mockThis.getNodeParameter as jest.Mock).mockImplementation((parameterName: string) => {
if (parameterName === 'contactIdentifier') return null;
if (parameterName === 'updateFields')
return { contactIdentifier: 'default-email@example.com' };
return undefined;
});
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {},
};
const result = await contactIdentifierPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.body).toEqual({ email: 'default-email@example.com' });
});
it('should initialize body as an empty object if it is undefined', async () => {
(isEmailValid as jest.Mock).mockReturnValue(false);
(isPhoneValid as jest.Mock).mockReturnValue(false);
(mockThis.getNodeParameter as jest.Mock).mockReturnValue('identifier-123');
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: undefined,
};
const result = await contactIdentifierPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.body).toEqual({ contactId: 'identifier-123' });
});
});

View file

@ -0,0 +1,95 @@
import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow';
import { dateTimeToEpochPreSendAction } from '../GenericFunctions';
describe('dateTimeToEpochPreSendAction', () => {
let mockThis: Partial<IExecuteSingleFunctions>;
beforeEach(() => {
mockThis = {};
});
it('should convert startDate and endDate to epoch time', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
qs: {
startDate: '2024-12-25T00:00:00Z',
endDate: '2024-12-26T00:00:00Z',
},
};
const result = await dateTimeToEpochPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.qs).toEqual({
startDate: new Date('2024-12-25T00:00:00Z').getTime(),
endDate: new Date('2024-12-26T00:00:00Z').getTime(),
});
});
it('should convert only startDate if endDate is not provided', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
qs: {
startDate: '2024-12-25T00:00:00Z',
},
};
const result = await dateTimeToEpochPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.qs).toEqual({
startDate: new Date('2024-12-25T00:00:00Z').getTime(),
});
});
it('should convert only endDate if startDate is not provided', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
qs: {
endDate: '2024-12-26T00:00:00Z',
},
};
const result = await dateTimeToEpochPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.qs).toEqual({
endDate: new Date('2024-12-26T00:00:00Z').getTime(),
});
});
it('should not modify requestOptions if neither startDate nor endDate are provided', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
qs: {},
};
const result = await dateTimeToEpochPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result).toEqual(requestOptions);
});
it('should not modify requestOptions if qs is undefined', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
qs: undefined,
};
const result = await dateTimeToEpochPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result).toEqual(requestOptions);
});
});

View file

@ -0,0 +1,33 @@
import { dateToIsoSupressMillis } from '../GenericFunctions';
describe('dateToIsoSupressMillis', () => {
it('should return an ISO string without milliseconds (UTC)', () => {
const dateTime = '2024-12-25T10:15:30.123Z';
const result = dateToIsoSupressMillis(dateTime);
expect(result).toBe('2024-12-25T10:15:30.123+00:00');
});
it('should handle dates without milliseconds correctly', () => {
const dateTime = '2024-12-25T10:15:30Z';
const result = dateToIsoSupressMillis(dateTime);
expect(result).toBe('2024-12-25T10:15:30+00:00');
});
it('should handle time zone offsets correctly', () => {
const dateTime = '2024-12-25T10:15:30.123+02:00';
const result = dateToIsoSupressMillis(dateTime);
expect(result).toBe('2024-12-25T08:15:30.123+00:00');
});
it('should handle edge case for empty input', () => {
const dateTime = '';
const result = dateToIsoSupressMillis(dateTime);
expect(result).toBeNull();
});
it('should handle edge case for null input', () => {
const dateTime = null as unknown as string;
const result = dateToIsoSupressMillis(dateTime);
expect(result).toBeNull();
});
});

View file

@ -0,0 +1,62 @@
import type { IExecuteSingleFunctions, IHttpRequestOptions, INode } from 'n8n-workflow';
import { dueDatePreSendAction } from '../GenericFunctions';
describe('dueDatePreSendAction', () => {
let mockThis: IExecuteSingleFunctions;
beforeEach(() => {
mockThis = {
getNode: jest.fn(
() =>
({
id: 'mock-node-id',
name: 'mock-node',
typeVersion: 1,
type: 'n8n-nodes-base.mockNode',
position: [0, 0],
parameters: {},
}) as INode,
),
getNodeParameter: jest.fn(),
getInputData: jest.fn(),
helpers: {} as any,
} as unknown as IExecuteSingleFunctions;
jest.clearAllMocks();
});
it('should add formatted dueDate to requestOptions.body if dueDate is provided directly', async () => {
(mockThis.getNodeParameter as jest.Mock).mockReturnValueOnce('2024-12-25');
const requestOptions: IHttpRequestOptions = { url: 'https://example.com/api' };
const result = await dueDatePreSendAction.call(mockThis, requestOptions);
expect(result.body).toEqual({ dueDate: '2024-12-25T00:00:00+00:00' });
});
it('should add formatted dueDate to requestOptions.body if dueDate is provided in updateFields', async () => {
(mockThis.getNodeParameter as jest.Mock).mockImplementation((paramName: string) => {
if (paramName === 'dueDate') return null;
if (paramName === 'updateFields') return { dueDate: '2024-12-25' };
return undefined;
});
const requestOptions: IHttpRequestOptions = { url: 'https://example.com/api' };
const result = await dueDatePreSendAction.call(mockThis, requestOptions);
expect(result.body).toEqual({ dueDate: '2024-12-25T00:00:00+00:00' });
});
it('should initialize body as an empty object if it is undefined', async () => {
(mockThis.getNodeParameter as jest.Mock).mockReturnValueOnce('2024-12-25');
const requestOptions: IHttpRequestOptions = { url: 'https://example.com/api', body: undefined };
const result = await dueDatePreSendAction.call(mockThis, requestOptions);
expect(result.body).toEqual({ dueDate: '2024-12-25T00:00:00+00:00' });
});
});

View file

@ -0,0 +1,45 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { getContacts } from '../GenericFunctions';
describe('getContacts', () => {
const mockHighLevelApiRequest = jest.fn();
const mockGetCredentials = jest.fn();
const mockContext = {
getCredentials: mockGetCredentials,
helpers: {
httpRequestWithAuthentication: mockHighLevelApiRequest,
},
} as unknown as ILoadOptionsFunctions;
beforeEach(() => {
mockHighLevelApiRequest.mockClear();
mockGetCredentials.mockClear();
});
it('should return a list of contacts', async () => {
const mockContacts = [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' },
];
mockHighLevelApiRequest.mockResolvedValue({ contacts: mockContacts });
mockGetCredentials.mockResolvedValue({ oauthTokenData: { locationId: '123' } });
const response = await getContacts.call(mockContext);
expect(response).toEqual([
{ name: 'alice@example.com', value: '1' },
{ name: 'bob@example.com', value: '2' },
]);
});
it('should handle empty contacts list', async () => {
mockHighLevelApiRequest.mockResolvedValue({ contacts: [] });
mockGetCredentials.mockResolvedValue({ oauthTokenData: { locationId: '123' } });
const response = await getContacts.call(mockContext);
expect(response).toEqual([]);
});
});

View file

@ -0,0 +1,87 @@
import { getPipelineStages } from '../GenericFunctions';
const mockHighLevelApiRequest = jest.fn();
const mockGetNodeParameter = jest.fn();
const mockGetCurrentNodeParameter = jest.fn();
const mockGetCredentials = jest.fn();
const mockContext: any = {
getNodeParameter: mockGetNodeParameter,
getCurrentNodeParameter: mockGetCurrentNodeParameter,
getCredentials: mockGetCredentials,
helpers: {
httpRequestWithAuthentication: mockHighLevelApiRequest,
},
};
describe('getPipelineStages', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return pipeline stages for create operation', async () => {
mockGetNodeParameter.mockReturnValue('create');
mockGetCurrentNodeParameter.mockReturnValue('pipeline-1');
mockHighLevelApiRequest.mockResolvedValue({
pipelines: [
{
id: 'pipeline-1',
stages: [
{ id: 'stage-1', name: 'Stage 1' },
{ id: 'stage-2', name: 'Stage 2' },
],
},
],
});
const response = await getPipelineStages.call(mockContext);
expect(response).toEqual([
{ name: 'Stage 1', value: 'stage-1' },
{ name: 'Stage 2', value: 'stage-2' },
]);
});
it('should return pipeline stages for update operation', async () => {
mockGetNodeParameter.mockImplementation((param) => {
if (param === 'operation') return 'update';
if (param === 'updateFields.pipelineId') return 'pipeline-2';
});
mockHighLevelApiRequest.mockResolvedValue({
pipelines: [
{
id: 'pipeline-2',
stages: [
{ id: 'stage-3', name: 'Stage 3' },
{ id: 'stage-4', name: 'Stage 4' },
],
},
],
});
const response = await getPipelineStages.call(mockContext);
expect(response).toEqual([
{ name: 'Stage 3', value: 'stage-3' },
{ name: 'Stage 4', value: 'stage-4' },
]);
});
it('should return an empty array if pipeline is not found', async () => {
mockGetNodeParameter.mockReturnValue('create');
mockGetCurrentNodeParameter.mockReturnValue('non-existent-pipeline');
mockHighLevelApiRequest.mockResolvedValue({
pipelines: [
{
id: 'pipeline-1',
stages: [{ id: 'stage-1', name: 'Stage 1' }],
},
],
});
const response = await getPipelineStages.call(mockContext);
expect(response).toEqual([]);
});
});

View file

@ -0,0 +1,45 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { getPipelines } from '../GenericFunctions';
describe('getPipelines', () => {
const mockHighLevelApiRequest = jest.fn();
const mockGetCredentials = jest.fn();
const mockContext = {
getCredentials: mockGetCredentials,
helpers: {
httpRequestWithAuthentication: mockHighLevelApiRequest,
},
} as unknown as ILoadOptionsFunctions;
beforeEach(() => {
mockHighLevelApiRequest.mockClear();
mockGetCredentials.mockClear();
});
it('should return a list of pipelines', async () => {
const mockPipelines = [
{ id: '1', name: 'Pipeline A' },
{ id: '2', name: 'Pipeline B' },
];
mockHighLevelApiRequest.mockResolvedValue({ pipelines: mockPipelines });
mockGetCredentials.mockResolvedValue({ oauthTokenData: { locationId: '123' } });
const response = await getPipelines.call(mockContext);
expect(response).toEqual([
{ name: 'Pipeline A', value: '1' },
{ name: 'Pipeline B', value: '2' },
]);
});
it('should handle empty pipelines list', async () => {
mockHighLevelApiRequest.mockResolvedValue({ pipelines: [] });
mockGetCredentials.mockResolvedValue({ oauthTokenData: { locationId: '123' } });
const response = await getPipelines.call(mockContext);
expect(response).toEqual([]);
});
});

View file

@ -0,0 +1,45 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { getUsers } from '../GenericFunctions';
describe('getUsers', () => {
const mockHighLevelApiRequest = jest.fn();
const mockGetCredentials = jest.fn();
const mockContext = {
getCredentials: mockGetCredentials,
helpers: {
httpRequestWithAuthentication: mockHighLevelApiRequest,
},
} as unknown as ILoadOptionsFunctions;
beforeEach(() => {
mockHighLevelApiRequest.mockClear();
mockGetCredentials.mockClear();
});
it('should return a list of users', async () => {
const mockUsers = [
{ id: '1', name: 'John Doe', email: 'john.doe@example.com' },
{ id: '2', name: 'Jane Smith', email: 'jane.smith@example.com' },
];
mockHighLevelApiRequest.mockResolvedValue({ users: mockUsers });
mockGetCredentials.mockResolvedValue({ oauthTokenData: { locationId: '123' } });
const response = await getUsers.call(mockContext);
expect(response).toEqual([
{ name: 'John Doe', value: '1' },
{ name: 'Jane Smith', value: '2' },
]);
});
it('should handle empty users list', async () => {
mockHighLevelApiRequest.mockResolvedValue({ users: [] });
mockGetCredentials.mockResolvedValue({ oauthTokenData: { locationId: '123' } });
const response = await getUsers.call(mockContext);
expect(response).toEqual([]);
});
});

View file

@ -0,0 +1,103 @@
import type { IExecutePaginationFunctions } from 'n8n-workflow';
import { highLevelApiPagination } from '../GenericFunctions';
describe('highLevelApiPagination', () => {
let mockContext: Partial<IExecutePaginationFunctions>;
beforeEach(() => {
mockContext = {
getNodeParameter: jest.fn(),
makeRoutingRequest: jest.fn(),
};
});
it('should paginate and return all items when returnAll is true', async () => {
(mockContext.getNodeParameter as jest.Mock).mockImplementation((parameter) => {
if (parameter === 'resource') return 'contact';
if (parameter === 'returnAll') return true;
});
(mockContext.makeRoutingRequest as jest.Mock)
.mockResolvedValueOnce([
{
json: {
contacts: [{ id: '1' }, { id: '2' }],
meta: { startAfterId: '2', startAfter: 2, total: 4 },
},
},
])
.mockResolvedValueOnce([
{
json: {
contacts: [{ id: '3' }, { id: '4' }],
meta: { startAfterId: null, startAfter: null, total: 4 },
},
},
]);
const requestData = { options: { qs: {} } } as any;
const result = await highLevelApiPagination.call(
mockContext as IExecutePaginationFunctions,
requestData,
);
expect(result).toEqual([
{ json: { id: '1' } },
{ json: { id: '2' } },
{ json: { id: '3' } },
{ json: { id: '4' } },
]);
});
it('should return only the first page of items when returnAll is false', async () => {
(mockContext.getNodeParameter as jest.Mock).mockImplementation((parameter) => {
if (parameter === 'resource') return 'contact';
if (parameter === 'returnAll') return false;
});
(mockContext.makeRoutingRequest as jest.Mock).mockResolvedValueOnce([
{
json: {
contacts: [{ id: '1' }, { id: '2' }],
meta: { startAfterId: '2', startAfter: 2, total: 4 },
},
},
]);
const requestData = { options: { qs: {} } } as any;
const result = await highLevelApiPagination.call(
mockContext as IExecutePaginationFunctions,
requestData,
);
expect(result).toEqual([{ json: { id: '1' } }, { json: { id: '2' } }]);
});
it('should handle cases with no items', async () => {
(mockContext.getNodeParameter as jest.Mock).mockImplementation((parameter) => {
if (parameter === 'resource') return 'contact';
if (parameter === 'returnAll') return true;
});
(mockContext.makeRoutingRequest as jest.Mock).mockResolvedValueOnce([
{
json: {
contacts: [],
meta: { startAfterId: null, startAfter: null, total: 0 },
},
},
]);
const requestData = { options: { qs: {} } } as any;
const result = await highLevelApiPagination.call(
mockContext as IExecutePaginationFunctions,
requestData,
);
expect(result).toEqual([]);
});
});

View file

@ -0,0 +1,116 @@
import { highLevelApiRequest } from '../GenericFunctions';
describe('GenericFunctions - highLevelApiRequest', () => {
let mockContext: any;
let mockHttpRequestWithAuthentication: jest.Mock;
beforeEach(() => {
mockHttpRequestWithAuthentication = jest.fn();
mockContext = {
helpers: {
httpRequestWithAuthentication: mockHttpRequestWithAuthentication,
},
};
});
test('should make a successful request with all parameters', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValueOnce(mockResponse);
const method = 'POST';
const resource = '/example-resource';
const body = { key: 'value' };
const qs = { query: 'test' };
const url = 'https://custom-url.example.com/api';
const option = { headers: { Authorization: 'Bearer test-token' } };
const result = await highLevelApiRequest.call(
mockContext,
method,
resource,
body,
qs,
url,
option,
);
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('highLevelOAuth2Api', {
headers: { Authorization: 'Bearer test-token' },
method: 'POST',
body: { key: 'value' },
qs: { query: 'test' },
url: 'https://custom-url.example.com/api',
json: true,
});
expect(result).toEqual(mockResponse);
});
test('should default to the base URL when no custom URL is provided', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValueOnce(mockResponse);
const method = 'GET';
const resource = '/default-resource';
const result = await highLevelApiRequest.call(mockContext, method, resource);
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('highLevelOAuth2Api', {
headers: {
'Content-Type': 'application/json',
Version: '2021-07-28',
},
method: 'GET',
url: 'https://services.leadconnectorhq.com/default-resource',
json: true,
});
expect(result).toEqual(mockResponse);
});
test('should remove the body property if it is empty', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValueOnce(mockResponse);
const method = 'DELETE';
const resource = '/example-resource';
const body = {};
const result = await highLevelApiRequest.call(mockContext, method, resource, body);
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('highLevelOAuth2Api', {
headers: {
'Content-Type': 'application/json',
Version: '2021-07-28',
},
method: 'DELETE',
url: 'https://services.leadconnectorhq.com/example-resource',
json: true,
});
expect(result).toEqual(mockResponse);
});
test('should remove the query string property if it is empty', async () => {
const mockResponse = { success: true };
mockHttpRequestWithAuthentication.mockResolvedValueOnce(mockResponse);
const method = 'PATCH';
const resource = '/example-resource';
const qs = {};
const result = await highLevelApiRequest.call(mockContext, method, resource, {}, qs);
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith('highLevelOAuth2Api', {
headers: {
'Content-Type': 'application/json',
Version: '2021-07-28',
},
method: 'PATCH',
url: 'https://services.leadconnectorhq.com/example-resource',
json: true,
});
expect(result).toEqual(mockResponse);
});
});

View file

@ -0,0 +1,63 @@
import { isEmailValid } from '../GenericFunctions';
describe('isEmailValid', () => {
it('should return true for a valid email address', () => {
const email = 'test@example.com';
const result = isEmailValid(email);
expect(result).toBe(true);
});
it('should return false for an invalid email address', () => {
const email = 'invalid-email';
const result = isEmailValid(email);
expect(result).toBe(false);
});
it('should return true for an email address with subdomain', () => {
const email = 'user@sub.example.com';
const result = isEmailValid(email);
expect(result).toBe(true);
});
it('should return false for an email address without a domain', () => {
const email = 'user@';
const result = isEmailValid(email);
expect(result).toBe(false);
});
it('should return false for an email address without a username', () => {
const email = '@example.com';
const result = isEmailValid(email);
expect(result).toBe(false);
});
it('should return true for an email address with a plus sign', () => {
const email = 'user+alias@example.com';
const result = isEmailValid(email);
expect(result).toBe(true);
});
it('should return false for an email address with invalid characters', () => {
const email = 'user@exa$mple.com';
const result = isEmailValid(email);
expect(result).toBe(false);
});
it('should return false for an email address without a top-level domain', () => {
const email = 'user@example';
const result = isEmailValid(email);
expect(result).toBe(false);
});
it('should return true for an email address with a valid top-level domain', () => {
const email = 'user@example.co.uk';
const result = isEmailValid(email);
expect(result).toBe(true);
});
it('should return false for an empty email string', () => {
const email = '';
const result = isEmailValid(email);
expect(result).toBe(false);
});
});

View file

@ -0,0 +1,33 @@
import { isPhoneValid } from '../GenericFunctions';
describe('isPhoneValid', () => {
it('should return true for a valid phone number', () => {
const phone = '+1234567890';
const result = isPhoneValid(phone);
expect(result).toBe(true);
});
it('should return false for an invalid phone number', () => {
const phone = 'invalid-phone';
const result = isPhoneValid(phone);
expect(result).toBe(false);
});
it('should return false for a phone number with invalid characters', () => {
const phone = '+123-abc-456';
const result = isPhoneValid(phone);
expect(result).toBe(false);
});
it('should return false for an empty phone number', () => {
const phone = '';
const result = isPhoneValid(phone);
expect(result).toBe(false);
});
it('should return false for a phone number with only special characters', () => {
const phone = '!!!@@@###';
const result = isPhoneValid(phone);
expect(result).toBe(false);
});
});

View file

@ -0,0 +1,91 @@
import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow';
import { splitTagsPreSendAction } from '../GenericFunctions';
describe('splitTagsPreSendAction', () => {
let mockThis: Partial<IExecuteSingleFunctions>;
beforeEach(() => {
mockThis = {};
});
it('should return requestOptions unchanged if tags are already an array', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {
tags: ['tag1', 'tag2', 'tag3'],
},
};
const result = await splitTagsPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result).toEqual(requestOptions);
});
it('should split a comma-separated string of tags into an array', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {
tags: 'tag1, tag2, tag3',
},
};
const result = await splitTagsPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.body).toEqual({
tags: ['tag1', 'tag2', 'tag3'],
});
});
it('should trim whitespace around tags when splitting a string', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {
tags: 'tag1 , tag2 , tag3 ',
},
};
const result = await splitTagsPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.body).toEqual({
tags: ['tag1', 'tag2', 'tag3'],
});
});
it('should return requestOptions unchanged if tags are not provided', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {},
};
const result = await splitTagsPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result).toEqual(requestOptions);
});
it('should return requestOptions unchanged if body is undefined', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: undefined,
};
const result = await splitTagsPreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result).toEqual(requestOptions);
});
});

View file

@ -0,0 +1,86 @@
import type {
IExecuteSingleFunctions,
INodeExecutionData,
IN8nHttpFullResponse,
} from 'n8n-workflow';
import { taskPostReceiceAction } from '../GenericFunctions';
describe('taskPostReceiceAction', () => {
let mockThis: Partial<IExecuteSingleFunctions>;
beforeEach(() => {
mockThis = {
getNodeParameter: jest.fn((parameterName: string) => {
if (parameterName === 'contactId') return '12345';
return undefined;
}),
};
});
it('should add contactId to each item in items', async () => {
const items: INodeExecutionData[] = [
{ json: { field1: 'value1' } },
{ json: { field2: 'value2' } },
];
const response: IN8nHttpFullResponse = {
body: {},
headers: {},
statusCode: 200,
};
const result = await taskPostReceiceAction.call(
mockThis as IExecuteSingleFunctions,
items,
response,
);
expect(result).toEqual([
{ json: { field1: 'value1', contactId: '12345' } },
{ json: { field2: 'value2', contactId: '12345' } },
]);
expect(mockThis.getNodeParameter).toHaveBeenCalledWith('contactId');
});
it('should not modify other fields in items', async () => {
const items: INodeExecutionData[] = [{ json: { name: 'John Doe' } }, { json: { age: 30 } }];
const response: IN8nHttpFullResponse = {
body: {},
headers: {},
statusCode: 200,
};
const result = await taskPostReceiceAction.call(
mockThis as IExecuteSingleFunctions,
items,
response,
);
expect(result).toEqual([
{ json: { name: 'John Doe', contactId: '12345' } },
{ json: { age: 30, contactId: '12345' } },
]);
expect(mockThis.getNodeParameter).toHaveBeenCalledWith('contactId');
});
it('should return an empty array if items is empty', async () => {
const items: INodeExecutionData[] = [];
const response: IN8nHttpFullResponse = {
body: {},
headers: {},
statusCode: 200,
};
const result = await taskPostReceiceAction.call(
mockThis as IExecuteSingleFunctions,
items,
response,
);
expect(result).toEqual([]);
expect(mockThis.getNodeParameter).toHaveBeenCalledWith('contactId');
});
});

View file

@ -0,0 +1,126 @@
import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow';
import { taskUpdatePreSendAction } from '../GenericFunctions';
describe('taskUpdatePreSendAction', () => {
let mockThis: Partial<IExecuteSingleFunctions>;
beforeEach(() => {
mockThis = {
getNodeParameter: jest.fn(),
helpers: {
httpRequestWithAuthentication: jest.fn(),
} as any,
};
});
it('should not modify requestOptions if title and dueDate are provided', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://api.example.com',
body: {
title: 'Task Title',
dueDate: '2024-12-25T00:00:00Z',
},
};
const result = await taskUpdatePreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result).toEqual(requestOptions);
});
it('should fetch missing title and dueDate from the API', async () => {
(mockThis.getNodeParameter as jest.Mock).mockReturnValueOnce('123').mockReturnValueOnce('456');
const mockApiResponse = {
title: 'Fetched Task Title',
dueDate: '2024-12-25T02:00:00+02:00',
};
(mockThis.helpers?.httpRequestWithAuthentication as jest.Mock).mockResolvedValue(
mockApiResponse,
);
const requestOptions: IHttpRequestOptions = {
url: 'https://api.example.com',
body: {
title: undefined,
dueDate: undefined,
},
};
const result = await taskUpdatePreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.body).toEqual({
title: 'Fetched Task Title',
dueDate: '2024-12-25T00:00:00+00:00',
});
});
it('should only fetch title if dueDate is provided', async () => {
(mockThis.getNodeParameter as jest.Mock).mockReturnValueOnce('123').mockReturnValueOnce('456');
const mockApiResponse = {
title: 'Fetched Task Title',
dueDate: '2024-12-25T02:00:00+02:00',
};
(mockThis.helpers?.httpRequestWithAuthentication as jest.Mock).mockResolvedValue(
mockApiResponse,
);
const requestOptions: IHttpRequestOptions = {
url: 'https://api.example.com',
body: {
title: undefined,
dueDate: '2024-12-24T00:00:00Z',
},
};
const result = await taskUpdatePreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.body).toEqual({
title: 'Fetched Task Title',
dueDate: '2024-12-24T00:00:00Z',
});
});
it('should only fetch dueDate if title is provided', async () => {
(mockThis.getNodeParameter as jest.Mock).mockReturnValueOnce('123').mockReturnValueOnce('456');
const mockApiResponse = {
title: 'Fetched Task Title',
dueDate: '2024-12-25T02:00:00+02:00',
};
(mockThis.helpers?.httpRequestWithAuthentication as jest.Mock).mockResolvedValue(
mockApiResponse,
);
const requestOptions: IHttpRequestOptions = {
url: 'https://api.example.com',
body: {
title: 'Existing Task Title',
dueDate: undefined,
},
};
const result = await taskUpdatePreSendAction.call(
mockThis as IExecuteSingleFunctions,
requestOptions,
);
expect(result.body).toEqual({
title: 'Existing Task Title',
dueDate: '2024-12-25T00:00:00+00:00',
});
});
});

View file

@ -0,0 +1,70 @@
import type { IExecuteSingleFunctions, IHttpRequestOptions, INode } from 'n8n-workflow';
import { validEmailAndPhonePreSendAction, isEmailValid, isPhoneValid } from '../GenericFunctions';
jest.mock('../GenericFunctions', () => ({
...jest.requireActual('../GenericFunctions'),
isEmailValid: jest.fn(),
isPhoneValid: jest.fn(),
}));
describe('validEmailAndPhonePreSendAction', () => {
let mockThis: IExecuteSingleFunctions;
beforeEach(() => {
mockThis = {
getNode: jest.fn(
() =>
({
id: 'mock-node-id',
name: 'mock-node',
typeVersion: 1,
type: 'n8n-nodes-base.mockNode',
position: [0, 0],
parameters: {},
}) as INode,
),
} as unknown as IExecuteSingleFunctions;
jest.clearAllMocks();
});
it('should return requestOptions unchanged if email and phone are valid', async () => {
(isEmailValid as jest.Mock).mockReturnValue(true);
(isPhoneValid as jest.Mock).mockReturnValue(true);
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {
email: 'valid@example.com',
phone: '+1234567890',
},
};
const result = await validEmailAndPhonePreSendAction.call(mockThis, requestOptions);
expect(result).toEqual(requestOptions);
});
it('should not modify requestOptions if no email or phone is provided', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: {},
};
const result = await validEmailAndPhonePreSendAction.call(mockThis, requestOptions);
expect(result).toEqual(requestOptions);
});
it('should not modify requestOptions if body is undefined', async () => {
const requestOptions: IHttpRequestOptions = {
url: 'https://example.com/api',
body: undefined,
};
const result = await validEmailAndPhonePreSendAction.call(mockThis, requestOptions);
expect(result).toEqual(requestOptions);
});
});