n8n/packages/nodes-base/nodes/Schedule/test/GenericFunctions.test.ts
Bernhard Wittmann 5f8ab01f9b
Some checks are pending
Build: Benchmark Image / build (push) Waiting to run
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.14.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
Util: Sync API Docs / sync-public-api (push) Waiting to run
fix(Schedule Node): Use elapsed-time check to self-heal after missed triggers (#28423)
2026-04-13 15:44:42 +00:00

629 lines
18 KiB
TypeScript

import moment from 'moment-timezone';
import type { INode } from 'n8n-workflow';
import * as n8nWorkflow from 'n8n-workflow';
import {
intervalToRecurrence,
recurrenceCheck,
toCronExpression,
validateInterval,
} from '../GenericFunctions';
import type { ScheduleInterval } from '../SchedulerInterface';
jest.mock('moment-timezone');
const mockedMoment = jest.mocked(moment);
function mockMomentTz(values: {
hour?: number;
dayOfYear?: number;
week?: number;
month?: number;
}) {
const tzObj = {
hour: () => values.hour ?? 0,
dayOfYear: () => values.dayOfYear ?? 1,
week: () => values.week ?? 1,
month: () => values.month ?? 0,
};
(mockedMoment.tz as unknown as jest.Mock).mockReturnValue(tzObj);
}
describe('toCronExpression', () => {
Object.defineProperty(n8nWorkflow, 'randomInt', {
value: (min: number, max: number) => Math.floor((min + max) / 2),
});
it('should return cron expression for cronExpression field', () => {
const result = toCronExpression({
field: 'cronExpression',
expression: '1 2 3 * * *',
});
expect(result).toEqual('1 2 3 * * *');
});
it('should return cron expression for seconds interval', () => {
const result = toCronExpression({
field: 'seconds',
secondsInterval: 10,
});
expect(result).toEqual('*/10 * * * * *');
});
it('should return cron expression for minutes interval', () => {
const result = toCronExpression({
field: 'minutes',
minutesInterval: 30,
});
expect(result).toEqual('30 */30 * * * *');
});
it('should return cron expression for hours interval', () => {
const result = toCronExpression({
field: 'hours',
hoursInterval: 3,
triggerAtMinute: 22,
});
expect(result).toEqual('30 22 */3 * * *');
const result1 = toCronExpression({
field: 'hours',
hoursInterval: 3,
});
expect(result1).toEqual('30 30 */3 * * *');
});
it('should return cron expression for days interval', () => {
const result = toCronExpression({
field: 'days',
daysInterval: 4,
triggerAtMinute: 30,
triggerAtHour: 10,
});
expect(result).toEqual('30 30 10 * * *');
const result1 = toCronExpression({
field: 'days',
daysInterval: 4,
});
expect(result1).toEqual('30 30 12 * * *');
});
it('should return cron expression for weeks interval', () => {
const result = toCronExpression({
field: 'weeks',
weeksInterval: 2,
triggerAtMinute: 0,
triggerAtHour: 9,
triggerAtDay: [1, 3, 5],
});
expect(result).toEqual('30 0 9 * * 1,3,5');
const result1 = toCronExpression({
field: 'weeks',
weeksInterval: 2,
triggerAtDay: [1, 3, 5],
});
expect(result1).toEqual('30 30 12 * * 1,3,5');
});
it('should return cron expression for months interval', () => {
const result = toCronExpression({
field: 'months',
monthsInterval: 3,
triggerAtMinute: 0,
triggerAtHour: 0,
triggerAtDayOfMonth: 1,
});
expect(result).toEqual('30 0 0 1 */3 *');
const result1 = toCronExpression({
field: 'months',
monthsInterval: 3,
});
expect(result1).toEqual('30 30 12 15 */3 *');
});
});
describe('validateInterval', () => {
const mockNode: INode = {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
describe('valid intervals', () => {
it.each<[string, ScheduleInterval]>([
['seconds', { field: 'seconds', secondsInterval: 1 }],
['seconds', { field: 'seconds', secondsInterval: 30 }],
['seconds', { field: 'seconds', secondsInterval: 59 }],
['minutes', { field: 'minutes', minutesInterval: 1 }],
['minutes', { field: 'minutes', minutesInterval: 30 }],
['minutes', { field: 'minutes', minutesInterval: 59 }],
['hours', { field: 'hours', hoursInterval: 1 }],
['hours', { field: 'hours', hoursInterval: 12 }],
['hours', { field: 'hours', hoursInterval: 23 }],
['days', { field: 'days', daysInterval: 1 }],
['days', { field: 'days', daysInterval: 15 }],
['days', { field: 'days', daysInterval: 31 }],
])('should not throw error for valid %s interval: %j', (_field, interval) => {
expect(() => {
validateInterval(mockNode, 0, interval);
}).not.toThrow();
});
});
describe('invalid intervals', () => {
it.each<[string, ScheduleInterval, string]>([
['seconds', { field: 'seconds', secondsInterval: 0 }, 'Seconds must be in range 1-59'],
['seconds', { field: 'seconds', secondsInterval: 60 }, 'Seconds must be in range 1-59'],
['seconds', { field: 'seconds', secondsInterval: -1 }, 'Seconds must be in range 1-59'],
['seconds', { field: 'seconds', secondsInterval: 100 }, 'Seconds must be in range 1-59'],
['minutes', { field: 'minutes', minutesInterval: 60 }, 'Minutes must be in range 1-59'],
['minutes', { field: 'minutes', minutesInterval: 0 }, 'Minutes must be in range 1-59'],
['minutes', { field: 'minutes', minutesInterval: -1 }, 'Minutes must be in range 1-59'],
['minutes', { field: 'minutes', minutesInterval: 100 }, 'Minutes must be in range 1-59'],
['hours', { field: 'hours', hoursInterval: 0 }, 'Hours must be in range 1-23'],
['hours', { field: 'hours', hoursInterval: 24 }, 'Hours must be in range 1-23'],
['hours', { field: 'hours', hoursInterval: -1 }, 'Hours must be in range 1-23'],
['hours', { field: 'hours', hoursInterval: 100 }, 'Hours must be in range 1-23'],
['days', { field: 'days', daysInterval: 0 }, 'Days must be in range 1-31'],
['days', { field: 'days', daysInterval: 32 }, 'Days must be in range 1-31'],
['days', { field: 'days', daysInterval: -1 }, 'Days must be in range 1-31'],
['days', { field: 'days', daysInterval: 100 }, 'Days must be in range 1-31'],
['months', { field: 'months', monthsInterval: 0 }, 'Months must be larger than 0'],
])(
'should throw error for invalid %s interval: %j',
(_field, interval, expectedDescription) => {
try {
validateInterval(mockNode, 0, interval);
fail('Expected validateInterval to throw an error');
} catch (error) {
expect(error.message).toBe('Invalid interval');
expect(error.description).toBe(expectedDescription);
}
},
);
});
});
describe('recurrenceCheck', () => {
it('should return true if activated=false', () => {
const result = recurrenceCheck({ activated: false }, [], 'UTC');
expect(result).toBe(true);
});
it('should return false if intervalSize is falsey', () => {
mockMomentTz({ dayOfYear: 10 });
const result = recurrenceCheck(
{
activated: true,
index: 0,
intervalSize: 0,
typeInterval: 'days',
},
[],
'UTC',
);
expect(result).toBe(false);
});
it('should return true on first execution when lastExecution is undefined', () => {
mockMomentTz({ dayOfYear: 100 });
const recurrenceRules: number[] = [];
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'days' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
expect(recurrenceRules[0]).toBe(100);
});
it('should not trigger again on the same day', () => {
mockMomentTz({ dayOfYear: 100 });
const recurrenceRules: number[] = [];
recurrenceCheck(
{ activated: true, index: 0, intervalSize: 2, typeInterval: 'days' },
recurrenceRules,
'UTC',
);
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 2, typeInterval: 'days' },
recurrenceRules,
'UTC',
);
expect(result).toBe(false);
});
describe('hours', () => {
it('should trigger when exactly on time', () => {
mockMomentTz({ hour: 5 });
const recurrenceRules = [2]; // lastExecution = hour 2, interval = 3
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'hours' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
expect(recurrenceRules[0]).toBe(5);
});
it('should not trigger before interval has elapsed', () => {
mockMomentTz({ hour: 4 });
const recurrenceRules = [2];
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'hours' },
recurrenceRules,
'UTC',
);
expect(result).toBe(false);
});
it('should recover after a missed execution', () => {
mockMomentTz({ hour: 8 });
const recurrenceRules = [2]; // missed hour 5, now at hour 8
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'hours' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
expect(recurrenceRules[0]).toBe(8);
});
it('should handle wrap-around (e.g., 23 → 2)', () => {
mockMomentTz({ hour: 2 });
const recurrenceRules = [23]; // lastExecution = 23, interval = 3, expected = 2
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'hours' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
});
it('should not trigger before interval on wrap-around', () => {
mockMomentTz({ hour: 0 });
const recurrenceRules = [23]; // only 1 hour elapsed, need 3
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'hours' },
recurrenceRules,
'UTC',
);
expect(result).toBe(false);
});
});
describe('days', () => {
it('should trigger when exactly on time', () => {
mockMomentTz({ dayOfYear: 24 });
const recurrenceRules = [21]; // lastExecution = day 21, interval = 3
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'days' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
expect(recurrenceRules[0]).toBe(24);
});
it('should not trigger before interval has elapsed', () => {
mockMomentTz({ dayOfYear: 23 });
const recurrenceRules = [21];
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'days' },
recurrenceRules,
'UTC',
);
expect(result).toBe(false);
});
it('should recover after a missed execution', () => {
mockMomentTz({ dayOfYear: 30 });
const recurrenceRules = [21]; // missed day 24, now at day 30
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'days' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
expect(recurrenceRules[0]).toBe(30);
});
it('should handle wrap-around at year boundary', () => {
mockMomentTz({ dayOfYear: 3 });
const recurrenceRules = [363]; // interval = 5, elapsed = (3-363+365)%365 = 5
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 5, typeInterval: 'days' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
});
it('should not trigger before interval on wrap-around', () => {
mockMomentTz({ dayOfYear: 2 });
const recurrenceRules = [363]; // elapsed = (2-363+365)%365 = 4, need 5
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 5, typeInterval: 'days' },
recurrenceRules,
'UTC',
);
expect(result).toBe(false);
});
it('should reproduce the exact bug from NODE-4831 (Indeed scenario)', () => {
// Workflow set to "every 3 days", last fired on day 21, missed day 24
// Now on day 25 — should fire but currently doesn't
mockMomentTz({ dayOfYear: 25 });
const recurrenceRules = [21];
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'days' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
});
});
describe('weeks', () => {
it('should trigger when exactly on time', () => {
mockMomentTz({ week: 8 });
const recurrenceRules = [6]; // lastExecution = week 6, interval = 2
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 2, typeInterval: 'weeks' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
expect(recurrenceRules[0]).toBe(8);
});
it('should not trigger before interval has elapsed', () => {
mockMomentTz({ week: 7 });
const recurrenceRules = [6];
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 2, typeInterval: 'weeks' },
recurrenceRules,
'UTC',
);
expect(result).toBe(false);
});
it('should recover after a missed execution', () => {
mockMomentTz({ week: 10 });
const recurrenceRules = [6]; // missed week 8, now at week 10
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 2, typeInterval: 'weeks' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
expect(recurrenceRules[0]).toBe(10);
});
it('should allow re-trigger on multiple days in the same week', () => {
mockMomentTz({ week: 10 });
const recurrenceRules = [10]; // same week as lastExecution
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 2, typeInterval: 'weeks' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
});
it('should handle wrap-around at year boundary', () => {
mockMomentTz({ week: 2 });
const recurrenceRules = [50]; // elapsed = (2-50+52)%52 = 4, interval = 3
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'weeks' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
});
});
describe('months', () => {
it('should trigger when exactly on time', () => {
mockMomentTz({ month: 5 });
const recurrenceRules = [2]; // lastExecution = month 2, interval = 3
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'months' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
expect(recurrenceRules[0]).toBe(5);
});
it('should not trigger before interval has elapsed', () => {
mockMomentTz({ month: 3 });
const recurrenceRules = [2];
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'months' },
recurrenceRules,
'UTC',
);
expect(result).toBe(false);
});
it('should recover after a missed execution', () => {
mockMomentTz({ month: 8 });
const recurrenceRules = [2]; // missed month 5, now at month 8
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'months' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
expect(recurrenceRules[0]).toBe(8);
});
it('should handle wrap-around (e.g., month 10 → month 1)', () => {
mockMomentTz({ month: 1 });
const recurrenceRules = [10]; // elapsed = (1-10+12)%12 = 3, interval = 3
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'months' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
});
it('should not trigger before interval on wrap-around', () => {
mockMomentTz({ month: 0 });
const recurrenceRules = [10]; // elapsed = (0-10+12)%12 = 2, need 3
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'months' },
recurrenceRules,
'UTC',
);
expect(result).toBe(false);
});
});
describe('schedule change recovery (NODE-4625)', () => {
it('should recover when interval is changed and lastExecution is stale', () => {
// Was "every 5 days", changed to "every 3 days". lastExecution=day 21 from old schedule.
// Now on day 25 — enough time has passed for the new 3-day interval.
mockMomentTz({ dayOfYear: 25 });
const recurrenceRules = [21];
const result = recurrenceCheck(
{ activated: true, index: 0, intervalSize: 3, typeInterval: 'days' },
recurrenceRules,
'UTC',
);
expect(result).toBe(true);
expect(recurrenceRules[0]).toBe(25);
});
});
});
describe('intervalToRecurrence', () => {
it('should return recurrence rule for seconds interval', () => {
const result = intervalToRecurrence(
{
field: 'seconds',
secondsInterval: 10,
},
0,
);
expect(result.activated).toBe(false);
});
it('should return recurrence rule for minutes interval', () => {
const result = intervalToRecurrence(
{
field: 'minutes',
minutesInterval: 30,
},
1,
);
expect(result.activated).toBe(false);
});
it('should return recurrence rule for hours interval', () => {
const result = intervalToRecurrence(
{
field: 'hours',
hoursInterval: 3,
triggerAtMinute: 22,
},
2,
);
expect(result).toEqual({
activated: true,
index: 2,
intervalSize: 3,
typeInterval: 'hours',
});
const result1 = intervalToRecurrence(
{
field: 'hours',
hoursInterval: 3,
},
3,
);
expect(result1).toEqual({
activated: true,
index: 3,
intervalSize: 3,
typeInterval: 'hours',
});
});
it('should return recurrence rule for days interval', () => {
const result = intervalToRecurrence(
{
field: 'days',
daysInterval: 4,
triggerAtMinute: 30,
triggerAtHour: 10,
},
4,
);
expect(result).toEqual({
activated: true,
index: 4,
intervalSize: 4,
typeInterval: 'days',
});
const result1 = intervalToRecurrence(
{
field: 'days',
daysInterval: 4,
},
5,
);
expect(result1).toEqual({
activated: true,
index: 5,
intervalSize: 4,
typeInterval: 'days',
});
});
it('should return recurrence rule for weeks interval', () => {
const result = intervalToRecurrence(
{
field: 'weeks',
weeksInterval: 2,
triggerAtMinute: 0,
triggerAtHour: 9,
triggerAtDay: [1, 3, 5],
},
6,
);
expect(result).toEqual({
activated: true,
index: 6,
intervalSize: 2,
typeInterval: 'weeks',
});
});
it('should return recurrence rule for months interval', () => {
const result = intervalToRecurrence(
{
field: 'months',
monthsInterval: 3,
triggerAtMinute: 0,
triggerAtHour: 0,
triggerAtDayOfMonth: 1,
},
8,
);
expect(result).toEqual({
activated: true,
index: 8,
intervalSize: 3,
typeInterval: 'months',
});
});
});