From fc26c44f65a7e16ca9a27eb26b5991f8ad18e86b Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Thu, 3 Oct 2024 13:59:15 +0200 Subject: [PATCH] test(Gmail Trigger Node): Add tests (no-changelog) (#11076) --- .../test/ExecuteWorkflowTrigger.node.test.ts | 19 ++ .../Google/Gmail/test/GmailTrigger.test.ts | 221 ++++++++++++++++++ .../nodes-base/nodes/Google/Gmail/types.ts | 39 ++++ .../nodes-base/test/nodes/TriggerHelpers.ts | 92 +++++++- 4 files changed, 362 insertions(+), 9 deletions(-) create mode 100644 packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts create mode 100644 packages/nodes-base/nodes/Google/Gmail/test/GmailTrigger.test.ts create mode 100644 packages/nodes-base/nodes/Google/Gmail/types.ts diff --git a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts b/packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts new file mode 100644 index 0000000000..ad35bff192 --- /dev/null +++ b/packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts @@ -0,0 +1,19 @@ +import { mock } from 'jest-mock-extended'; +import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; + +import { ExecuteWorkflowTrigger } from '../ExecuteWorkflowTrigger.node'; + +describe('ExecuteWorkflowTrigger', () => { + it('should return its input data', async () => { + const mockInputData: INodeExecutionData[] = [ + { json: { item: 0, foo: 'bar' } }, + { json: { item: 1, foo: 'quz' } }, + ]; + const executeFns = mock({ + getInputData: () => mockInputData, + }); + const result = await new ExecuteWorkflowTrigger().execute.call(executeFns); + + expect(result).toEqual([mockInputData]); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Gmail/test/GmailTrigger.test.ts b/packages/nodes-base/nodes/Google/Gmail/test/GmailTrigger.test.ts new file mode 100644 index 0000000000..4cd2ae0742 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Gmail/test/GmailTrigger.test.ts @@ -0,0 +1,221 @@ +import nock from 'nock'; +import * as mailparser from 'mailparser'; + +import { testPollingTriggerNode } from '@test/nodes/TriggerHelpers'; + +import { GmailTrigger } from '../GmailTrigger.node'; +import type { Message, ListMessage, MessageListResponse } from '../types'; + +jest.mock('mailparser'); + +describe('GmailTrigger', () => { + const baseUrl = 'https://www.googleapis.com'; + + function createMessage(message: Partial = {}): Message { + const content = Buffer.from('test'); + const contentBase64 = content.toString('base64'); + const size = content.byteLength; + + return { + historyId: 'testHistoryId', + id: 'testId', + internalDate: '1727777957863', + raw: contentBase64, + labelIds: ['testLabelId'], + sizeEstimate: size, + snippet: content.toString('utf-8'), + threadId: 'testThreadId', + payload: { + body: { attachmentId: 'testAttachmentId', data: contentBase64, size }, + filename: 'foo.txt', + headers: [{ name: 'testHeader', value: 'testHeaderValue' }], + mimeType: 'text/plain', + partId: 'testPartId', + parts: [], + }, + ...message, + }; + } + + function createListMessage(message: Partial = {}): ListMessage { + return { id: 'testId', threadId: 'testThreadId', ...message }; + } + + beforeAll(() => { + nock.disableNetConnect(); + + jest.spyOn(mailparser, 'simpleParser').mockResolvedValue({ + headers: new Map([['headerKey', 'headerValue']]), + attachments: [], + headerLines: [{ key: 'headerKey', line: 'headerValue' }], + html: '

test

', + date: new Date('2024-08-31'), + from: { + text: 'from@example.com', + value: [{ name: 'From', address: 'from@example.com' }], + html: 'from@example.com', + }, + to: { + text: 'to@example.com', + value: [{ name: 'To', address: 'to@example.com' }], + html: 'to@example.com', + }, + }); + }); + + afterAll(() => { + nock.restore(); + }); + + it('should return incoming emails', async () => { + const messageListResponse: MessageListResponse = { + messages: [createListMessage({ id: '1' }), createListMessage({ id: '2' })], + resultSizeEstimate: 123, + }; + nock(baseUrl) + .get('/gmail/v1/users/me/labels') + .reply(200, { labels: [{ id: 'testLabelId', name: 'Test Label Name' }] }); + nock(baseUrl).get(new RegExp('/gmail/v1/users/me/messages?.*')).reply(200, messageListResponse); + nock(baseUrl) + .get(new RegExp('/gmail/v1/users/me/messages/1?.*')) + .reply(200, createMessage({ id: '1' })); + nock(baseUrl) + .get(new RegExp('/gmail/v1/users/me/messages/2?.*')) + .reply(200, createMessage({ id: '2' })); + + const { response } = await testPollingTriggerNode(GmailTrigger); + + expect(response).toEqual([ + [ + { + json: { + date: '2024-08-31T00:00:00.000Z', + from: { + html: 'from@example.com', + text: 'from@example.com', + value: [{ address: 'from@example.com', name: 'From' }], + }, + headers: { headerKey: 'headerValue' }, + html: '

test

', + id: '1', + labelIds: ['testLabelId'], + sizeEstimate: 4, + threadId: 'testThreadId', + to: { + html: 'to@example.com', + text: 'to@example.com', + value: [{ address: 'to@example.com', name: 'To' }], + }, + }, + }, + { + json: { + date: '2024-08-31T00:00:00.000Z', + from: { + html: 'from@example.com', + text: 'from@example.com', + value: [{ address: 'from@example.com', name: 'From' }], + }, + headers: { headerKey: 'headerValue' }, + html: '

test

', + id: '2', + labelIds: ['testLabelId'], + sizeEstimate: 4, + threadId: 'testThreadId', + to: { + html: 'to@example.com', + text: 'to@example.com', + value: [{ address: 'to@example.com', name: 'To' }], + }, + }, + }, + ], + ]); + }); + + it('should simplify output when enabled', async () => { + const messageListResponse: MessageListResponse = { + messages: [createListMessage({ id: '1' }), createListMessage({ id: '2' })], + resultSizeEstimate: 123, + }; + nock(baseUrl) + .get('/gmail/v1/users/me/labels') + .reply(200, { labels: [{ id: 'testLabelId', name: 'Test Label Name' }] }); + nock(baseUrl).get(new RegExp('/gmail/v1/users/me/messages?.*')).reply(200, messageListResponse); + nock(baseUrl) + .get(new RegExp('/gmail/v1/users/me/messages/1?.*')) + .reply(200, createMessage({ id: '1' })); + nock(baseUrl) + .get(new RegExp('/gmail/v1/users/me/messages/2?.*')) + .reply(200, createMessage({ id: '2' })); + + const { response } = await testPollingTriggerNode(GmailTrigger, { + node: { parameters: { simple: true } }, + }); + + expect(response).toEqual([ + [ + { + json: { + historyId: 'testHistoryId', + id: '1', + internalDate: '1727777957863', + labels: [{ id: 'testLabelId', name: 'Test Label Name' }], + payload: { + body: { attachmentId: 'testAttachmentId', data: 'dGVzdA==', size: 4 }, + filename: 'foo.txt', + mimeType: 'text/plain', + partId: 'testPartId', + parts: [], + }, + raw: 'dGVzdA==', + sizeEstimate: 4, + snippet: 'test', + testHeader: 'testHeaderValue', + threadId: 'testThreadId', + }, + }, + { + json: { + historyId: 'testHistoryId', + id: '2', + internalDate: '1727777957863', + labels: [{ id: 'testLabelId', name: 'Test Label Name' }], + payload: { + body: { attachmentId: 'testAttachmentId', data: 'dGVzdA==', size: 4 }, + filename: 'foo.txt', + mimeType: 'text/plain', + partId: 'testPartId', + parts: [], + }, + raw: 'dGVzdA==', + sizeEstimate: 4, + snippet: 'test', + testHeader: 'testHeaderValue', + threadId: 'testThreadId', + }, + }, + ], + ]); + }); + + it('should filter out emails that were already processed', async () => { + const messageListResponse: MessageListResponse = { + messages: [], + resultSizeEstimate: 0, + }; + nock(baseUrl) + .get('/gmail/v1/users/me/labels') + .reply(200, { labels: [{ id: 'testLabelId', name: 'Test Label Name' }] }); + nock(baseUrl).get(new RegExp('/gmail/v1/users/me/messages?.*')).reply(200, messageListResponse); + + const { response } = await testPollingTriggerNode(GmailTrigger, { + node: { parameters: { simple: true } }, + workflowStaticData: { + 'Gmail Trigger': { lastTimeChecked: new Date('2024-10-31').getTime() / 1000 }, + }, + }); + + expect(response).toEqual(null); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Gmail/types.ts b/packages/nodes-base/nodes/Google/Gmail/types.ts new file mode 100644 index 0000000000..5e519a242f --- /dev/null +++ b/packages/nodes-base/nodes/Google/Gmail/types.ts @@ -0,0 +1,39 @@ +export type Message = { + id: string; + threadId: string; + labelIds: string[]; + snippet: string; + historyId: string; + internalDate: string; + sizeEstimate: number; + raw: string; + payload: MessagePart; +}; + +export type ListMessage = Pick; + +export type MessageListResponse = { + messages: ListMessage[]; + nextPageToken?: string; + resultSizeEstimate: number; +}; + +type GmailHeader = { + name: string; + value: string; +}; + +type MessagePart = { + partId: string; + mimeType: string; + filename: string; + headers: GmailHeader[]; + body: MessagePartBody; + parts: MessagePart[]; +}; + +type MessagePartBody = { + attachmentId: string; + size: number; + data: string; +}; diff --git a/packages/nodes-base/test/nodes/TriggerHelpers.ts b/packages/nodes-base/test/nodes/TriggerHelpers.ts index c1957ffafd..93b4dfae9e 100644 --- a/packages/nodes-base/test/nodes/TriggerHelpers.ts +++ b/packages/nodes-base/test/nodes/TriggerHelpers.ts @@ -2,18 +2,25 @@ import type * as express from 'express'; import { mock } from 'jest-mock-extended'; import get from 'lodash/get'; import merge from 'lodash/merge'; -import { returnJsonArray, type InstanceSettings } from 'n8n-core'; +import set from 'lodash/set'; +import { getExecutePollFunctions, returnJsonArray, type InstanceSettings } from 'n8n-core'; import { ScheduledTaskManager } from 'n8n-core/dist/ScheduledTaskManager'; import type { IBinaryData, + ICredentialDataDecryptedObject, IDataObject, + IHttpRequestOptions, INode, INodeType, + INodeTypes, + IPollFunctions, ITriggerFunctions, IWebhookFunctions, + IWorkflowExecuteAdditionalData, NodeTypeAndVersion, VersionedNodeType, Workflow, + WorkflowHooks, } from 'n8n-workflow'; type MockDeepPartial = Parameters>[0]; @@ -23,23 +30,29 @@ type TestTriggerNodeOptions = { node?: MockDeepPartial; timezone?: string; workflowStaticData?: IDataObject; + credential?: ICredentialDataDecryptedObject; }; type TestWebhookTriggerNodeOptions = TestTriggerNodeOptions & { - mode?: 'manual' | 'trigger'; webhookName?: string; request?: MockDeepPartial; bodyData?: IDataObject; childNodes?: NodeTypeAndVersion[]; }; +type TestPollingTriggerNodeOptions = TestTriggerNodeOptions & {}; + +function getNodeVersion(Trigger: new () => VersionedNodeType, version?: number) { + const instance = new Trigger(); + return instance.nodeVersions[version ?? instance.currentVersion]; +} + export async function testVersionedTriggerNode( Trigger: new () => VersionedNodeType, version?: number, options: TestTriggerNodeOptions = {}, ) { - const instance = new Trigger(); - return await testTriggerNode(instance.nodeVersions[version ?? instance.currentVersion], options); + return await testTriggerNode(getNodeVersion(Trigger, version), options); } export async function testTriggerNode( @@ -98,11 +111,7 @@ export async function testVersionedWebhookTriggerNode( version?: number, options: TestWebhookTriggerNodeOptions = {}, ) { - const instance = new Trigger(); - return await testWebhookTriggerNode( - instance.nodeVersions[version ?? instance.currentVersion], - options, - ); + return await testWebhookTriggerNode(getNodeVersion(Trigger, version), options); } export async function testWebhookTriggerNode( @@ -165,3 +174,68 @@ export async function testWebhookTriggerNode( response: webhookFunctions.getResponseObject(), }; } + +export async function testPollingTriggerNode( + Trigger: (new () => INodeType) | INodeType, + options: TestPollingTriggerNodeOptions = {}, +) { + const trigger = 'description' in Trigger ? Trigger : new Trigger(); + + const timezone = options.timezone ?? 'Europe/Berlin'; + const version = trigger.description.version; + const node = merge( + { + type: trigger.description.name, + name: trigger.description.defaults.name ?? `Test Node (${trigger.description.name})`, + typeVersion: typeof version === 'number' ? version : version.at(-1), + credentials: {}, + } satisfies Partial, + options.node, + ) as INode; + const workflow = mock({ + timezone: options.timezone ?? 'Europe/Berlin', + nodeTypes: mock({ + getByNameAndVersion: () => mock({ description: trigger.description }), + }), + }); + const mode = options.mode ?? 'trigger'; + + const originalPollingFunctions = getExecutePollFunctions( + workflow, + node, + mock({ + currentNodeParameters: node.parameters, + credentialsHelper: mock({ + getParentTypes: () => [], + authenticate: async (_creds, _type, options) => { + set(options, 'headers.authorization', 'mockAuth'); + return options as IHttpRequestOptions; + }, + }), + hooks: mock(), + }), + mode, + 'init', + ); + + async function getCredentials(): Promise { + return (options.credential ?? {}) as T; + } + + const pollingFunctions = mock({ + ...originalPollingFunctions, + getCredentials, + getTimezone: () => timezone, + getNode: () => node, + getMode: () => mode, + getInstanceId: () => 'instanceId', + getWorkflowStaticData: () => options.workflowStaticData ?? {}, + getNodeParameter: (parameterName, fallback) => get(node.parameters, parameterName) ?? fallback, + }); + + const response = await trigger.poll?.call(pollingFunctions); + + return { + response, + }; +}