fix(Schedule Node): Apply declared interval defaults to stored rules (#35271)

This commit is contained in:
Lorent Lempereur 2026-08-06 08:15:42 +02:00 committed by GitHub
parent 4e6fc65f55
commit 28d929dc2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 145 additions and 6 deletions

View File

@ -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 = '';

View File

@ -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<ITriggerResponse> {
const version = this.getNode().typeVersion;
const { interval: intervals } = this.getNodeParameter('rule', []) as Rule;
const { interval = [{}] } = this.getNodeParameter('rule', {}) as Partial<Rule>;
const intervals = interval.map(withIntervalDefaults);
const timezone = this.getTimezone();
const staticData = this.getWorkflowStaticData('node') as {
recurrenceRules: Array<number | undefined>;

View File

@ -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[];
}

View File

@ -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',

View File

@ -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,