mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
fix(Schedule Node): Apply declared interval defaults to stored rules (#35271)
This commit is contained in:
parent
4e6fc65f55
commit
28d929dc2e
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user