From f92637a9fe0f36d1af944ae9129f1342a6a74b0e Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 1 Oct 2024 11:40:43 +0200 Subject: [PATCH] test(n8n Form Trigger Node): Add tests, extract testing util for webhook triggers (no-changelog) (#11023) --- .../Form/test/FormTriggerV2.node.test.ts | 316 ++++++++++++++++++ .../nodes-base/nodes/Form/test/utils.test.ts | 150 +-------- .../test/ScheduleTrigger.node.test.ts | 14 +- .../nodes-base/test/nodes/TriggerHelpers.ts | 201 +++++++---- 4 files changed, 462 insertions(+), 219 deletions(-) create mode 100644 packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts diff --git a/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts b/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts new file mode 100644 index 0000000000..fba596c87e --- /dev/null +++ b/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts @@ -0,0 +1,316 @@ +import { mock } from 'jest-mock-extended'; +import { NodeOperationError, type INode } from 'n8n-workflow'; + +import { testVersionedWebhookTriggerNode } from '@test/nodes/TriggerHelpers'; + +import { FormTrigger } from '../FormTrigger.node'; +import type { FormField } from '../interfaces'; + +describe('FormTrigger', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render a form template with correct fields', async () => { + const formFields: FormField[] = [ + { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, + { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, + { fieldLabel: 'Notes', fieldType: 'textarea', requiredField: false }, + { + fieldLabel: 'Gender', + fieldType: 'select', + requiredField: true, + fieldOptions: { values: [{ option: 'Male' }, { option: 'Female' }] }, + }, + { + fieldLabel: 'Resume', + fieldType: 'file', + requiredField: true, + acceptFileTypes: '.pdf,.doc', + multipleFiles: false, + }, + ]; + + const { response, responseData } = await testVersionedWebhookTriggerNode(FormTrigger, 2, { + mode: 'manual', + node: { + parameters: { + formTitle: 'Test Form', + formDescription: 'Test Description', + responseMode: 'onReceived', + formFields: { values: formFields }, + options: { + appendAttribution: false, + respondWithOptions: { values: { respondWith: 'text' } }, + }, + }, + }, + }); + + expect(response.render).toHaveBeenCalledWith('form-trigger', { + appendAttribution: false, + formDescription: 'Test Description', + formFields: [ + { + defaultValue: '', + errorId: 'error-field-0', + id: 'field-0', + inputRequired: 'form-required', + isInput: true, + label: 'Name', + placeholder: undefined, + type: 'text', + }, + { + defaultValue: '', + errorId: 'error-field-1', + id: 'field-1', + inputRequired: '', + isInput: true, + label: 'Age', + placeholder: undefined, + type: 'number', + }, + { + defaultValue: '', + errorId: 'error-field-2', + id: 'field-2', + inputRequired: '', + label: 'Notes', + placeholder: undefined, + isTextarea: true, + }, + { + defaultValue: '', + errorId: 'error-field-3', + id: 'field-3', + inputRequired: 'form-required', + isInput: true, + label: 'Gender', + placeholder: undefined, + type: 'select', + }, + { + acceptFileTypes: '.pdf,.doc', + defaultValue: '', + errorId: 'error-field-4', + id: 'field-4', + inputRequired: 'form-required', + isFileInput: true, + label: 'Resume', + multipleFiles: '', + placeholder: undefined, + }, + ], + formSubmittedText: 'Your response has been recorded', + formTitle: 'Test Form', + n8nWebsiteLink: + 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=instanceId', + testRun: true, + useResponseData: false, + validForm: true, + }); + + expect(responseData).toEqual({ noWebhookResponse: true }); + }); + + it('should return workflowData on POST request', async () => { + const formFields: FormField[] = [ + { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, + { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, + { fieldLabel: 'Date', fieldType: 'date', formatDate: 'dd MMM', requiredField: false }, + { fieldLabel: 'Empty', fieldType: 'number', requiredField: false }, + { + fieldLabel: 'Tags', + fieldType: 'select', + multiselect: true, + requiredField: false, + fieldOptions: { values: [{ option: 'Popular' }, { option: 'Recent' }] }, + }, + ]; + + const bodyData = { + data: { + 'field-0': 'John Doe', + 'field-1': '30', + 'field-2': '2024-08-31', + 'field-4': '{}', + }, + }; + + const { responseData } = await testVersionedWebhookTriggerNode(FormTrigger, 2, { + mode: 'manual', + node: { + parameters: { + formTitle: 'Test Form', + formDescription: 'Test Description', + responseMode: 'onReceived', + formFields: { values: formFields }, + }, + }, + request: { method: 'POST' }, + bodyData, + }); + + expect(responseData).toEqual({ + webhookResponse: { status: 200 }, + workflowData: [ + [ + { + json: { + Name: 'John Doe', + Age: 30, + Date: '31 Jan', + Empty: null, + Tags: {}, + submittedAt: expect.any(String), + formMode: 'test', + }, + }, + ], + ], + }); + }); + + describe('Respond to Webhook', () => { + it('should throw when misconfigured', async () => { + await expect( + testVersionedWebhookTriggerNode(FormTrigger, 2, { + node: { + parameters: { + responseMode: 'responseNode', + }, + }, + request: { method: 'POST' }, + childNodes: [], + }), + ).rejects.toEqual( + new NodeOperationError(mock(), 'No Respond to Webhook node found in the workflow'), + ); + + await expect( + testVersionedWebhookTriggerNode(FormTrigger, 2, { + node: { + parameters: { + responseMode: 'onReceived', + }, + }, + request: { method: 'POST' }, + childNodes: [ + { + name: 'Test Respond To Webhook', + type: 'n8n-nodes-base.respondToWebhook', + typeVersion: 1, + }, + ], + }), + ).rejects.toEqual( + new NodeOperationError(mock(), 'n8n Form Trigger node not correctly configured'), + ); + }); + }); + + it('should throw on invalid webhook authentication', async () => { + const formFields: FormField[] = [ + { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, + { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, + ]; + + const { responseData, response } = await testVersionedWebhookTriggerNode(FormTrigger, 2, { + mode: 'manual', + node: { + parameters: { + formTitle: 'Test Form', + formDescription: 'Test Description', + responseMode: 'onReceived', + formFields: { values: formFields }, + authentication: 'basicAuth', + }, + }, + request: { method: 'POST' }, + }); + + expect(responseData).toEqual({ noWebhookResponse: true }); + expect(response.status).toHaveBeenCalledWith(401); + expect(response.setHeader).toHaveBeenCalledWith( + 'WWW-Authenticate', + 'Basic realm="Enter credentials"', + ); + }); + + it('should handle files', async () => { + const formFields: FormField[] = [ + { + fieldLabel: 'Resume', + fieldType: 'file', + requiredField: true, + acceptFileTypes: '.pdf,.doc', + multipleFiles: false, + }, + { + fieldLabel: 'Attachments', + fieldType: 'file', + requiredField: true, + acceptFileTypes: '.pdf,.doc', + multipleFiles: true, + }, + ]; + + const bodyData = { + files: { + 'field-0': { + originalFilename: 'resume.pdf', + mimetype: 'application/json', + filepath: '/resume.pdf', + size: 200, + }, + 'field-1': [ + { + originalFilename: 'attachment1.pdf', + mimetype: 'application/json', + filepath: '/attachment1.pdf', + size: 201, + }, + ], + }, + }; + + const { responseData } = await testVersionedWebhookTriggerNode(FormTrigger, 2, { + mode: 'trigger', + node: { + parameters: { + formTitle: 'Test Form', + formDescription: 'Test Description', + responseMode: 'onReceived', + formFields: { values: formFields }, + }, + }, + request: { method: 'POST' }, + bodyData, + }); + + expect(responseData?.webhookResponse).toEqual({ status: 200 }); + expect(responseData?.workflowData).toEqual([ + [ + expect.objectContaining({ + json: { + Resume: { + filename: 'resume.pdf', + mimetype: 'application/json', + size: 200, + }, + Attachments: [ + { + filename: 'attachment1.pdf', + mimetype: 'application/json', + size: 201, + }, + ], + formMode: 'production', + submittedAt: expect.any(String), + }, + }), + ], + ]); + }); +}); diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index bb44a354e6..40673f7878 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -1,153 +1,5 @@ -import { mock } from 'jest-mock-extended'; -import type { IWebhookFunctions } from 'n8n-workflow'; import type { FormField } from '../interfaces'; -import { formWebhook, prepareFormData } from '../utils'; - -describe('FormTrigger, formWebhook', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should call response render', async () => { - const executeFunctions = mock(); - const mockRender = jest.fn(); - - const formFields: FormField[] = [ - { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, - { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, - { - fieldLabel: 'Gender', - fieldType: 'select', - requiredField: true, - fieldOptions: { values: [{ option: 'Male' }, { option: 'Female' }] }, - }, - { - fieldLabel: 'Resume', - fieldType: 'file', - requiredField: true, - acceptFileTypes: '.pdf,.doc', - multipleFiles: false, - }, - ]; - - executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any); - executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({}); - executeFunctions.getNodeParameter.calledWith('formTitle').mockReturnValue('Test Form'); - executeFunctions.getNodeParameter - .calledWith('formDescription') - .mockReturnValue('Test Description'); - executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived'); - executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields); - executeFunctions.getResponseObject.mockReturnValue({ render: mockRender } as any); - executeFunctions.getRequestObject.mockReturnValue({ method: 'GET', query: {} } as any); - executeFunctions.getMode.mockReturnValue('manual'); - executeFunctions.getInstanceId.mockReturnValue('instanceId'); - executeFunctions.getBodyData.mockReturnValue({ data: {}, files: {} }); - executeFunctions.getChildNodes.mockReturnValue([]); - - await formWebhook(executeFunctions); - - expect(mockRender).toHaveBeenCalledWith('form-trigger', { - appendAttribution: true, - formDescription: 'Test Description', - formFields: [ - { - defaultValue: '', - errorId: 'error-field-0', - id: 'field-0', - inputRequired: 'form-required', - isInput: true, - label: 'Name', - placeholder: undefined, - type: 'text', - }, - { - defaultValue: '', - errorId: 'error-field-1', - id: 'field-1', - inputRequired: '', - isInput: true, - label: 'Age', - placeholder: undefined, - type: 'number', - }, - { - defaultValue: '', - errorId: 'error-field-2', - id: 'field-2', - inputRequired: 'form-required', - isInput: true, - label: 'Gender', - placeholder: undefined, - type: 'select', - }, - { - acceptFileTypes: '.pdf,.doc', - defaultValue: '', - errorId: 'error-field-3', - id: 'field-3', - inputRequired: 'form-required', - isFileInput: true, - label: 'Resume', - multipleFiles: '', - placeholder: undefined, - }, - ], - formSubmittedText: 'Your response has been recorded', - formTitle: 'Test Form', - n8nWebsiteLink: - 'https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&utm_campaign=instanceId', - testRun: true, - useResponseData: false, - validForm: true, - }); - }); - - it('should return workflowData on POST request', async () => { - const executeFunctions = mock(); - const mockStatus = jest.fn(); - const mockEnd = jest.fn(); - - const formFields: FormField[] = [ - { fieldLabel: 'Name', fieldType: 'text', requiredField: true }, - { fieldLabel: 'Age', fieldType: 'number', requiredField: false }, - ]; - - const bodyData = { - 'field-0': 'John Doe', - 'field-1': '30', - }; - - executeFunctions.getNode.mockReturnValue({ typeVersion: 2.1 } as any); - executeFunctions.getNodeParameter.calledWith('options').mockReturnValue({}); - executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived'); - executeFunctions.getChildNodes.mockReturnValue([]); - executeFunctions.getNodeParameter.calledWith('formFields.values').mockReturnValue(formFields); - executeFunctions.getResponseObject.mockReturnValue({ status: mockStatus, end: mockEnd } as any); - executeFunctions.getRequestObject.mockReturnValue({ method: 'POST' } as any); - executeFunctions.getMode.mockReturnValue('manual'); - executeFunctions.getInstanceId.mockReturnValue('instanceId'); - executeFunctions.getBodyData.mockReturnValue({ data: bodyData, files: {} }); - - const result = await formWebhook(executeFunctions); - - expect(result).toEqual({ - webhookResponse: { status: 200 }, - workflowData: [ - [ - { - json: { - Name: 'John Doe', - Age: 30, - submittedAt: expect.any(String), - formMode: 'test', - }, - }, - ], - ], - }); - }); -}); +import { prepareFormData } from '../utils'; describe('FormTrigger, prepareFormData', () => { it('should return valid form data with given parameters', () => { diff --git a/packages/nodes-base/nodes/Schedule/test/ScheduleTrigger.node.test.ts b/packages/nodes-base/nodes/Schedule/test/ScheduleTrigger.node.test.ts index 9ea19c14e3..aa81a05857 100644 --- a/packages/nodes-base/nodes/Schedule/test/ScheduleTrigger.node.test.ts +++ b/packages/nodes-base/nodes/Schedule/test/ScheduleTrigger.node.test.ts @@ -1,6 +1,6 @@ import * as n8nWorkflow from 'n8n-workflow'; -import { createTestTriggerNode } from '@test/nodes/TriggerHelpers'; +import { testTriggerNode } from '@test/nodes/TriggerHelpers'; import { ScheduleTrigger } from '../ScheduleTrigger.node'; @@ -21,7 +21,7 @@ describe('ScheduleTrigger', () => { describe('trigger', () => { it('should emit on defined schedule', async () => { - const { emit } = await createTestTriggerNode(ScheduleTrigger).trigger({ + const { emit } = await testTriggerNode(ScheduleTrigger, { timezone, node: { parameters: { rule: { interval: [{ field: 'hours', hoursInterval: 3 }] } } }, workflowStaticData: { recurrenceRules: [] }, @@ -60,7 +60,7 @@ describe('ScheduleTrigger', () => { }); it('should emit on schedule defined as a cron expression', async () => { - const { emit } = await createTestTriggerNode(ScheduleTrigger).trigger({ + const { emit } = await testTriggerNode(ScheduleTrigger, { timezone, node: { parameters: { @@ -88,7 +88,7 @@ describe('ScheduleTrigger', () => { it('should throw on invalid cron expressions', async () => { await expect( - createTestTriggerNode(ScheduleTrigger).trigger({ + testTriggerNode(ScheduleTrigger, { timezone, node: { parameters: { @@ -108,7 +108,8 @@ describe('ScheduleTrigger', () => { }); it('should emit when manually executed', async () => { - const { emit } = await createTestTriggerNode(ScheduleTrigger).triggerManual({ + const { emit } = await testTriggerNode(ScheduleTrigger, { + mode: 'manual', timezone, node: { parameters: { rule: { interval: [{ field: 'hours', hoursInterval: 3 }] } } }, workflowStaticData: { recurrenceRules: [] }, @@ -134,7 +135,8 @@ describe('ScheduleTrigger', () => { it('should throw on invalid cron expressions in manual mode', async () => { await expect( - createTestTriggerNode(ScheduleTrigger).triggerManual({ + testTriggerNode(ScheduleTrigger, { + mode: 'manual', timezone, node: { parameters: { diff --git a/packages/nodes-base/test/nodes/TriggerHelpers.ts b/packages/nodes-base/test/nodes/TriggerHelpers.ts index 990bcb3c5b..c1957ffafd 100644 --- a/packages/nodes-base/test/nodes/TriggerHelpers.ts +++ b/packages/nodes-base/test/nodes/TriggerHelpers.ts @@ -1,94 +1,167 @@ +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 { ScheduledTaskManager } from 'n8n-core/dist/ScheduledTaskManager'; import type { + IBinaryData, IDataObject, INode, INodeType, ITriggerFunctions, + IWebhookFunctions, + NodeTypeAndVersion, + VersionedNodeType, Workflow, - WorkflowExecuteMode, } from 'n8n-workflow'; type MockDeepPartial = Parameters>[0]; type TestTriggerNodeOptions = { + mode?: 'manual' | 'trigger'; node?: MockDeepPartial; timezone?: string; workflowStaticData?: IDataObject; }; -type TriggerNodeTypeClass = new () => INodeType & Required>; +type TestWebhookTriggerNodeOptions = TestTriggerNodeOptions & { + mode?: 'manual' | 'trigger'; + webhookName?: string; + request?: MockDeepPartial; + bodyData?: IDataObject; + childNodes?: NodeTypeAndVersion[]; +}; -export const createTestTriggerNode = (Trigger: TriggerNodeTypeClass) => { - const trigger = new Trigger(); +export async function testVersionedTriggerNode( + Trigger: new () => VersionedNodeType, + version?: number, + options: TestTriggerNodeOptions = {}, +) { + const instance = new Trigger(); + return await testTriggerNode(instance.nodeVersions[version ?? instance.currentVersion], options); +} +export async function testTriggerNode( + Trigger: (new () => INodeType) | INodeType, + options: TestTriggerNodeOptions = {}, +) { + const trigger = 'description' in Trigger ? Trigger : new Trigger(); const emit: jest.MockedFunction = jest.fn(); - const setupTriggerFunctions = ( - mode: WorkflowExecuteMode, - options: TestTriggerNodeOptions = {}, - ) => { - 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), - } satisfies Partial, - options.node, - ) as INode; - const workflow = mock({ timezone: options.timezone ?? 'Europe/Berlin' }); + 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), + } satisfies Partial, + options.node, + ) as INode; + const workflow = mock({ timezone: options.timezone ?? 'Europe/Berlin' }); - const scheduledTaskManager = new ScheduledTaskManager(mock()); - const helpers = mock({ - returnJsonArray, - registerCron: (cronExpression, onTick) => - scheduledTaskManager.registerCron(workflow, cronExpression, onTick), - }); + const scheduledTaskManager = new ScheduledTaskManager(mock()); + const helpers = mock({ + returnJsonArray, + registerCron: (cronExpression, onTick) => + scheduledTaskManager.registerCron(workflow, cronExpression, onTick), + }); - const triggerFunctions = mock({ - helpers, - emit, - getTimezone: () => timezone, - getNode: () => node, - getMode: () => mode, - getWorkflowStaticData: () => options.workflowStaticData ?? {}, - getNodeParameter: (parameterName, fallback) => node.parameters[parameterName] ?? fallback, - }); + const triggerFunctions = mock({ + helpers, + emit, + getTimezone: () => timezone, + getNode: () => node, + getMode: () => options.mode ?? 'trigger', + getWorkflowStaticData: () => options.workflowStaticData ?? {}, + getNodeParameter: (parameterName, fallback) => get(node.parameters, parameterName) ?? fallback, + }); - return triggerFunctions; - }; + const response = await trigger.trigger?.call(triggerFunctions); + + if (options.mode === 'manual') { + expect(response?.manualTriggerFunction).toBeInstanceOf(Function); + await response?.manualTriggerFunction?.(); + } else { + expect(response?.manualTriggerFunction).toBeUndefined(); + } return { - trigger: async (options: TestTriggerNodeOptions = {}) => { - const triggerFunctions = setupTriggerFunctions('trigger', options); - - const response = await trigger.trigger.call(triggerFunctions); - - expect(response?.manualTriggerFunction).toBeUndefined(); - - return { - close: jest.fn(response?.closeFunction), - emit, - }; - }, - - triggerManual: async (options: TestTriggerNodeOptions = {}) => { - const triggerFunctions = setupTriggerFunctions('manual', options); - - const response = await trigger.trigger.call(triggerFunctions); - - expect(response?.manualTriggerFunction).toBeInstanceOf(Function); - - await response?.manualTriggerFunction?.(); - - return { - close: jest.fn(response?.closeFunction), - emit, - }; - }, + close: jest.fn(response?.closeFunction), + emit, }; -}; +} + +export async function testVersionedWebhookTriggerNode( + Trigger: new () => VersionedNodeType, + version?: number, + options: TestWebhookTriggerNodeOptions = {}, +) { + const instance = new Trigger(); + return await testWebhookTriggerNode( + instance.nodeVersions[version ?? instance.currentVersion], + options, + ); +} + +export async function testWebhookTriggerNode( + Trigger: (new () => INodeType) | INodeType, + options: TestWebhookTriggerNodeOptions = {}, +) { + 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), + } satisfies Partial, + options.node, + ) as INode; + const workflow = mock({ timezone: options.timezone ?? 'Europe/Berlin' }); + + const scheduledTaskManager = new ScheduledTaskManager(mock()); + const helpers = mock({ + returnJsonArray, + registerCron: (cronExpression, onTick) => + scheduledTaskManager.registerCron(workflow, cronExpression, onTick), + }); + + const request = mock({ + method: 'GET', + ...options.request, + }); + const response = mock({ status: jest.fn(() => mock()) }); + const webhookFunctions = mock({ + helpers, + nodeHelpers: { + copyBinaryFile: jest.fn(async () => mock()), + }, + getTimezone: () => timezone, + getNode: () => node, + getMode: () => options.mode ?? 'trigger', + getInstanceId: () => 'instanceId', + getBodyData: () => options.bodyData ?? {}, + getHeaderData: () => ({}), + getInputConnectionData: async () => ({}), + getNodeWebhookUrl: (name) => `/test-webhook-url/${name}`, + getParamsData: () => ({}), + getQueryData: () => ({}), + getRequestObject: () => request, + getResponseObject: () => response, + getWebhookName: () => options.webhookName ?? 'default', + getWorkflowStaticData: () => options.workflowStaticData ?? {}, + getNodeParameter: (parameterName, fallback) => get(node.parameters, parameterName) ?? fallback, + getChildNodes: () => options.childNodes ?? [], + }); + + const responseData = await trigger.webhook?.call(webhookFunctions); + + return { + responseData, + response: webhookFunctions.getResponseObject(), + }; +}