fix(Calendly Trigger Node): Use API v2 for webhook subscriptions (#29771)

This commit is contained in:
Michael Kret 2026-05-07 14:29:34 +03:00 committed by GitHub
parent a316742c92
commit 0edcdcfe85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 455 additions and 223 deletions

View File

@ -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<IHttpRequestOptions> {
//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',
},
};
}

View File

@ -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');
});
});

View File

@ -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<boolean> {
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;
},

View File

@ -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<any> {
const authenticationType = await getAuthenticationType.call(this);
): Promise<IDataObject> {
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);
}

View File

@ -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<IHookFunctions>;
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<IHookFunctions>;
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<string, unknown> = {};
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();
});
});

View File

@ -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<IHookFunctions>;
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');
});
});