From 28d929dc2eae050cc85a7887049ea5080e048165 Mon Sep 17 00:00:00 2001 From: Lorent Lempereur Date: Thu, 6 Aug 2026 08:15:42 +0200 Subject: [PATCH] fix(Schedule Node): Apply declared interval defaults to stored rules (#35271) --- .../nodes/Schedule/GenericFunctions.ts | 49 ++++++++++++++++- .../nodes/Schedule/ScheduleTrigger.node.ts | 4 +- .../nodes/Schedule/SchedulerInterface.ts | 17 +++++- .../Schedule/test/GenericFunctions.test.ts | 55 ++++++++++++++++++- .../test/ScheduleTrigger.node.test.ts | 26 +++++++++ 5 files changed, 145 insertions(+), 6 deletions(-) diff --git a/packages/nodes-base/nodes/Schedule/GenericFunctions.ts b/packages/nodes-base/nodes/Schedule/GenericFunctions.ts index dcf4728480e..72bbea9c12c 100644 --- a/packages/nodes-base/nodes/Schedule/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Schedule/GenericFunctions.ts @@ -2,7 +2,54 @@ import { createHash } from 'crypto'; import moment from 'moment-timezone'; import { type CronExpression, type CronSource, type INode, NodeOperationError } from 'n8n-workflow'; -import type { IRecurrenceRule, ScheduleInterval } from './SchedulerInterface'; +import type { IRecurrenceRule, RawScheduleInterval, ScheduleInterval } from './SchedulerInterface'; + +const SCHEDULE_FIELDS = [ + 'seconds', + 'minutes', + 'hours', + 'days', + 'weeks', + 'months', + 'cronExpression', +] as const; + +const isScheduleField = (value: unknown): value is ScheduleInterval['field'] => + SCHEDULE_FIELDS.some((field) => field === value); + +export function withIntervalDefaults(interval: RawScheduleInterval): ScheduleInterval { + const { triggerAtHour, triggerAtMinute, triggerAtDayOfMonth } = interval; + const field = isScheduleField(interval.field) ? interval.field : 'days'; + + switch (field) { + case 'cronExpression': + return { field, expression: interval.expression ?? ('' as CronExpression) }; + case 'seconds': + return { field, secondsInterval: interval.secondsInterval ?? 30 }; + case 'minutes': + return { field, minutesInterval: interval.minutesInterval ?? 5 }; + case 'hours': + return { field, hoursInterval: interval.hoursInterval ?? 1, triggerAtMinute }; + case 'days': + return { field, daysInterval: interval.daysInterval ?? 1, triggerAtHour, triggerAtMinute }; + case 'weeks': + return { + field, + weeksInterval: interval.weeksInterval ?? 1, + triggerAtDay: interval.triggerAtDay ?? [0], + triggerAtHour, + triggerAtMinute, + }; + case 'months': + return { + field, + monthsInterval: interval.monthsInterval ?? 1, + triggerAtDayOfMonth, + triggerAtHour, + triggerAtMinute, + }; + } +} export function validateInterval(node: INode, itemIndex: number, interval: ScheduleInterval): void { let errorMessage = ''; diff --git a/packages/nodes-base/nodes/Schedule/ScheduleTrigger.node.ts b/packages/nodes-base/nodes/Schedule/ScheduleTrigger.node.ts index c42cb9168be..f57df853ddd 100644 --- a/packages/nodes-base/nodes/Schedule/ScheduleTrigger.node.ts +++ b/packages/nodes-base/nodes/Schedule/ScheduleTrigger.node.ts @@ -16,6 +16,7 @@ import { toCronExpression, toCronSource, validateInterval, + withIntervalDefaults, } from './GenericFunctions'; import type { IRecurrenceRule, Rule } from './SchedulerInterface'; @@ -444,7 +445,8 @@ export class ScheduleTrigger implements INodeType { async trigger(this: ITriggerFunctions): Promise { const version = this.getNode().typeVersion; - const { interval: intervals } = this.getNodeParameter('rule', []) as Rule; + const { interval = [{}] } = this.getNodeParameter('rule', {}) as Partial; + const intervals = interval.map(withIntervalDefaults); const timezone = this.getTimezone(); const staticData = this.getWorkflowStaticData('node') as { recurrenceRules: Array; diff --git a/packages/nodes-base/nodes/Schedule/SchedulerInterface.ts b/packages/nodes-base/nodes/Schedule/SchedulerInterface.ts index 5d0730daceb..1617a956af3 100644 --- a/packages/nodes-base/nodes/Schedule/SchedulerInterface.ts +++ b/packages/nodes-base/nodes/Schedule/SchedulerInterface.ts @@ -48,6 +48,21 @@ export type ScheduleInterval = triggerAtMinute?: number; }; +export type RawScheduleInterval = Partial<{ + field: ScheduleInterval['field']; + expression: CronExpression; + secondsInterval: number; + minutesInterval: number; + hoursInterval: number; + daysInterval: number; + weeksInterval: number; + monthsInterval: number; + triggerAtDay: number[]; + triggerAtDayOfMonth: number; + triggerAtHour: number; + triggerAtMinute: number; +}>; + export interface Rule { - interval: ScheduleInterval[]; + interval: RawScheduleInterval[]; } diff --git a/packages/nodes-base/nodes/Schedule/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Schedule/test/GenericFunctions.test.ts index c830a9db52b..a92d32f9249 100644 --- a/packages/nodes-base/nodes/Schedule/test/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Schedule/test/GenericFunctions.test.ts @@ -1,5 +1,6 @@ import moment from 'moment-timezone'; -import type { INode } from 'n8n-workflow'; +import type { CronExpression, INode } from 'n8n-workflow'; +import type { Mock } from 'vitest'; import { intervalToRecurrence, @@ -7,9 +8,9 @@ import { resetStaleRecurrence, toCronExpression, validateInterval, + withIntervalDefaults, } from '../GenericFunctions'; -import type { ScheduleInterval } from '../SchedulerInterface'; -import type { Mock } from 'vitest'; +import type { RawScheduleInterval, ScheduleInterval } from '../SchedulerInterface'; vi.mock('moment-timezone'); const mockedMoment = vi.mocked(moment); @@ -210,6 +211,54 @@ describe('toCronExpression', () => { }); }); +describe('withIntervalDefaults', () => { + it.each<[string, RawScheduleInterval, ScheduleInterval]>([ + ['an empty entry', {}, { field: 'days', daysInterval: 1 }], + [ + 'an entry with no field', + { triggerAtHour: 7 }, + { field: 'days', daysInterval: 1, triggerAtHour: 7 }, + ], + [ + 'an unrecognized field', + { field: 'daily' } as unknown as RawScheduleInterval, + { field: 'days', daysInterval: 1 }, + ], + [ + 'a seconds entry with no size', + { field: 'seconds' }, + { field: 'seconds', secondsInterval: 30 }, + ], + [ + 'a minutes entry with no size', + { field: 'minutes' }, + { field: 'minutes', minutesInterval: 5 }, + ], + ['an hours entry with no size', { field: 'hours' }, { field: 'hours', hoursInterval: 1 }], + ['a days entry with no size', { field: 'days' }, { field: 'days', daysInterval: 1 }], + [ + 'a weeks entry with no size and no weekdays', + { field: 'weeks' }, + { field: 'weeks', weeksInterval: 1, triggerAtDay: [0] }, + ], + ['a months entry with no size', { field: 'months' }, { field: 'months', monthsInterval: 1 }], + [ + 'a cron entry with no expression', + { field: 'cronExpression' }, + { field: 'cronExpression', expression: '' as CronExpression }, + ], + ])('should complete %s', (_, stored, expected) => { + expect(withIntervalDefaults(stored)).toEqual(expected); + }); + + it('should keep an out-of-range size so validateInterval can report it', () => { + expect(withIntervalDefaults({ field: 'days', daysInterval: 0 })).toEqual({ + field: 'days', + daysInterval: 0, + }); + }); +}); + describe('validateInterval', () => { const mockNode: INode = { id: 'test-node', 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 cb1c3078c1e..090c38062bf 100644 --- a/packages/nodes-base/nodes/Schedule/test/ScheduleTrigger.node.test.ts +++ b/packages/nodes-base/nodes/Schedule/test/ScheduleTrigger.node.test.ts @@ -149,6 +149,32 @@ describe('ScheduleTrigger', () => { expect(emit).toHaveBeenCalledTimes(2); }); + const DAY = 24 * HOUR; + it.each<[string, n8nWorkflow.INodeParameters, number, number]>([ + [ + 'omits daysInterval', + { rule: { interval: [{ field: 'days', triggerAtHour: 7 }] } }, + 3 * DAY, + 3, + ], + ['omits the field', { rule: { interval: [{ triggerAtHour: 7 }] } }, 3 * DAY, 3], + ['omits minutesInterval', { rule: { interval: [{ field: 'minutes' }] } }, HOUR, 12], + ['omits the weekdays', { rule: { interval: [{ field: 'weeks' }] } }, 2 * 7 * DAY, 2], + ['is missing entirely', {}, 3 * DAY, 3], + ])( + 'should emit on the declared default schedule when the stored rule %s', + async (_, parameters, elapsed, expected) => { + const { emit } = await testTriggerNode(ScheduleTrigger, { + timezone, + node: { parameters }, + workflowStaticData: {}, + }); + + vi.advanceTimersByTime(elapsed); + expect(emit).toHaveBeenCalledTimes(expected); + }, + ); + it('should emit on schedule defined as a cron expression', async () => { const { emit } = await testTriggerNode(ScheduleTrigger, { timezone,