first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
import crypto from 'crypto';
|
||||
import type * as express from 'express';
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import type { IDataObject, IHookFunctions, INode, IWebhookFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import * as GenericFunctions from '../GenericFunctions';
|
||||
import type { WhatsAppAppWebhookSubscription, WhatsAppPageEvent } from '../types';
|
||||
import { WhatsAppTrigger, filterStatuses } from '../WhatsAppTrigger.node';
|
||||
|
||||
describe('WhatsAppTrigger', () => {
|
||||
let node: WhatsAppTrigger;
|
||||
let mockHookFunctions: jest.Mocked<IHookFunctions>;
|
||||
let mockWebhookFunctions: jest.Mocked<IWebhookFunctions>;
|
||||
let mockNode: jest.Mocked<INode>;
|
||||
|
||||
const appWebhookSubscriptionListSpy = jest.spyOn(GenericFunctions, 'appWebhookSubscriptionList');
|
||||
const appWebhookSubscriptionCreateSpy = jest.spyOn(
|
||||
GenericFunctions,
|
||||
'appWebhookSubscriptionCreate',
|
||||
);
|
||||
const appWebhookSubscriptionDeleteSpy = jest.spyOn(
|
||||
GenericFunctions,
|
||||
'appWebhookSubscriptionDelete',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
node = new WhatsAppTrigger();
|
||||
mockHookFunctions = mockDeep<IHookFunctions>();
|
||||
mockWebhookFunctions = mockDeep<IWebhookFunctions>();
|
||||
mockNode = mock<INode>({
|
||||
id: 'test-node-id',
|
||||
name: 'WhatsApp Trigger',
|
||||
type: 'n8n-nodes-base.whatsAppTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('filterStatuses', () => {
|
||||
const mockEvents = [
|
||||
{ statuses: [{ status: 'sent' }] },
|
||||
{ statuses: [{ status: 'delivered' }] },
|
||||
{ statuses: [{ status: 'read' }] },
|
||||
{ statuses: [{ status: 'failed' }] },
|
||||
{},
|
||||
];
|
||||
|
||||
it('should return all events when allowedStatuses is undefined', () => {
|
||||
const result = filterStatuses(mockEvents, undefined);
|
||||
expect(result).toEqual(mockEvents);
|
||||
});
|
||||
|
||||
it('should return all events when allowedStatuses includes "all"', () => {
|
||||
const result = filterStatuses(mockEvents, ['all']);
|
||||
expect(result).toEqual(mockEvents);
|
||||
});
|
||||
|
||||
it('should filter events with no statuses when allowedStatuses is empty', () => {
|
||||
const result = filterStatuses(mockEvents, []);
|
||||
expect(result).toEqual([{}]);
|
||||
});
|
||||
|
||||
it('should filter events by specific statuses', () => {
|
||||
const result = filterStatuses(mockEvents, ['sent', 'delivered']);
|
||||
expect(result).toEqual([
|
||||
{ statuses: [{ status: 'sent' }] },
|
||||
{ statuses: [{ status: 'delivered' }] },
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle events with multiple statuses', () => {
|
||||
const eventsWithMultipleStatuses = [
|
||||
{ statuses: [{ status: 'sent' }, { status: 'delivered' }] },
|
||||
{ statuses: [{ status: 'read' }] },
|
||||
];
|
||||
const result = filterStatuses(eventsWithMultipleStatuses, ['sent']);
|
||||
expect(result).toEqual([{ statuses: [{ status: 'sent' }, { status: 'delivered' }] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook Methods', () => {
|
||||
describe('checkExists', () => {
|
||||
beforeEach(() => {
|
||||
mockHookFunctions.getNodeWebhookUrl.mockReturnValue('https://test.com/webhook');
|
||||
mockHookFunctions.getCredentials.mockResolvedValue({
|
||||
clientId: 'test-app-id',
|
||||
clientSecret: 'test-secret',
|
||||
});
|
||||
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'updates') {
|
||||
return ['messages', 'account_update'] as string[];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
mockHookFunctions.getNode.mockReturnValue(mockNode);
|
||||
});
|
||||
|
||||
it('should return false when no subscription exists', async () => {
|
||||
appWebhookSubscriptionListSpy.mockResolvedValue([]);
|
||||
|
||||
const result = await node.webhookMethods.default.checkExists.call(mockHookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(appWebhookSubscriptionListSpy).toHaveBeenCalledWith('test-app-id');
|
||||
});
|
||||
|
||||
it('should return true when matching subscription exists', async () => {
|
||||
const mockSubscription: WhatsAppAppWebhookSubscription = {
|
||||
object: 'whatsapp_business_account',
|
||||
callback_url: 'https://test.com/webhook',
|
||||
active: true,
|
||||
fields: [
|
||||
{ name: 'account_update', version: '1.0' },
|
||||
{ name: 'messages', version: '1.0' },
|
||||
],
|
||||
};
|
||||
|
||||
appWebhookSubscriptionListSpy.mockResolvedValue([mockSubscription]);
|
||||
|
||||
const result = await node.webhookMethods.default.checkExists.call(mockHookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error when subscription exists with different callback URL', async () => {
|
||||
const mockSubscription: WhatsAppAppWebhookSubscription = {
|
||||
object: 'whatsapp_business_account',
|
||||
callback_url: 'https://different.com/webhook',
|
||||
active: true,
|
||||
fields: [
|
||||
{ name: 'account_update', version: '1.0' },
|
||||
{ name: 'messages', version: '1.0' },
|
||||
],
|
||||
};
|
||||
|
||||
appWebhookSubscriptionListSpy.mockResolvedValue([mockSubscription]);
|
||||
|
||||
await expect(
|
||||
node.webhookMethods.default.checkExists.call(mockHookFunctions),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should return false when subscription fields do not match subscribed events', async () => {
|
||||
const mockSubscription: WhatsAppAppWebhookSubscription = {
|
||||
object: 'whatsapp_business_account',
|
||||
callback_url: 'https://test.com/webhook',
|
||||
active: true,
|
||||
fields: [{ name: 'different_field', version: '1.0' }],
|
||||
};
|
||||
|
||||
appWebhookSubscriptionListSpy.mockResolvedValue([mockSubscription]);
|
||||
|
||||
const result = await node.webhookMethods.default.checkExists.call(mockHookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(appWebhookSubscriptionDeleteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
beforeEach(() => {
|
||||
mockHookFunctions.getNodeWebhookUrl.mockReturnValue('https://test.com/webhook');
|
||||
mockHookFunctions.getCredentials.mockResolvedValue({
|
||||
clientId: 'test-app-id',
|
||||
clientSecret: 'test-secret',
|
||||
});
|
||||
mockHookFunctions.getNodeParameter.mockReturnValue(['messages', 'account_update']);
|
||||
mockHookFunctions.getNode.mockReturnValue(mockNode);
|
||||
});
|
||||
|
||||
it('should create webhook subscription successfully', async () => {
|
||||
appWebhookSubscriptionCreateSpy.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await node.webhookMethods.default.create.call(mockHookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(appWebhookSubscriptionCreateSpy).toHaveBeenCalledWith('test-app-id', {
|
||||
object: 'whatsapp_business_account',
|
||||
callback_url: 'https://test.com/webhook',
|
||||
verify_token: 'test-node-id',
|
||||
fields: JSON.stringify(['messages', 'account_update']),
|
||||
include_values: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
beforeEach(() => {
|
||||
mockHookFunctions.getCredentials.mockResolvedValue({
|
||||
clientId: 'test-app-id',
|
||||
clientSecret: 'test-secret',
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete webhook subscription successfully', async () => {
|
||||
appWebhookSubscriptionDeleteSpy.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await node.webhookMethods.default.delete.call(mockHookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(appWebhookSubscriptionDeleteSpy).toHaveBeenCalledWith(
|
||||
'test-app-id',
|
||||
'whatsapp_business_account',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook', () => {
|
||||
beforeEach(() => {
|
||||
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||
clientId: 'test-app-id',
|
||||
clientSecret: 'test-secret',
|
||||
});
|
||||
mockWebhookFunctions.getNode.mockReturnValue(mockNode);
|
||||
mockWebhookFunctions.getNodeParameter.mockReturnValue({});
|
||||
});
|
||||
|
||||
describe('setup webhook (GET)', () => {
|
||||
it('should handle challenge verification successfully', async () => {
|
||||
const mockRequest = {
|
||||
rawBody: Buffer.from('test'),
|
||||
};
|
||||
const mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
end: jest.fn(),
|
||||
} as unknown as express.Response;
|
||||
|
||||
mockWebhookFunctions.getWebhookName.mockReturnValue('setup');
|
||||
mockWebhookFunctions.getQueryData.mockReturnValue({
|
||||
'hub.challenge': 'test-challenge',
|
||||
'hub.verify_token': 'test-node-id',
|
||||
} as any);
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
|
||||
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(200);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith('test-challenge');
|
||||
expect(mockResponse.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty object when verify token does not match', async () => {
|
||||
mockWebhookFunctions.getWebhookName.mockReturnValue('setup');
|
||||
mockWebhookFunctions.getQueryData.mockReturnValue({
|
||||
'hub.challenge': 'test-challenge',
|
||||
'hub.verify_token': 'wrong-token',
|
||||
} as any);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when no challenge is provided', async () => {
|
||||
const mockRequest = {
|
||||
rawBody: Buffer.from('test'),
|
||||
};
|
||||
const mockBodyData = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [],
|
||||
};
|
||||
|
||||
mockWebhookFunctions.getWebhookName.mockReturnValue('setup');
|
||||
mockWebhookFunctions.getQueryData.mockReturnValue({});
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
|
||||
mockWebhookFunctions.getBodyData.mockReturnValue(mockBodyData as any);
|
||||
mockWebhookFunctions.getHeaderData.mockReturnValue({
|
||||
'x-hub-signature-256': 'sha256=test-signature',
|
||||
} as any);
|
||||
|
||||
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
|
||||
const mockHmac = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
digest: jest.fn().mockReturnValue('test-signature'),
|
||||
};
|
||||
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('default webhook (POST)', () => {
|
||||
beforeEach(() => {
|
||||
mockWebhookFunctions.getWebhookName.mockReturnValue('default');
|
||||
});
|
||||
|
||||
it('should process valid webhook data successfully', async () => {
|
||||
const mockRequest = {
|
||||
rawBody: Buffer.from('test-body'),
|
||||
};
|
||||
const mockBodyData: WhatsAppPageEvent = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [
|
||||
{
|
||||
id: 'entry-1',
|
||||
time: 1234567890,
|
||||
changes: [
|
||||
{
|
||||
field: 'messages',
|
||||
value: {
|
||||
statuses: [{ status: 'sent' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
|
||||
mockWebhookFunctions.getBodyData.mockReturnValue(mockBodyData as any);
|
||||
mockWebhookFunctions.getHeaderData.mockReturnValue({
|
||||
'x-hub-signature-256': 'sha256=test-signature',
|
||||
} as any);
|
||||
mockWebhookFunctions.getNodeParameter.mockReturnValue({});
|
||||
|
||||
// Mock crypto.createHmac to return predictable signature
|
||||
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
|
||||
const mockHmac = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
digest: jest.fn().mockReturnValue('test-signature'),
|
||||
};
|
||||
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
|
||||
|
||||
// Mock helpers.returnJsonArray
|
||||
(mockWebhookFunctions.helpers.returnJsonArray as jest.Mock).mockReturnValue([
|
||||
{ json: { statuses: [{ status: 'sent' }], field: 'messages' } },
|
||||
]);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result.workflowData).toBeDefined();
|
||||
expect(result.workflowData?.[0]).toHaveLength(1);
|
||||
expect(result.workflowData?.[0]?.[0].json).toEqual({
|
||||
statuses: [{ status: 'sent' }],
|
||||
field: 'messages',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty object when signature verification fails', async () => {
|
||||
const mockRequest = {
|
||||
rawBody: Buffer.from('test-body'),
|
||||
};
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
|
||||
mockWebhookFunctions.getHeaderData.mockReturnValue({
|
||||
'x-hub-signature-256': 'sha256=wrong-signature',
|
||||
});
|
||||
|
||||
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
|
||||
const mockHmac = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
digest: jest.fn().mockReturnValue('correct-signature'),
|
||||
};
|
||||
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when object is not whatsapp_business_account', async () => {
|
||||
const mockRequest = {
|
||||
rawBody: Buffer.from('test-body'),
|
||||
};
|
||||
const mockBodyData = {
|
||||
object: 'different_object',
|
||||
entry: [],
|
||||
};
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
|
||||
mockWebhookFunctions.getBodyData.mockReturnValue(mockBodyData as any);
|
||||
mockWebhookFunctions.getHeaderData.mockReturnValue({
|
||||
'x-hub-signature-256': 'sha256=test-signature',
|
||||
} as any);
|
||||
|
||||
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
|
||||
const mockHmac = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
digest: jest.fn().mockReturnValue('test-signature'),
|
||||
};
|
||||
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should filter events based on messageStatusUpdates option', async () => {
|
||||
const mockRequest = {
|
||||
rawBody: Buffer.from('test-body'),
|
||||
};
|
||||
const mockBodyData: WhatsAppPageEvent = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [
|
||||
{
|
||||
id: 'entry-1',
|
||||
time: 1234567890,
|
||||
changes: [
|
||||
{
|
||||
field: 'messages',
|
||||
value: {
|
||||
statuses: [{ status: 'sent' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'messages',
|
||||
value: {
|
||||
statuses: [{ status: 'delivered' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'messages',
|
||||
value: {
|
||||
statuses: [{ status: 'read' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
|
||||
mockWebhookFunctions.getBodyData.mockReturnValue(mockBodyData as any);
|
||||
mockWebhookFunctions.getHeaderData.mockReturnValue({
|
||||
'x-hub-signature-256': 'sha256=test-signature',
|
||||
} as any);
|
||||
mockWebhookFunctions.getNodeParameter.mockReturnValue({
|
||||
messageStatusUpdates: ['sent', 'delivered'],
|
||||
});
|
||||
|
||||
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
|
||||
const mockHmac = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
digest: jest.fn().mockReturnValue('test-signature'),
|
||||
};
|
||||
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
|
||||
|
||||
// Mock helpers.returnJsonArray for filtered results
|
||||
(mockWebhookFunctions.helpers.returnJsonArray as jest.Mock).mockReturnValue([
|
||||
{ json: { statuses: [{ status: 'sent' }], field: 'messages' } },
|
||||
{ json: { statuses: [{ status: 'delivered' }], field: 'messages' } },
|
||||
]);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result.workflowData?.[0]).toHaveLength(2);
|
||||
expect((result.workflowData?.[0]?.[0]?.json?.statuses as IDataObject[])?.[0]?.status).toBe(
|
||||
'sent',
|
||||
);
|
||||
expect((result.workflowData?.[0]?.[1]?.json?.statuses as IDataObject[])?.[0]?.status).toBe(
|
||||
'delivered',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty object when no events match filters', async () => {
|
||||
const mockRequest = {
|
||||
rawBody: Buffer.from('test-body'),
|
||||
};
|
||||
const mockBodyData: WhatsAppPageEvent = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [
|
||||
{
|
||||
id: 'entry-1',
|
||||
time: 1234567890,
|
||||
changes: [
|
||||
{
|
||||
field: 'messages',
|
||||
value: {
|
||||
statuses: [{ status: 'read' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
|
||||
mockWebhookFunctions.getBodyData.mockReturnValue(mockBodyData as any);
|
||||
mockWebhookFunctions.getHeaderData.mockReturnValue({
|
||||
'x-hub-signature-256': 'sha256=test-signature',
|
||||
} as any);
|
||||
mockWebhookFunctions.getNodeParameter.mockReturnValue({
|
||||
messageStatusUpdates: ['sent', 'delivered'],
|
||||
});
|
||||
|
||||
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
|
||||
const mockHmac = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
digest: jest.fn().mockReturnValue('test-signature'),
|
||||
};
|
||||
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle events without statuses when filtering', async () => {
|
||||
const mockRequest = {
|
||||
rawBody: Buffer.from('test-body'),
|
||||
};
|
||||
const mockBodyData: WhatsAppPageEvent = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [
|
||||
{
|
||||
id: 'entry-1',
|
||||
time: 1234567890,
|
||||
changes: [
|
||||
{
|
||||
field: 'messages',
|
||||
value: {
|
||||
// No statuses property
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest as any);
|
||||
mockWebhookFunctions.getBodyData.mockReturnValue(mockBodyData as any);
|
||||
mockWebhookFunctions.getHeaderData.mockReturnValue({
|
||||
'x-hub-signature-256': 'sha256=test-signature',
|
||||
} as any);
|
||||
mockWebhookFunctions.getNodeParameter.mockReturnValue({
|
||||
messageStatusUpdates: ['sent'],
|
||||
});
|
||||
|
||||
const createHmacSpy = jest.spyOn(crypto, 'createHmac');
|
||||
const mockHmac = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
digest: jest.fn().mockReturnValue('test-signature'),
|
||||
};
|
||||
createHmacSpy.mockReturnValue(mockHmac as unknown as ReturnType<typeof crypto.createHmac>);
|
||||
|
||||
// Mock helpers.returnJsonArray for events without statuses
|
||||
(mockWebhookFunctions.helpers.returnJsonArray as jest.Mock).mockReturnValue([
|
||||
{ json: { field: 'messages' } },
|
||||
]);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result.workflowData?.[0]).toHaveLength(1);
|
||||
expect(result.workflowData?.[0]?.[0].json).toEqual({
|
||||
field: 'messages',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle API errors in webhook methods', async () => {
|
||||
mockHookFunctions.getCredentials.mockResolvedValue({
|
||||
clientId: 'test-app-id',
|
||||
clientSecret: 'test-secret',
|
||||
});
|
||||
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
if (paramName === 'updates') {
|
||||
return ['messages', 'account_update'];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
appWebhookSubscriptionListSpy.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
await expect(node.webhookMethods.default.checkExists.call(mockHookFunctions)).rejects.toThrow(
|
||||
'API Error',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { MockProxy } from 'jest-mock-extended';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { type IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { WhatsApp } from '../../WhatsApp.node';
|
||||
|
||||
describe('Test WhatsApp Business Cloud, sendAndWait operation', () => {
|
||||
let whatsApp: WhatsApp;
|
||||
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
whatsApp = new WhatsApp();
|
||||
mockExecuteFunctions = mock<IExecuteFunctions>();
|
||||
|
||||
mockExecuteFunctions.helpers = {
|
||||
httpRequestWithAuthentication: jest.fn().mockResolvedValue({}),
|
||||
} as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should send message and put execution to wait', async () => {
|
||||
const items = [{ json: { data: 'test' } }];
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((key: string) => {
|
||||
if (key === 'phoneNumberId') return '11111';
|
||||
if (key === 'recipientPhoneNumber') return '22222';
|
||||
if (key === 'message') return 'my message';
|
||||
if (key === 'subject') return '';
|
||||
if (key === 'approvalOptions.values') return {};
|
||||
if (key === 'responseType') return 'approval';
|
||||
if (key === 'sendTo') return 'channel';
|
||||
if (key === 'channelId') return 'channelID';
|
||||
if (key === 'options.limitWaitTime.values') return {};
|
||||
});
|
||||
|
||||
mockExecuteFunctions.putExecutionToWait.mockImplementation();
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(items);
|
||||
mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId');
|
||||
|
||||
mockExecuteFunctions.getSignedResumeUrl.mockReturnValue(
|
||||
'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
|
||||
);
|
||||
|
||||
const result = await whatsApp.customOperations.message.sendAndWait.call(mockExecuteFunctions);
|
||||
|
||||
expect(result).toEqual([items]);
|
||||
|
||||
expect(mockExecuteFunctions.putExecutionToWait).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'whatsAppApi',
|
||||
{
|
||||
baseURL: 'https://graph.facebook.com/v13.0/',
|
||||
body: {
|
||||
messaging_product: 'whatsapp',
|
||||
text: {
|
||||
body: 'my message\n\n*Approve:*\n_http://localhost/waiting-webhook/nodeID?approved=true&signature=abc_\n\n',
|
||||
},
|
||||
to: '22222',
|
||||
type: 'text',
|
||||
},
|
||||
method: 'POST',
|
||||
url: '11111/messages',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
import type { SendAndWaitConfig } from '../../../utils/sendAndWait/utils';
|
||||
import { createMessage, WHATSAPP_BASE_URL } from '../GenericFunctions';
|
||||
import { sanitizePhoneNumber } from '../MessageFunctions';
|
||||
|
||||
describe('sanitizePhoneNumber', () => {
|
||||
const testNumber = '+99-(000)-111-2222';
|
||||
|
||||
it('should remove hyphens, parentheses, and plus signs from the phone number', () => {
|
||||
expect(sanitizePhoneNumber(testNumber)).toBe('990001112222');
|
||||
});
|
||||
|
||||
it('should return an empty string if input is empty', () => {
|
||||
expect(sanitizePhoneNumber('')).toBe('');
|
||||
});
|
||||
|
||||
it('should return the same number if no special characters are present', () => {
|
||||
expect(sanitizePhoneNumber('990001112222')).toBe('990001112222');
|
||||
});
|
||||
|
||||
it('should handle numbers with spaces correctly (not removing them)', () => {
|
||||
expect(sanitizePhoneNumber('+99 000 111 2222')).toBe('99 000 111 2222');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMessage', () => {
|
||||
const mockSendAndWaitConfig: SendAndWaitConfig = {
|
||||
title: '',
|
||||
message: 'Please approve an option:',
|
||||
options: [
|
||||
{
|
||||
label: 'Yes',
|
||||
style: 'primary',
|
||||
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
|
||||
},
|
||||
{
|
||||
label: 'No',
|
||||
style: 'secondary',
|
||||
url: 'http://localhost/waiting-webhook/nodeID?approved=false&signature=abc',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const phoneID = '123456789';
|
||||
const recipientPhone = '990001112222';
|
||||
|
||||
it('should return a valid HTTP request object', () => {
|
||||
const request: IHttpRequestOptions = createMessage(
|
||||
mockSendAndWaitConfig,
|
||||
phoneID,
|
||||
recipientPhone,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(request).toEqual({
|
||||
baseURL: WHATSAPP_BASE_URL,
|
||||
method: 'POST',
|
||||
url: `${phoneID}/messages`,
|
||||
body: {
|
||||
messaging_product: 'whatsapp',
|
||||
text: {
|
||||
body:
|
||||
'Please approve an option:\n\n' +
|
||||
'*Yes:*\n_http://localhost/waiting-webhook/nodeID?approved=true&signature=abc_\n\n' +
|
||||
'*No:*\n_http://localhost/waiting-webhook/nodeID?approved=false&signature=abc_\n\n',
|
||||
},
|
||||
type: 'text',
|
||||
to: recipientPhone,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle a single option correctly', () => {
|
||||
const singleOptionConfig: SendAndWaitConfig = {
|
||||
title: '',
|
||||
message: 'Choose an option:',
|
||||
options: [
|
||||
{
|
||||
label: 'Confirm',
|
||||
style: '',
|
||||
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const request: IHttpRequestOptions = createMessage(
|
||||
singleOptionConfig,
|
||||
phoneID,
|
||||
recipientPhone,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(request).toEqual({
|
||||
baseURL: WHATSAPP_BASE_URL,
|
||||
method: 'POST',
|
||||
url: `${phoneID}/messages`,
|
||||
body: {
|
||||
messaging_product: 'whatsapp',
|
||||
text: {
|
||||
body: 'Choose an option:\n\n*Confirm:*\n_http://localhost/waiting-webhook/nodeID?approved=true&signature=abc_\n\n',
|
||||
},
|
||||
type: 'text',
|
||||
to: recipientPhone,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { filterStatuses } from '../WhatsAppTrigger.node';
|
||||
|
||||
describe('filterStatuses', () => {
|
||||
const mockEvents = [
|
||||
{ statuses: [{ status: 'deleted' }] },
|
||||
{ statuses: [{ status: 'delivered' }] },
|
||||
{ statuses: [{ status: 'failed' }] },
|
||||
{ statuses: [{ status: 'read' }] },
|
||||
{ statuses: [{ status: 'sent' }] },
|
||||
];
|
||||
|
||||
test('returns events with no statuses when allowedStatuses is empty', () => {
|
||||
expect(filterStatuses(mockEvents, [])).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns all events when 'all' is in allowedStatuses", () => {
|
||||
expect(filterStatuses(mockEvents, ['all'])).toEqual(mockEvents);
|
||||
});
|
||||
|
||||
test('filters events correctly when specific statuses are provided', () => {
|
||||
expect(filterStatuses(mockEvents, ['deleted', 'read'])).toEqual([
|
||||
{ statuses: [{ status: 'deleted' }] },
|
||||
{ statuses: [{ status: 'read' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns only event with matching status', () => {
|
||||
expect(filterStatuses(mockEvents, ['failed'])).toEqual([{ statuses: [{ status: 'failed' }] }]);
|
||||
});
|
||||
|
||||
test('returns unchanged event when allowedStatuses is undefined', () => {
|
||||
expect(filterStatuses(mockEvents, undefined)).toEqual(mockEvents);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user