diff --git a/packages/nodes-base/credentials/HighLevelOAuth2Api.credentials.ts b/packages/nodes-base/credentials/HighLevelOAuth2Api.credentials.ts index 656cfcc0fb..f41ef76449 100644 --- a/packages/nodes-base/credentials/HighLevelOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/HighLevelOAuth2Api.credentials.ts @@ -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, + }, + }, ]; } diff --git a/packages/nodes-base/nodes/HighLevel/v2/GenericFunctions.ts b/packages/nodes-base/nodes/HighLevel/v2/GenericFunctions.ts index 5b4529b9be..1147371cfd 100644 --- a/packages/nodes-base/nodes/HighLevel/v2/GenericFunctions.ts +++ b/packages/nodes-base/nodes/HighLevel/v2/GenericFunctions.ts @@ -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 { - 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 { - 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 { - 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 { + 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 { - 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 { - 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 { - 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 { + 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; } diff --git a/packages/nodes-base/nodes/HighLevel/v2/HighLevelV2.node.ts b/packages/nodes-base/nodes/HighLevel/v2/HighLevelV2.node.ts index cb596319f9..be9ee5d4f7 100644 --- a/packages/nodes-base/nodes/HighLevel/v2/HighLevelV2.node.ts +++ b/packages/nodes-base/nodes/HighLevel/v2/HighLevelV2.node.ts @@ -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 { + 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 { + 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 }; + }, }, }; } diff --git a/packages/nodes-base/nodes/HighLevel/v2/description/CalendarDescription.ts b/packages/nodes-base/nodes/HighLevel/v2/description/CalendarDescription.ts new file mode 100644 index 0000000000..a30f2d0f03 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/description/CalendarDescription.ts @@ -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, +]; diff --git a/packages/nodes-base/nodes/HighLevel/v2/description/ContactDescription.ts b/packages/nodes-base/nodes/HighLevel/v2/description/ContactDescription.ts index 04b87b2d7d..770d2ed26e 100644 --- a/packages/nodes-base/nodes/HighLevel/v2/description/ContactDescription.ts +++ b/packages/nodes-base/nodes/HighLevel/v2/description/ContactDescription.ts @@ -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 expression', - 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 expression', - 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 expression', - 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', diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/AddCustomFieldsPreSendAction.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/AddCustomFieldsPreSendAction.test.ts new file mode 100644 index 0000000000..6d77177703 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/AddCustomFieldsPreSendAction.test.ts @@ -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([]); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/AddLocationIdPreSendAction.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/AddLocationIdPreSendAction.test.ts new file mode 100644 index 0000000000..608a454cb3 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/AddLocationIdPreSendAction.test.ts @@ -0,0 +1,125 @@ +import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow'; + +import { addLocationIdPreSendAction } from '../GenericFunctions'; + +describe('addLocationIdPreSendAction', () => { + let mockThis: Partial; + + 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); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/AddNotePostReceiveAction.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/AddNotePostReceiveAction.test.ts new file mode 100644 index 0000000000..77c790782e --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/AddNotePostReceiveAction.test.ts @@ -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); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/ContactIdentifierPreSendAction.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/ContactIdentifierPreSendAction.test.ts new file mode 100644 index 0000000000..f2c7bb1672 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/ContactIdentifierPreSendAction.test.ts @@ -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; + + 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' }); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/DateTimeToEpochPreSendAction.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/DateTimeToEpochPreSendAction.test.ts new file mode 100644 index 0000000000..6816504641 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/DateTimeToEpochPreSendAction.test.ts @@ -0,0 +1,95 @@ +import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow'; + +import { dateTimeToEpochPreSendAction } from '../GenericFunctions'; + +describe('dateTimeToEpochPreSendAction', () => { + let mockThis: Partial; + + 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); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/DateToIsoSuperssMillis.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/DateToIsoSuperssMillis.test.ts new file mode 100644 index 0000000000..c015908a9c --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/DateToIsoSuperssMillis.test.ts @@ -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(); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/DueDatePreSendAction.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/DueDatePreSendAction.test.ts new file mode 100644 index 0000000000..1b68bb6206 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/DueDatePreSendAction.test.ts @@ -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' }); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/GetContacts.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/GetContacts.test.ts new file mode 100644 index 0000000000..a432bf315b --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/GetContacts.test.ts @@ -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([]); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/GetPipelineStages.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/GetPipelineStages.test.ts new file mode 100644 index 0000000000..fa0f534f26 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/GetPipelineStages.test.ts @@ -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([]); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/GetPipelines.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/GetPipelines.test.ts new file mode 100644 index 0000000000..3c86568dff --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/GetPipelines.test.ts @@ -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([]); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/GetUsers.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/GetUsers.test.ts new file mode 100644 index 0000000000..a60335d20b --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/GetUsers.test.ts @@ -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([]); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/HighLevelApiPagination.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/HighLevelApiPagination.test.ts new file mode 100644 index 0000000000..297856eac9 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/HighLevelApiPagination.test.ts @@ -0,0 +1,103 @@ +import type { IExecutePaginationFunctions } from 'n8n-workflow'; + +import { highLevelApiPagination } from '../GenericFunctions'; + +describe('highLevelApiPagination', () => { + let mockContext: Partial; + + 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([]); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/HighLevelApiRequest.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/HighLevelApiRequest.test.ts new file mode 100644 index 0000000000..77c790782e --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/HighLevelApiRequest.test.ts @@ -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); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/IsEmailValid.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/IsEmailValid.test.ts new file mode 100644 index 0000000000..40c54ad41b --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/IsEmailValid.test.ts @@ -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); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/IsPhoneValid.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/IsPhoneValid.test.ts new file mode 100644 index 0000000000..d0fef9099c --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/IsPhoneValid.test.ts @@ -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); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/SplitTagsPreSendAction.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/SplitTagsPreSendAction.test.ts new file mode 100644 index 0000000000..0d5a0aa729 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/SplitTagsPreSendAction.test.ts @@ -0,0 +1,91 @@ +import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow'; + +import { splitTagsPreSendAction } from '../GenericFunctions'; + +describe('splitTagsPreSendAction', () => { + let mockThis: Partial; + + 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); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/TaskPostReceiceAction.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/TaskPostReceiceAction.test.ts new file mode 100644 index 0000000000..8b54b12f54 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/TaskPostReceiceAction.test.ts @@ -0,0 +1,86 @@ +import type { + IExecuteSingleFunctions, + INodeExecutionData, + IN8nHttpFullResponse, +} from 'n8n-workflow'; + +import { taskPostReceiceAction } from '../GenericFunctions'; + +describe('taskPostReceiceAction', () => { + let mockThis: Partial; + + 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'); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/TaskUpdatePreSendAction.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/TaskUpdatePreSendAction.test.ts new file mode 100644 index 0000000000..3598126af3 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/TaskUpdatePreSendAction.test.ts @@ -0,0 +1,126 @@ +import type { IExecuteSingleFunctions, IHttpRequestOptions } from 'n8n-workflow'; + +import { taskUpdatePreSendAction } from '../GenericFunctions'; + +describe('taskUpdatePreSendAction', () => { + let mockThis: Partial; + + 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', + }); + }); +}); diff --git a/packages/nodes-base/nodes/HighLevel/v2/test/ValidEmailAndPhonePreSendAction.test.ts b/packages/nodes-base/nodes/HighLevel/v2/test/ValidEmailAndPhonePreSendAction.test.ts new file mode 100644 index 0000000000..6a322e0c92 --- /dev/null +++ b/packages/nodes-base/nodes/HighLevel/v2/test/ValidEmailAndPhonePreSendAction.test.ts @@ -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); + }); +});