From 0ff0f1aa11ece14bf04caf41e05045679557e154 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 27 Sep 2024 17:53:00 +0200 Subject: [PATCH] test(Schedule Trigger Node): Add tests and extract trigger test helper (no-changelog) (#10625) --- .../test/ScheduleTrigger.node.test.ts | 156 ++++++++++++++++++ .../tests/ScheduleTrigger.node.test.ts | 84 ---------- .../nodes-base/test/nodes/TriggerHelpers.ts | 94 +++++++++++ 3 files changed, 250 insertions(+), 84 deletions(-) create mode 100644 packages/nodes-base/nodes/Schedule/test/ScheduleTrigger.node.test.ts delete mode 100644 packages/nodes-base/nodes/Schedule/tests/ScheduleTrigger.node.test.ts create mode 100644 packages/nodes-base/test/nodes/TriggerHelpers.ts diff --git a/packages/nodes-base/nodes/Schedule/test/ScheduleTrigger.node.test.ts b/packages/nodes-base/nodes/Schedule/test/ScheduleTrigger.node.test.ts new file mode 100644 index 0000000000..9ea19c14e3 --- /dev/null +++ b/packages/nodes-base/nodes/Schedule/test/ScheduleTrigger.node.test.ts @@ -0,0 +1,156 @@ +import * as n8nWorkflow from 'n8n-workflow'; + +import { createTestTriggerNode } from '@test/nodes/TriggerHelpers'; + +import { ScheduleTrigger } from '../ScheduleTrigger.node'; + +describe('ScheduleTrigger', () => { + Object.defineProperty(n8nWorkflow, 'randomInt', { + value: (min: number, max: number) => Math.floor((min + max) / 2), + }); + + const HOUR = 60 * 60 * 1000; + const mockDate = new Date('2023-12-28 12:34:56.789Z'); + const timezone = 'Europe/Berlin'; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.setSystemTime(mockDate); + }); + + describe('trigger', () => { + it('should emit on defined schedule', async () => { + const { emit } = await createTestTriggerNode(ScheduleTrigger).trigger({ + timezone, + node: { parameters: { rule: { interval: [{ field: 'hours', hoursInterval: 3 }] } } }, + workflowStaticData: { recurrenceRules: [] }, + }); + + expect(emit).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(HOUR); + expect(emit).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(2 * HOUR); + expect(emit).toHaveBeenCalledTimes(1); + + const firstTriggerData = emit.mock.calls[0][0][0][0]; + expect(firstTriggerData.json).toEqual({ + 'Day of month': '28', + 'Day of week': 'Thursday', + Hour: '15', + Minute: '30', + Month: 'December', + 'Readable date': 'December 28th 2023, 3:30:30 pm', + 'Readable time': '3:30:30 pm', + Second: '30', + Timezone: 'Europe/Berlin (UTC+01:00)', + Year: '2023', + timestamp: '2023-12-28T15:30:30.000+01:00', + }); + + jest.setSystemTime(new Date(firstTriggerData.json.timestamp as string)); + + jest.advanceTimersByTime(2 * HOUR); + expect(emit).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(HOUR); + expect(emit).toHaveBeenCalledTimes(2); + }); + + it('should emit on schedule defined as a cron expression', async () => { + const { emit } = await createTestTriggerNode(ScheduleTrigger).trigger({ + timezone, + node: { + parameters: { + rule: { + interval: [ + { + field: 'cronExpression', + expression: '0 */2 * * *', // every 2 hours + }, + ], + }, + }, + }, + workflowStaticData: {}, + }); + + expect(emit).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(2 * HOUR); + expect(emit).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(2 * HOUR); + expect(emit).toHaveBeenCalledTimes(2); + }); + + it('should throw on invalid cron expressions', async () => { + await expect( + createTestTriggerNode(ScheduleTrigger).trigger({ + timezone, + node: { + parameters: { + rule: { + interval: [ + { + field: 'cronExpression', + expression: '100 * * * *', // minute should be 0-59 -> invalid + }, + ], + }, + }, + }, + workflowStaticData: {}, + }), + ).rejects.toBeInstanceOf(n8nWorkflow.NodeOperationError); + }); + + it('should emit when manually executed', async () => { + const { emit } = await createTestTriggerNode(ScheduleTrigger).triggerManual({ + timezone, + node: { parameters: { rule: { interval: [{ field: 'hours', hoursInterval: 3 }] } } }, + workflowStaticData: { recurrenceRules: [] }, + }); + + expect(emit).toHaveBeenCalledTimes(1); + + const firstTriggerData = emit.mock.calls[0][0][0][0]; + expect(firstTriggerData.json).toEqual({ + 'Day of month': '28', + 'Day of week': 'Thursday', + Hour: '13', + Minute: '34', + Month: 'December', + 'Readable date': 'December 28th 2023, 1:34:56 pm', + 'Readable time': '1:34:56 pm', + Second: '56', + Timezone: 'Europe/Berlin (UTC+01:00)', + Year: '2023', + timestamp: '2023-12-28T13:34:56.789+01:00', + }); + }); + + it('should throw on invalid cron expressions in manual mode', async () => { + await expect( + createTestTriggerNode(ScheduleTrigger).triggerManual({ + timezone, + node: { + parameters: { + rule: { + interval: [ + { + field: 'cronExpression', + expression: '@daily *', // adding extra fields to shorthand not allowed -> invalid + }, + ], + }, + }, + }, + workflowStaticData: {}, + }), + ).rejects.toBeInstanceOf(n8nWorkflow.NodeOperationError); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Schedule/tests/ScheduleTrigger.node.test.ts b/packages/nodes-base/nodes/Schedule/tests/ScheduleTrigger.node.test.ts deleted file mode 100644 index 0693806f4c..0000000000 --- a/packages/nodes-base/nodes/Schedule/tests/ScheduleTrigger.node.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import * as n8nWorkflow from 'n8n-workflow'; -import type { INode, ITriggerFunctions, Workflow } from 'n8n-workflow'; -import { type InstanceSettings, returnJsonArray } from 'n8n-core'; -import { ScheduledTaskManager } from 'n8n-core/dist/ScheduledTaskManager'; -import { mock } from 'jest-mock-extended'; -import { ScheduleTrigger } from '../ScheduleTrigger.node'; - -describe('ScheduleTrigger', () => { - Object.defineProperty(n8nWorkflow, 'randomInt', { - value: (min: number, max: number) => Math.floor((min + max) / 2), - }); - - const HOUR = 60 * 60 * 1000; - const mockDate = new Date('2023-12-28 12:34:56.789Z'); - const timezone = 'Europe/Berlin'; - jest.useFakeTimers(); - jest.setSystemTime(mockDate); - - const node = mock({ typeVersion: 1 }); - const workflow = mock({ timezone }); - const instanceSettings = mock({ isLeader: true }); - const scheduledTaskManager = new ScheduledTaskManager(instanceSettings); - const helpers = mock({ - returnJsonArray, - registerCron: (cronExpression, onTick) => - scheduledTaskManager.registerCron(workflow, cronExpression, onTick), - }); - - const triggerFunctions = mock({ - helpers, - getTimezone: () => timezone, - getNode: () => node, - getMode: () => 'trigger', - }); - - const scheduleTrigger = new ScheduleTrigger(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('trigger', () => { - it('should emit on defined schedule', async () => { - triggerFunctions.getNodeParameter.calledWith('rule', expect.anything()).mockReturnValueOnce({ - interval: [{ field: 'hours', hoursInterval: 3 }], - }); - triggerFunctions.getWorkflowStaticData.mockReturnValueOnce({ recurrenceRules: [] }); - - const result = await scheduleTrigger.trigger.call(triggerFunctions); - // Assert that no manualTriggerFunction is returned - expect(result).toEqual({}); - - expect(triggerFunctions.emit).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(HOUR); - expect(triggerFunctions.emit).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(2 * HOUR); - expect(triggerFunctions.emit).toHaveBeenCalledTimes(1); - - const firstTriggerData = triggerFunctions.emit.mock.calls[0][0][0][0]; - expect(firstTriggerData.json).toEqual({ - 'Day of month': '28', - 'Day of week': 'Thursday', - Hour: '15', - Minute: '30', - Month: 'December', - 'Readable date': 'December 28th 2023, 3:30:30 pm', - 'Readable time': '3:30:30 pm', - Second: '30', - Timezone: 'Europe/Berlin (UTC+01:00)', - Year: '2023', - timestamp: '2023-12-28T15:30:30.000+01:00', - }); - - jest.setSystemTime(new Date(firstTriggerData.json.timestamp as string)); - - jest.advanceTimersByTime(2 * HOUR); - expect(triggerFunctions.emit).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(HOUR); - expect(triggerFunctions.emit).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/packages/nodes-base/test/nodes/TriggerHelpers.ts b/packages/nodes-base/test/nodes/TriggerHelpers.ts new file mode 100644 index 0000000000..990bcb3c5b --- /dev/null +++ b/packages/nodes-base/test/nodes/TriggerHelpers.ts @@ -0,0 +1,94 @@ +import { mock } from 'jest-mock-extended'; +import merge from 'lodash/merge'; +import { returnJsonArray, type InstanceSettings } from 'n8n-core'; +import { ScheduledTaskManager } from 'n8n-core/dist/ScheduledTaskManager'; +import type { + IDataObject, + INode, + INodeType, + ITriggerFunctions, + Workflow, + WorkflowExecuteMode, +} from 'n8n-workflow'; + +type MockDeepPartial = Parameters>[0]; + +type TestTriggerNodeOptions = { + node?: MockDeepPartial; + timezone?: string; + workflowStaticData?: IDataObject; +}; + +type TriggerNodeTypeClass = new () => INodeType & Required>; + +export const createTestTriggerNode = (Trigger: TriggerNodeTypeClass) => { + const 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 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, + }); + + return triggerFunctions; + }; + + 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, + }; + }, + }; +};