fix(Wait Node): Validate datetime for specific time mode (#14701)

Co-authored-by: Danny Martini <danny@n8n.io>
Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
Guillaume Jacquart
2025-04-18 10:26:21 +02:00
committed by GitHub
parent 3feab31792
commit 3641c1fb87
4 changed files with 109 additions and 21 deletions

View File

@@ -1,4 +1,3 @@
import { DateTime } from 'luxon';
import type {
IExecuteFunctions,
INodeExecutionData,
@@ -8,10 +7,11 @@ import type {
IWebhookFunctions,
} from 'n8n-workflow';
import {
NodeOperationError,
NodeConnectionTypes,
WAIT_INDEFINITELY,
FORM_TRIGGER_NODE_TYPE,
tryToParseDateTime,
NodeOperationError,
} from 'n8n-workflow';
import { updateDisplayOptions } from '../../utils/utilities';
@@ -506,20 +506,17 @@ export class Wait extends Webhook {
// a number of seconds added to the current timestamp
waitTill = new Date(new Date().getTime() + waitAmount);
} else {
const dateTimeStr = context.getNodeParameter('dateTime', 0) as string;
try {
const dateTimeStrRaw = context.getNodeParameter('dateTime', 0);
const parsedDateTime = tryToParseDateTime(dateTimeStrRaw, context.getTimezone());
if (isNaN(Date.parse(dateTimeStr))) {
waitTill = parsedDateTime.toUTC().toJSDate();
} catch (e) {
throw new NodeOperationError(
context.getNode(),
'[Wait node] Cannot put execution to wait because `dateTime` parameter is not a valid date. Please pick a specific date and time to wait until.',
);
}
waitTill = DateTime.fromFormat(dateTimeStr, "yyyy-MM-dd'T'HH:mm:ss", {
zone: context.getTimezone(),
})
.toUTC()
.toJSDate();
}
const waitValue = Math.max(waitTill.getTime() - new Date().getTime(), 0);

View File

@@ -1,4 +1,11 @@
import { testWorkflows, getWorkflowFilenames } from '@test/nodes/Helpers';
import { mock } from 'jest-mock-extended';
import { DateTime } from 'luxon';
import { NodeOperationError, type IExecuteFunctions } from 'n8n-workflow';
import { getWorkflowFilenames, testWorkflows } from '@test/nodes/Helpers';
import { Wait } from '../Wait.node';
const workflows = getWorkflowFilenames(__dirname);
describe('Execute Wait Node', () => {
@@ -15,5 +22,52 @@ describe('Execute Wait Node', () => {
jest.useRealTimers();
});
test.each([
{ value: 'invalid_date', isValid: false },
{
value: '2025-04-18T10:50:47.560',
isValid: true,
expectedWaitTill: new Date('2025-04-18T10:50:47.560Z'),
},
{
value: '2025-04-18T10:50:47.560+02:00',
isValid: true,
expectedWaitTill: new Date('2025-04-18T08:50:47.560Z'),
},
{
value: DateTime.fromISO('2025-04-18T10:50:47.560Z').toJSDate(),
isValid: true,
expectedWaitTill: new Date('2025-04-18T10:50:47.560Z'),
},
{
value: DateTime.fromISO('2025-04-18T10:50:47.560Z'),
isValid: true,
expectedWaitTill: new Date('2025-04-18T10:50:47.560Z'),
},
])(
'Test Wait Node with specificTime $value and isValid $isValid',
async ({ value, isValid, expectedWaitTill }) => {
const putExecutionToWaitSpy = jest.fn();
const waitNode = new Wait();
const executeFunctionsMock = mock<IExecuteFunctions>({
getNodeParameter: jest.fn().mockImplementation((paramName: string) => {
if (paramName === 'resume') return 'specificTime';
if (paramName === 'dateTime') return value;
}),
getTimezone: jest.fn().mockReturnValue('UTC'),
putExecutionToWait: putExecutionToWaitSpy,
getInputData: jest.fn(),
getNode: jest.fn(),
});
if (isValid) {
await expect(waitNode.execute(executeFunctionsMock)).resolves.not.toThrow();
expect(putExecutionToWaitSpy).toHaveBeenCalledWith(expectedWaitTill);
} else {
await expect(waitNode.execute(executeFunctionsMock)).rejects.toThrow(NodeOperationError);
}
},
);
testWorkflows(workflows);
});

View File

@@ -65,13 +65,15 @@ export const tryToParseBoolean = (value: unknown): value is boolean => {
});
};
export const tryToParseDateTime = (value: unknown): DateTime => {
if (value instanceof DateTime && value.isValid) {
export const tryToParseDateTime = (value: unknown, defaultZone?: string): DateTime => {
if (DateTime.isDateTime(value) && value.isValid) {
// Ignore the defaultZone if the value is already a DateTime
// because DateTime objects already contain the zone information
return value;
}
if (value instanceof Date) {
const fromJSDate = DateTime.fromJSDate(value);
const fromJSDate = DateTime.fromJSDate(value, { zone: defaultZone });
if (fromJSDate.isValid) {
return fromJSDate;
}
@@ -80,24 +82,24 @@ export const tryToParseDateTime = (value: unknown): DateTime => {
const dateString = String(value).trim();
// Rely on luxon to parse different date formats
const isoDate = DateTime.fromISO(dateString, { setZone: true });
const isoDate = DateTime.fromISO(dateString, { zone: defaultZone, setZone: true });
if (isoDate.isValid) {
return isoDate;
}
const httpDate = DateTime.fromHTTP(dateString, { setZone: true });
const httpDate = DateTime.fromHTTP(dateString, { zone: defaultZone, setZone: true });
if (httpDate.isValid) {
return httpDate;
}
const rfc2822Date = DateTime.fromRFC2822(dateString, { setZone: true });
const rfc2822Date = DateTime.fromRFC2822(dateString, { zone: defaultZone, setZone: true });
if (rfc2822Date.isValid) {
return rfc2822Date;
}
const sqlDate = DateTime.fromSQL(dateString, { setZone: true });
const sqlDate = DateTime.fromSQL(dateString, { zone: defaultZone, setZone: true });
if (sqlDate.isValid) {
return sqlDate;
}
const parsedDateTime = DateTime.fromMillis(Date.parse(dateString));
const parsedDateTime = DateTime.fromMillis(Date.parse(dateString), { zone: defaultZone });
if (parsedDateTime.isValid) {
return parsedDateTime;
}

View File

@@ -1,6 +1,6 @@
import { DateTime } from 'luxon';
import { DateTime, Settings } from 'luxon';
import { getValueDescription, validateFieldType } from '@/TypeValidation';
import { getValueDescription, tryToParseDateTime, validateFieldType } from '@/TypeValidation';
describe('Type Validation', () => {
describe('Dates', () => {
@@ -292,4 +292,39 @@ describe('Type Validation', () => {
expect(getValueDescription({})).toBe('object');
});
});
describe('tryToParseDateTime', () => {
it('should NOT use defaultZone if set', () => {
const result = tryToParseDateTime('2025-04-17T06:22:20-04:00', 'Europe/Brussels');
expect(result.zoneName).toEqual('UTC-4');
expect(result.toISO()).toEqual('2025-04-17T06:22:20.000-04:00');
});
it('should use defaultZone if timezone is not set', () => {
const result = tryToParseDateTime('2025-04-17T06:22:20', 'Europe/Brussels');
expect(result.zoneName).toEqual('Europe/Brussels');
expect(result.toISO()).toEqual('2025-04-17T06:22:20.000+02:00');
});
it('should use the system timezone when defaultZone arg is not given', () => {
Settings.defaultZone = 'UTC-7';
const result = tryToParseDateTime('2025-04-17T06:22:20');
expect(result.zoneName).toEqual('UTC-7');
expect(result.toISO()).toEqual('2025-04-17T06:22:20.000-07:00');
});
it('should not impact DateTime zone', () => {
const dateTime = DateTime.fromObject(
{ year: 2025, month: 1, day: 1 },
{ zone: 'Asia/Tokyo' },
);
const result = tryToParseDateTime(dateTime, 'Europe/Brussels');
expect(result.zoneName).toEqual('Asia/Tokyo');
expect(result.toISO()).toEqual('2025-01-01T00:00:00.000+09:00');
});
});
});