diff --git a/packages/nodes-base/credentials/CalendlyApi.credentials.ts b/packages/nodes-base/credentials/CalendlyApi.credentials.ts index c999e645903..5018786b5a4 100644 --- a/packages/nodes-base/credentials/CalendlyApi.credentials.ts +++ b/packages/nodes-base/credentials/CalendlyApi.credentials.ts @@ -6,24 +6,16 @@ import type { INodeProperties, } from 'n8n-workflow'; -const getAuthenticationType = (data: string): 'accessToken' | 'apiKey' => { - // The access token is a JWT, so it will always include dots to separate - // header, payoload and signature. - return data.includes('.') ? 'accessToken' : 'apiKey'; -}; - export class CalendlyApi implements ICredentialType { name = 'calendlyApi'; - displayName = 'Calendly API'; + displayName = 'Calendly Personal Access Token API'; documentationUrl = 'calendly'; properties: INodeProperties[] = [ - // Change name to Personal Access Token once API Keys - // are deprecated { - displayName: 'API Key or Personal Access Token', + displayName: 'Personal Access Token', name: 'apiKey', type: 'string', typeOptions: { password: true }, @@ -35,23 +27,16 @@ export class CalendlyApi implements ICredentialType { credentials: ICredentialDataDecryptedObject, requestOptions: IHttpRequestOptions, ): Promise { - //check whether the token is an API Key or an access token - const { apiKey } = credentials as { apiKey: string }; - const tokenType = getAuthenticationType(apiKey); - // remove condition once v1 is deprecated - // and only inject credentials as an access token - if (tokenType === 'accessToken') { - requestOptions.headers!.Authorization = `Bearer ${apiKey}`; - } else { - requestOptions.headers!['X-TOKEN'] = apiKey; - } + const apiKey = typeof credentials.apiKey === 'string' ? credentials.apiKey : ''; + requestOptions.headers = requestOptions.headers ?? {}; + requestOptions.headers.Authorization = `Bearer ${apiKey}`; return requestOptions; } test: ICredentialTestRequest = { request: { - baseURL: 'https://calendly.com', - url: '/api/v1/users/me', + baseURL: 'https://api.calendly.com', + url: '/users/me', }, }; } diff --git a/packages/nodes-base/credentials/test/CalendlyApi.credentials.test.ts b/packages/nodes-base/credentials/test/CalendlyApi.credentials.test.ts new file mode 100644 index 00000000000..d4552f7dff4 --- /dev/null +++ b/packages/nodes-base/credentials/test/CalendlyApi.credentials.test.ts @@ -0,0 +1,45 @@ +import type { ICredentialDataDecryptedObject, IHttpRequestOptions } from 'n8n-workflow'; + +import { CalendlyApi } from '../CalendlyApi.credentials'; + +describe('CalendlyApi Credential', () => { + const credential = new CalendlyApi(); + + it('should use Calendly API v2 for credential tests', () => { + expect(credential.name).toBe('calendlyApi'); + expect(credential.displayName).toBe('Calendly Personal Access Token API'); + expect(credential.properties).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + displayName: 'Personal Access Token', + name: 'apiKey', + typeOptions: { password: true }, + }), + ]), + ); + expect(credential.test.request).toEqual({ + baseURL: 'https://api.calendly.com', + url: '/users/me', + }); + }); + + it('should add personal access token as a bearer token', async () => { + const requestOptions: IHttpRequestOptions = { + headers: { + 'Content-Type': 'application/json', + }, + url: '/users/me', + }; + + const result = await credential.authenticate( + { apiKey: 'test-personal-access-token' } satisfies ICredentialDataDecryptedObject, + requestOptions, + ); + + expect(result.headers).toEqual({ + 'Content-Type': 'application/json', + Authorization: 'Bearer test-personal-access-token', + }); + expect(result.headers).not.toHaveProperty('X-TOKEN'); + }); +}); diff --git a/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts b/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts index f5644b93a47..a4564295271 100644 --- a/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts +++ b/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts @@ -8,10 +8,36 @@ import type { INodeTypeDescription, IWebhookResponseData, } from 'n8n-workflow'; -import { NodeConnectionTypes } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { verifySignature } from './CalendlyTriggerHelpers'; -import { calendlyApiRequest, getAuthenticationType } from './GenericFunctions'; +import { calendlyApiRequest } from './GenericFunctions'; + +function isDataObject(value: unknown): value is IDataObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getStringField(data: IDataObject | undefined, key: string): string | undefined { + const value = data?.[key]; + return typeof value === 'string' ? value : undefined; +} + +function getStringArrayField(data: IDataObject, key: string): string[] | undefined { + const value = data[key]; + if (!Array.isArray(value)) return undefined; + + const strings: string[] = []; + for (const item of value) { + if (typeof item !== 'string') return undefined; + strings.push(item); + } + + return strings; +} + +function isValidCalendlyWebhookUrl(webhookUrl: string | undefined): webhookUrl is string { + return webhookUrl?.startsWith('https://') === true; +} export class CalendlyTrigger implements INodeType { description: INodeTypeDescription = { @@ -66,31 +92,18 @@ export class CalendlyTrigger implements INodeType { value: 'oAuth2', }, { - name: 'API Key or Personal Access Token', + name: 'Personal Access Token', value: 'apiKey', }, ], default: 'apiKey', }, - { - displayName: - 'Action required: Calendly will discontinue API Key authentication on May 31, 2025. Update node to use OAuth2 authentication now to ensure your workflows continue to work.', - name: 'deprecationNotice', - type: 'notice', - default: '', - displayOptions: { - show: { - authentication: ['apiKey'], - }, - }, - }, { displayName: 'Scope', name: 'scope', type: 'options', default: 'user', required: true, - hint: 'Ignored if you are using an API Key', options: [ { name: 'Organization', @@ -134,56 +147,49 @@ export class CalendlyTrigger implements INodeType { const webhookData = this.getWorkflowStaticData('node'); const events = this.getNodeParameter('events') as string[]; - const authenticationType = await getAuthenticationType.call(this); + const scope = this.getNodeParameter('scope', 0) as string; + const userResponse = await calendlyApiRequest.call(this, 'GET', '/users/me'); + const user = isDataObject(userResponse.resource) ? userResponse.resource : undefined; + const organization = getStringField(user, 'current_organization'); + const userUri = getStringField(user, 'uri'); - // remove condition once API Keys are deprecated - if (authenticationType === 'apiKey') { - const endpoint = '/hooks'; - const { data } = await calendlyApiRequest.call(this, 'GET', endpoint, {}); + if (organization === undefined || (scope === 'user' && userUri === undefined)) return false; - for (const webhook of data) { - if (webhook.attributes.url === webhookUrl) { - for (const event of events) { - if (!webhook.attributes.events.includes(event)) { - return false; - } - } - } - // Set webhook-id to be sure that it can be deleted - webhookData.webhookId = webhook.id as string; - return true; - } + const qs: IDataObject = {}; + + if (scope === 'user') { + qs.scope = 'user'; + qs.organization = organization; + qs.user = userUri; } - if (authenticationType === 'accessToken') { - const scope = this.getNodeParameter('scope', 0) as string; - const { resource } = await calendlyApiRequest.call(this, 'GET', '/users/me'); + if (scope === 'organization') { + qs.scope = 'organization'; + qs.organization = organization; + } - const qs: IDataObject = {}; + const endpoint = '/webhook_subscriptions'; + const subscriptionsResponse = await calendlyApiRequest.call(this, 'GET', endpoint, {}, qs); + const collection = Array.isArray(subscriptionsResponse.collection) + ? subscriptionsResponse.collection + : []; - if (scope === 'user') { - qs.scope = 'user'; - qs.organization = resource.current_organization; - qs.user = resource.uri; - } + for (const webhook of collection) { + if (!isDataObject(webhook)) continue; - if (scope === 'organization') { - qs.scope = 'organization'; - qs.organization = resource.current_organization; - } + const callbackUrl = getStringField(webhook, 'callback_url'); + const webhookEvents = getStringArrayField(webhook, 'events'); + const webhookUri = getStringField(webhook, 'uri'); - const endpoint = '/webhook_subscriptions'; - const { collection } = await calendlyApiRequest.call(this, 'GET', endpoint, {}, qs); - - for (const webhook of collection) { - if ( - webhook.callback_url === webhookUrl && - events.length === webhook.events.length && - events.every((event: string) => webhook.events.includes(event)) - ) { - webhookData.webhookURI = webhook.uri; - return true; - } + if ( + callbackUrl === webhookUrl && + webhookUri !== undefined && + webhookEvents !== undefined && + events.length === webhookEvents.length && + events.every((event) => webhookEvents.includes(event)) + ) { + webhookData.webhookURI = webhookUri; + return true; } } @@ -194,98 +200,67 @@ export class CalendlyTrigger implements INodeType { const webhookUrl = this.getNodeWebhookUrl('default'); const events = this.getNodeParameter('events') as string[]; - const authenticationType = await getAuthenticationType.call(this); - - // remove condition once API Keys are deprecated - if (authenticationType === 'apiKey') { - const endpoint = '/hooks'; - - const body = { - url: webhookUrl, - events, - }; - - const responseData = await calendlyApiRequest.call(this, 'POST', endpoint, body); - - if (responseData.id === undefined) { - // Required data is missing so was not successful - return false; - } - - webhookData.webhookId = responseData.id as string; + if (!isValidCalendlyWebhookUrl(webhookUrl)) { + throw new NodeOperationError( + this.getNode(), + 'Calendly requires a public HTTPS webhook URL', + { + description: + 'Set the n8n webhook URL to a public HTTPS address, or use a tunnel while testing locally.', + }, + ); } - if (authenticationType === 'accessToken') { - const scope = this.getNodeParameter('scope', 0) as string; - const { resource } = await calendlyApiRequest.call(this, 'GET', '/users/me'); + const scope = this.getNodeParameter('scope', 0) as string; + const userResponse = await calendlyApiRequest.call(this, 'GET', '/users/me'); + const user = isDataObject(userResponse.resource) ? userResponse.resource : undefined; + const organization = getStringField(user, 'current_organization'); + const userUri = getStringField(user, 'uri'); - const webhookSecret = randomBytes(32).toString('hex'); + if (organization === undefined || (scope === 'user' && userUri === undefined)) return false; - const body: IDataObject = { - url: webhookUrl, - events, - organization: resource.current_organization, - scope, - signing_key: webhookSecret, - }; + const webhookSecret = randomBytes(32).toString('hex'); + const body: IDataObject = { + url: webhookUrl, + events, + organization, + scope, + signing_key: webhookSecret, + }; - if (scope === 'user') { - body.user = resource.uri; - } - - const endpoint = '/webhook_subscriptions'; - const responseData = await calendlyApiRequest.call(this, 'POST', endpoint, body); - - if (responseData?.resource === undefined || responseData?.resource?.uri === undefined) { - return false; - } - - webhookData.webhookURI = responseData.resource.uri; - webhookData.webhookSecret = webhookSecret; + if (scope === 'user') { + body.user = userUri; } + const endpoint = '/webhook_subscriptions'; + const responseData = await calendlyApiRequest.call(this, 'POST', endpoint, body); + const subscription = isDataObject(responseData.resource) + ? responseData.resource + : undefined; + const subscriptionUri = getStringField(subscription, 'uri'); + + if (subscriptionUri === undefined) return false; + + webhookData.webhookURI = subscriptionUri; + webhookData.webhookSecret = webhookSecret; + return true; }, async delete(this: IHookFunctions): Promise { const webhookData = this.getWorkflowStaticData('node'); - const authenticationType = await getAuthenticationType.call(this); + const { webhookURI } = webhookData; - // remove condition once API Keys are deprecated - if (authenticationType === 'apiKey') { - if (webhookData.webhookId !== undefined) { - const endpoint = `/hooks/${webhookData.webhookId}`; - - try { - await calendlyApiRequest.call(this, 'DELETE', endpoint); - } catch (error) { - return false; - } - - // Remove from the static workflow data so that it is clear - // that no webhooks are registered anymore - delete webhookData.webhookId; + if (typeof webhookURI === 'string') { + try { + await calendlyApiRequest.call(this, 'DELETE', '', {}, {}, webhookURI); + } catch (error) { + return false; } - } - if (authenticationType === 'accessToken') { - if (webhookData.webhookURI !== undefined) { - try { - await calendlyApiRequest.call( - this, - 'DELETE', - '', - {}, - {}, - webhookData.webhookURI as string, - ); - } catch (error) { - return false; - } - - delete webhookData.webhookURI; - delete webhookData.webhookSecret; - } + delete webhookData.webhookURI; + delete webhookData.webhookSecret; } + delete webhookData.webhookId; return true; }, diff --git a/packages/nodes-base/nodes/Calendly/GenericFunctions.ts b/packages/nodes-base/nodes/Calendly/GenericFunctions.ts index 70cf55c1f23..be9cf2da8eb 100644 --- a/packages/nodes-base/nodes/Calendly/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Calendly/GenericFunctions.ts @@ -8,22 +8,11 @@ import type { IRequestOptions, } from 'n8n-workflow'; -function getAuthenticationTypeFromApiKey(data: string): 'accessToken' | 'apiKey' { - // The access token is a JWT, so it will always include dots to separate - // header, payoload and signature. - return data.includes('.') ? 'accessToken' : 'apiKey'; -} - -export async function getAuthenticationType( +export function getCredentialsType( this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, -): Promise<'accessToken' | 'apiKey'> { +): 'calendlyApi' | 'calendlyOAuth2Api' { const authentication = this.getNodeParameter('authentication', 0) as string; - if (authentication === 'apiKey') { - const { apiKey } = await this.getCredentials<{ apiKey: string }>('calendlyApi'); - return getAuthenticationTypeFromApiKey(apiKey); - } else { - return 'accessToken'; - } + return authentication === 'apiKey' ? 'calendlyApi' : 'calendlyOAuth2Api'; } export async function calendlyApiRequest( @@ -31,23 +20,16 @@ export async function calendlyApiRequest( method: IHttpRequestMethods, resource: string, - body: any = {}, + body: IDataObject = {}, query: IDataObject = {}, uri?: string, option: IDataObject = {}, -): Promise { - const authenticationType = await getAuthenticationType.call(this); - +): Promise { const headers: IDataObject = { 'Content-Type': 'application/json', }; - let endpoint = 'https://api.calendly.com'; - - // remove once API key is deprecated - if (authenticationType === 'apiKey') { - endpoint = 'https://calendly.com/api/v1'; - } + const endpoint = 'https://api.calendly.com'; let options: IRequestOptions = { headers, @@ -58,17 +40,14 @@ export async function calendlyApiRequest( json: true, }; - if (!Object.keys(body as IDataObject).length) { - delete options.form; + if (!Object.keys(body).length) { + delete options.body; } if (!Object.keys(query).length) { delete options.qs; } options = Object.assign({}, options, option); - const credentialsType = - (this.getNodeParameter('authentication', 0) as string) === 'apiKey' - ? 'calendlyApi' - : 'calendlyOAuth2Api'; + const credentialsType = getCredentialsType.call(this); return await this.helpers.requestWithAuthentication.call(this, credentialsType, options); } diff --git a/packages/nodes-base/nodes/Calendly/test/CalendlyTrigger.node.test.ts b/packages/nodes-base/nodes/Calendly/test/CalendlyTrigger.node.test.ts index cecebd4b692..f613cd77ae1 100644 --- a/packages/nodes-base/nodes/Calendly/test/CalendlyTrigger.node.test.ts +++ b/packages/nodes-base/nodes/Calendly/test/CalendlyTrigger.node.test.ts @@ -1,11 +1,10 @@ import { randomBytes } from 'crypto'; -import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow'; + +import type { IHookFunctions, IDataObject, IWebhookFunctions } from 'n8n-workflow'; import { CalendlyTrigger } from '../CalendlyTrigger.node'; import { verifySignature } from '../CalendlyTriggerHelpers'; -import { calendlyApiRequest, getAuthenticationType } from '../GenericFunctions'; -jest.mock('../GenericFunctions'); jest.mock('../CalendlyTriggerHelpers'); jest.mock('crypto', () => ({ ...jest.requireActual('crypto'), @@ -13,49 +12,231 @@ jest.mock('crypto', () => ({ })); describe('CalendlyTrigger', () => { + const webhookUrl = 'https://example.com/webhook/calendly'; + const organizationUri = 'https://api.calendly.com/organizations/ORGANIZATION_ID'; + const userUri = 'https://api.calendly.com/users/USER_ID'; + const webhookUri = 'https://api.calendly.com/webhook_subscriptions/WEBHOOK_ID'; + const webhookSecret = 'a'.repeat(64); + let trigger: CalendlyTrigger; + let requestWithAuthentication: jest.Mock; + let webhookData: IDataObject; + let mockHookFunctions: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); + trigger = new CalendlyTrigger(); + requestWithAuthentication = jest.fn(); + webhookData = {}; + + (randomBytes as jest.Mock).mockReturnValue({ + toString: jest.fn().mockReturnValue(webhookSecret), + }); + + mockHookFunctions = { + getNode: jest.fn().mockReturnValue({ name: 'Calendly Trigger', type: 'calendlyTrigger' }), + getNodeWebhookUrl: jest.fn().mockReturnValue(webhookUrl), + getNodeParameter: jest.fn((name: string) => { + if (name === 'authentication') return 'apiKey'; + if (name === 'events') return ['invitee.created']; + if (name === 'scope') return 'user'; + return undefined; + }), + getWorkflowStaticData: jest.fn().mockReturnValue(webhookData), + helpers: { + requestWithAuthentication, + }, + } as unknown as jest.Mocked; + + requestWithAuthentication.mockImplementation(async (_credentialsType, requestOptions) => { + if (requestOptions.uri === 'https://api.calendly.com/users/me') { + return { + resource: { + uri: userUri, + current_organization: organizationUri, + }, + }; + } + + if ( + requestOptions.method === 'POST' && + requestOptions.uri === 'https://api.calendly.com/webhook_subscriptions' + ) { + return { + resource: { + uri: webhookUri, + }, + }; + } + + if ( + requestOptions.method === 'GET' && + requestOptions.uri === 'https://api.calendly.com/webhook_subscriptions' + ) { + return { collection: [] }; + } + + return {}; + }); }); describe('webhookMethods.default.create', () => { - it('should generate a signing key and send it to Calendly via v2 API', async () => { - const webhookSecret = 'a'.repeat(64); - const userResource = { - uri: 'https://api.calendly.com/users/USER', - current_organization: 'https://api.calendly.com/organizations/ORG', - }; + it('should create a user-scoped webhook subscription', async () => { + const result = await trigger.webhookMethods.default.create.call(mockHookFunctions); - (getAuthenticationType as jest.Mock).mockResolvedValue('accessToken'); - (randomBytes as jest.Mock).mockReturnValue({ - toString: jest.fn().mockReturnValue(webhookSecret), - }); - (calendlyApiRequest as jest.Mock) - .mockResolvedValueOnce({ resource: userResource }) - .mockResolvedValueOnce({ - resource: { uri: 'https://api.calendly.com/webhook_subscriptions/SUB' }, - }); - - const webhookData: Record = {}; - const mockHook = { - getNodeWebhookUrl: jest.fn().mockReturnValue('https://example.com/webhook'), - getNodeParameter: jest.fn().mockImplementation((name: string) => { - if (name === 'events') return ['invitee.created']; - if (name === 'scope') return 'user'; - }), - getWorkflowStaticData: jest.fn().mockReturnValue(webhookData), - } as unknown as IHookFunctions; - - await trigger.webhookMethods!.default.create.call(mockHook); - - expect(calendlyApiRequest).toHaveBeenLastCalledWith( - 'POST', - '/webhook_subscriptions', - expect.objectContaining({ signing_key: webhookSecret }), - ); + expect(result).toBe(true); + expect(webhookData.webhookURI).toBe(webhookUri); expect(webhookData.webhookSecret).toBe(webhookSecret); + expect(requestWithAuthentication).toHaveBeenLastCalledWith( + 'calendlyApi', + expect.objectContaining({ + method: 'POST', + uri: 'https://api.calendly.com/webhook_subscriptions', + body: { + url: webhookUrl, + events: ['invitee.created'], + organization: organizationUri, + scope: 'user', + signing_key: webhookSecret, + user: userUri, + }, + }), + ); + }); + + it('should create an organization-scoped webhook subscription', async () => { + mockHookFunctions.getNodeParameter.mockImplementation((name: string) => { + if (name === 'authentication') return 'apiKey'; + if (name === 'events') return ['invitee.created', 'invitee.canceled']; + if (name === 'scope') return 'organization'; + return undefined; + }); + + const result = await trigger.webhookMethods.default.create.call(mockHookFunctions); + + expect(result).toBe(true); + expect(webhookData.webhookSecret).toBe(webhookSecret); + expect(requestWithAuthentication).toHaveBeenLastCalledWith( + 'calendlyApi', + expect.objectContaining({ + method: 'POST', + uri: 'https://api.calendly.com/webhook_subscriptions', + body: { + url: webhookUrl, + events: ['invitee.created', 'invitee.canceled'], + organization: organizationUri, + scope: 'organization', + signing_key: webhookSecret, + }, + }), + ); + }); + + it('should reject non-HTTPS webhook URLs before calling Calendly', async () => { + mockHookFunctions.getNodeWebhookUrl.mockReturnValue('http://localhost:5678/webhook/test'); + + await expect(trigger.webhookMethods.default.create.call(mockHookFunctions)).rejects.toThrow( + 'Calendly requires a public HTTPS webhook URL', + ); + expect(requestWithAuthentication).not.toHaveBeenCalled(); + }); + }); + + describe('webhookMethods.default.checkExists', () => { + it('should return true when matching webhook subscription exists', async () => { + requestWithAuthentication.mockImplementation(async (_credentialsType, requestOptions) => { + if (requestOptions.uri === 'https://api.calendly.com/users/me') { + return { + resource: { + uri: userUri, + current_organization: organizationUri, + }, + }; + } + + return { + collection: [ + { + callback_url: webhookUrl, + events: ['invitee.created'], + uri: webhookUri, + }, + ], + }; + }); + + const result = await trigger.webhookMethods.default.checkExists.call(mockHookFunctions); + + expect(result).toBe(true); + expect(webhookData.webhookURI).toBe(webhookUri); + expect(requestWithAuthentication).toHaveBeenLastCalledWith( + 'calendlyApi', + expect.objectContaining({ + method: 'GET', + uri: 'https://api.calendly.com/webhook_subscriptions', + qs: { + scope: 'user', + organization: organizationUri, + user: userUri, + }, + }), + ); + }); + + it('should return false when webhook subscription events do not match', async () => { + requestWithAuthentication.mockImplementation(async (_credentialsType, requestOptions) => { + if (requestOptions.uri === 'https://api.calendly.com/users/me') { + return { + resource: { + uri: userUri, + current_organization: organizationUri, + }, + }; + } + + return { + collection: [ + { + callback_url: webhookUrl, + events: ['invitee.canceled'], + uri: webhookUri, + }, + ], + }; + }); + + const result = await trigger.webhookMethods.default.checkExists.call(mockHookFunctions); + + expect(result).toBe(false); + expect(webhookData.webhookURI).toBeUndefined(); + }); + }); + + describe('webhookMethods.default.delete', () => { + it('should delete webhook subscription by stored URI', async () => { + webhookData.webhookURI = webhookUri; + webhookData.webhookSecret = webhookSecret; + + const result = await trigger.webhookMethods.default.delete.call(mockHookFunctions); + + expect(result).toBe(true); + expect(requestWithAuthentication).toHaveBeenCalledWith( + 'calendlyApi', + expect.objectContaining({ + method: 'DELETE', + uri: webhookUri, + }), + ); + expect(webhookData.webhookURI).toBeUndefined(); + expect(webhookData.webhookSecret).toBeUndefined(); + }); + + it('should return true when no webhook subscription is stored', async () => { + const result = await trigger.webhookMethods.default.delete.call(mockHookFunctions); + + expect(result).toBe(true); + expect(requestWithAuthentication).not.toHaveBeenCalled(); }); }); diff --git a/packages/nodes-base/nodes/Calendly/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Calendly/test/GenericFunctions.test.ts new file mode 100644 index 00000000000..0067af47dd6 --- /dev/null +++ b/packages/nodes-base/nodes/Calendly/test/GenericFunctions.test.ts @@ -0,0 +1,67 @@ +import type { IHookFunctions } from 'n8n-workflow'; + +import { calendlyApiRequest } from '../GenericFunctions'; + +describe('Calendly GenericFunctions', () => { + const requestWithAuthentication = jest.fn(); + + const mockHookFunctions = { + getNodeParameter: jest.fn(), + helpers: { + requestWithAuthentication, + }, + } as unknown as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + requestWithAuthentication.mockResolvedValue({}); + mockHookFunctions.getNodeParameter.mockReturnValue('apiKey'); + }); + + it('should use personal access token credentials for PAT authentication', async () => { + await calendlyApiRequest.call(mockHookFunctions, 'GET', '/users/me'); + + expect(requestWithAuthentication).toHaveBeenCalledWith( + 'calendlyApi', + expect.objectContaining({ + method: 'GET', + uri: 'https://api.calendly.com/users/me', + }), + ); + }); + + it('should use OAuth2 credentials for OAuth2 authentication', async () => { + mockHookFunctions.getNodeParameter.mockReturnValue('oAuth2'); + + await calendlyApiRequest.call(mockHookFunctions, 'GET', '/users/me'); + + expect(requestWithAuthentication).toHaveBeenCalledWith( + 'calendlyOAuth2Api', + expect.objectContaining({ + method: 'GET', + uri: 'https://api.calendly.com/users/me', + }), + ); + }); + + it('should send requests to Calendly API v2', async () => { + await calendlyApiRequest.call(mockHookFunctions, 'POST', '/webhook_subscriptions', { + events: ['invitee.created'], + }); + + expect(requestWithAuthentication).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + uri: 'https://api.calendly.com/webhook_subscriptions', + }), + ); + }); + + it('should omit empty body and query string parameters', async () => { + await calendlyApiRequest.call(mockHookFunctions, 'GET', '/users/me'); + + const requestOptions = requestWithAuthentication.mock.calls[0][1]; + expect(requestOptions).not.toHaveProperty('body'); + expect(requestOptions).not.toHaveProperty('qs'); + }); +});