n8n/packages/workflow/test/RoutingNode.test.ts
Jan Oberhauser 4e568631be
feat(core): Add batching and other options to declarative nodes (#8885)
Co-authored-by: Michael Kret <michael.k@radency.com>
2024-06-07 08:39:31 +03:00

2152 lines
47 KiB
TypeScript

import type {
INode,
INodeExecutionData,
INodeParameters,
DeclarativeRestApiSettings,
IRunExecutionData,
INodeProperties,
IExecuteSingleFunctions,
IHttpRequestOptions,
ITaskDataConnections,
INodeExecuteFunctions,
IN8nRequestOperations,
INodeCredentialDescription,
IExecuteData,
INodeTypeDescription,
IWorkflowExecuteAdditionalData,
IExecuteFunctions,
} from '@/Interfaces';
import { RoutingNode } from '@/RoutingNode';
import { Workflow } from '@/Workflow';
import * as utilsModule from '@/utils';
import * as Helpers from './Helpers';
import { applyDeclarativeNodeOptionParameters } from '@/NodeHelpers';
import { mock } from 'jest-mock-extended';
const postReceiveFunction1 = async function (
this: IExecuteSingleFunctions,
items: INodeExecutionData[],
): Promise<INodeExecutionData[]> {
items.forEach((item) => (item.json1 = { success: true }));
return items;
};
const preSendFunction1 = async function (
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
requestOptions.headers = requestOptions.headers || {};
requestOptions.headers.addedIn = 'preSendFunction1';
return requestOptions;
};
describe('RoutingNode', () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>();
test('applyDeclarativeNodeOptionParameters', () => {
const nodeTypes = Helpers.NodeTypes();
const nodeType = nodeTypes.getByNameAndVersion('test.setMulti');
applyDeclarativeNodeOptionParameters(nodeType);
const options = nodeType.description.properties.find(
(property) => property.name === 'requestOptions',
);
expect(options?.options).toBeDefined;
const optionNames = options!.options!.map((option) => option.name);
expect(optionNames).toEqual(['batching', 'allowUnauthorizedCerts', 'proxy', 'timeout']);
});
describe('getRequestOptionsFromParameters', () => {
const tests: Array<{
description: string;
input: {
nodeParameters: INodeParameters;
nodeTypeProperties: INodeProperties;
};
output: DeclarativeRestApiSettings.ResultOptions | undefined;
}> = [
{
description: 'single parameter, only send defined, fixed value',
input: {
nodeParameters: {},
nodeTypeProperties: {
displayName: 'Email',
name: 'email',
type: 'string',
routing: {
send: {
property: 'toEmail',
type: 'body',
value: 'fixedValue',
},
},
default: '',
},
},
output: {
options: {
qs: {},
body: {
toEmail: 'fixedValue',
},
headers: {},
},
preSend: [],
postReceive: [],
requestOperations: {},
},
},
{
description: 'single parameter, only send defined, using expression',
input: {
nodeParameters: {
email: 'test@test.com',
},
nodeTypeProperties: {
displayName: 'Email',
name: 'email',
type: 'string',
routing: {
send: {
property: 'toEmail',
type: 'body',
value: '={{$value.toUpperCase()}}',
},
},
default: '',
},
},
output: {
options: {
qs: {},
body: {
toEmail: 'TEST@TEST.COM',
},
headers: {},
},
preSend: [],
postReceive: [],
requestOperations: {},
},
},
{
description: 'single parameter, send and operations defined, fixed value',
input: {
nodeParameters: {},
nodeTypeProperties: {
displayName: 'Email',
name: 'email',
type: 'string',
routing: {
send: {
property: 'toEmail',
type: 'body',
value: 'fixedValue',
},
operations: {
pagination: {
type: 'offset',
properties: {
limitParameter: 'limit',
offsetParameter: 'offset',
pageSize: 10,
rootProperty: 'data',
type: 'body',
},
},
},
},
default: '',
},
},
output: {
options: {
qs: {},
body: {
toEmail: 'fixedValue',
},
headers: {},
},
preSend: [],
postReceive: [],
requestOperations: {
pagination: {
type: 'offset',
properties: {
limitParameter: 'limit',
offsetParameter: 'offset',
pageSize: 10,
rootProperty: 'data',
type: 'body',
},
},
},
},
},
{
description: 'multiple parameters, complex example with everything',
input: {
nodeParameters: {
multipleFields: {
value1: 'v1',
value2: 'v2',
value3: 'v3',
value4: 4,
value6: 'value1,value2',
value7: 'value3,value4',
lowerLevel: {
lowLevelValue1: 1,
lowLevelValue2: 'llv2',
},
customPropertiesSingle1: {
property: {
name: 'cSName1',
value: 'cSValue1',
},
},
customPropertiesMulti: {
property0: [
{
name: 'cM0Name1',
value: 'cM0Value1',
},
{
name: 'cM0Name2',
value: 'cM0Value2',
},
],
property1: [
{
name: 'cM1Name2',
value: 'cM1Value2',
},
{
name: 'cM1Name2',
value: 'cM1Value2',
},
],
},
},
},
nodeTypeProperties: {
displayName: 'Multiple Fields',
name: 'multipleFields',
type: 'collection',
placeholder: 'Add Field',
routing: {
request: {
method: 'GET',
url: '/destination1',
},
operations: {
pagination: {
type: 'offset',
properties: {
limitParameter: 'limit1',
offsetParameter: 'offset1',
pageSize: 1,
rootProperty: 'data1',
type: 'body',
},
},
},
output: {
maxResults: 10,
postReceive: [postReceiveFunction1],
},
},
default: {},
options: [
{
displayName: 'Value 1',
name: 'value1',
type: 'string',
routing: {
send: {
property: 'value1',
type: 'body',
},
},
default: '',
},
{
displayName: 'Value 2',
name: 'value2',
type: 'string',
routing: {
send: {
property: 'topLevel.value2',
propertyInDotNotation: false,
type: 'body',
preSend: [preSendFunction1],
},
},
default: '',
},
{
displayName: 'Value 3',
name: 'value3',
type: 'string',
routing: {
send: {
property: 'lowerLevel.value3',
type: 'body',
},
},
default: '',
},
{
displayName: 'Value 4',
name: 'value4',
type: 'number',
default: 0,
routing: {
send: {
property: 'value4',
type: 'query',
},
output: {
maxResults: '={{$value}}',
},
operations: {
pagination: {
type: 'offset',
properties: {
limitParameter: 'limit100',
offsetParameter: 'offset100',
pageSize: 100,
rootProperty: 'data100',
type: 'query',
},
},
},
},
},
// This one should not be included
{
displayName: 'Value 5',
name: 'value5',
type: 'number',
displayOptions: {
show: {
value4: [1],
},
},
default: 5,
routing: {
send: {
property: 'value5',
type: 'query',
},
operations: {
pagination: {
type: 'offset',
properties: {
limitParameter: 'limit10',
offsetParameter: 'offset10',
pageSize: 10,
rootProperty: 'data10',
type: 'body',
},
},
},
},
},
// Test resolve of value and properties including as objects
{
displayName: 'Value 6',
name: 'value6',
type: 'string',
routing: {
send: {
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
property: '={{ `value${5+1}A` }}',
type: 'query',
value: '={{$value.toUpperCase()}}',
},
},
default: '',
},
{
displayName: 'Value 7',
name: 'value7',
type: 'string',
routing: {
send: {
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
property: '={{ `value${6+1}B` }}',
type: 'body',
value: "={{$value.split(',')}}",
},
},
default: '',
},
{
displayName: 'Lower Level',
name: 'lowerLevel',
type: 'collection',
placeholder: 'Add Field',
default: {},
options: [
{
displayName: 'Low Level Value1',
name: 'lowLevelValue1',
type: 'number',
default: 0,
routing: {
send: {
property: 'llvalue1',
type: 'query',
},
},
},
{
displayName: 'Low Level Value2',
name: 'lowLevelValue2',
type: 'string',
default: '',
routing: {
send: {
property: 'llvalue2',
type: 'query',
preSend: [preSendFunction1],
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'data',
},
},
],
},
},
},
],
},
// Test fixed collection1: multipleValues=false
{
displayName: 'Custom Properties1 (single)',
name: 'customPropertiesSingle1',
placeholder: 'Add Custom Property',
type: 'fixedCollection',
default: {},
options: [
{
name: 'property',
displayName: 'Property',
values: [
// To set: { single-customValues: { name: 'name', value: 'value'} }
{
displayName: 'Property Name',
name: 'name',
type: 'string',
default: '',
routing: {
request: {
method: 'POST',
url: '=/{{$value}}',
},
send: {
property: 'single-customValues.name',
},
},
},
{
displayName: 'Property Value',
name: 'value',
type: 'string',
default: '',
routing: {
send: {
property: 'single-customValues.value',
},
},
},
],
},
],
},
// Test fixed collection: multipleValues=true
{
displayName: 'Custom Properties (multi)',
name: 'customPropertiesMulti',
placeholder: 'Add Custom Property',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'property0',
displayName: 'Property0',
values: [
// To set: { name0: 'value0', name1: 'value1' }
{
displayName: 'Property Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the property to set.',
},
{
displayName: 'Property Value',
name: 'value',
type: 'string',
default: '',
routing: {
send: {
property: '=customMulti0.{{$parent.name}}',
type: 'body',
},
},
description: 'Value of the property to set.',
},
],
},
{
name: 'property1',
displayName: 'Property1',
values: [
// To set: { customValues: [ { name: 'name0', value: 'value0'}, { name: 'name1', value: 'value1'} ]}
{
displayName: 'Property Name',
name: 'name',
type: 'string',
default: '',
routing: {
send: {
property: '=customMulti1[{{$index}}].name',
type: 'body',
},
},
},
{
displayName: 'Property Value',
name: 'value',
type: 'string',
default: '',
routing: {
send: {
property: '=customMulti1[{{$index}}].value',
type: 'body',
},
},
},
],
},
],
},
],
},
},
output: {
maxResults: 4,
options: {
method: 'POST',
url: '/cSName1',
qs: {
value4: 4,
llvalue1: 1,
llvalue2: 'llv2',
'single-customValues': {
name: 'cSName1',
value: 'cSValue1',
},
value6A: 'VALUE1,VALUE2',
},
body: {
value1: 'v1',
'topLevel.value2': 'v2',
value7B: ['value3', 'value4'],
lowerLevel: {
value3: 'v3',
},
customMulti0: {
cM0Name1: 'cM0Value1',
cM0Name2: 'cM0Value2',
},
customMulti1: [
{
name: 'cM1Name2',
value: 'cM1Value2',
},
{
name: 'cM1Name2',
value: 'cM1Value2',
},
],
},
headers: {},
},
preSend: [preSendFunction1, preSendFunction1],
postReceive: [
{
actions: [postReceiveFunction1],
data: {
parameterValue: {
value1: 'v1',
value2: 'v2',
value3: 'v3',
value4: 4,
value6: 'value1,value2',
value7: 'value3,value4',
lowerLevel: {
lowLevelValue1: 1,
lowLevelValue2: 'llv2',
},
customPropertiesSingle1: {
property: {
name: 'cSName1',
value: 'cSValue1',
},
},
customPropertiesMulti: {
property0: [
{
name: 'cM0Name1',
value: 'cM0Value1',
},
{
name: 'cM0Name2',
value: 'cM0Value2',
},
],
property1: [
{
name: 'cM1Name2',
value: 'cM1Value2',
},
{
name: 'cM1Name2',
value: 'cM1Value2',
},
],
},
},
},
},
{
actions: [
{
type: 'rootProperty',
properties: {
property: 'data',
},
},
],
data: {
parameterValue: 'llv2',
},
},
],
requestOperations: {
pagination: {
type: 'offset',
properties: {
limitParameter: 'limit100',
offsetParameter: 'offset100',
pageSize: 100,
rootProperty: 'data100',
type: 'query',
},
},
},
},
},
];
const nodeTypes = Helpers.NodeTypes();
const node: INode = {
parameters: {},
name: 'test',
type: 'test.set',
typeVersion: 1,
id: 'uuid-1234',
position: [0, 0],
};
const mode = 'internal';
const runIndex = 0;
const itemIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
const runExecutionData: IRunExecutionData = { resultData: { runData: {} } };
const path = '';
const nodeType = nodeTypes.getByNameAndVersion(node.type);
const workflowData = {
nodes: [node],
connections: {},
};
for (const testData of tests) {
test(testData.description, async () => {
node.parameters = testData.input.nodeParameters;
nodeType.description.properties = [testData.input.nodeTypeProperties];
const workflow = new Workflow({
nodes: workflowData.nodes,
connections: workflowData.connections,
active: false,
nodeTypes,
});
const routingNode = new RoutingNode(
workflow,
node,
connectionInputData,
runExecutionData ?? null,
additionalData,
mode,
);
const executeSingleFunctions = Helpers.getExecuteSingleFunctions(
workflow,
runExecutionData,
runIndex,
node,
itemIndex,
);
const result = routingNode.getRequestOptionsFromParameters(
executeSingleFunctions,
testData.input.nodeTypeProperties,
itemIndex,
runIndex,
path,
{},
);
expect(result).toEqual(testData.output);
});
}
});
describe('runNode', () => {
const tests: Array<{
description: string;
input: {
specialTestOptions?: {
applyDeclarativeNodeOptionParameters?: boolean;
numberOfItems?: number;
sleepCalls?: number[][];
};
nodeType: {
properties?: INodeProperties[];
credentials?: INodeCredentialDescription[];
requestDefaults?: IHttpRequestOptions;
requestOperations?: IN8nRequestOperations;
};
node: {
parameters: INodeParameters;
};
};
output: INodeExecutionData[][] | undefined;
}> = [
{
description: 'single parameter, only send defined, fixed value, using requestDefaults',
input: {
nodeType: {
requestDefaults: {
baseURL: 'http://127.0.0.1:5678',
url: '/test-url',
},
properties: [
{
displayName: 'Email',
name: 'email',
type: 'string',
routing: {
send: {
property: 'toEmail',
type: 'body',
value: 'fixedValue',
},
},
default: '',
},
],
},
node: {
parameters: {},
},
},
output: [
[
{
json: {
headers: {},
statusCode: 200,
requestOptions: {
url: '/test-url',
headers: {},
qs: {},
body: {
toEmail: 'fixedValue',
},
baseURL: 'http://127.0.0.1:5678',
returnFullResponse: true,
timeout: 300000,
},
},
},
],
],
},
{
description: 'single parameter, only send defined, fixed value, using requestDefaults',
input: {
nodeType: {
requestDefaults: {
baseURL: 'http://127.0.0.1:5678',
url: '/test-url',
},
properties: [
{
displayName: 'Email',
name: 'email',
type: 'string',
routing: {
send: {
property: 'toEmail',
type: 'body',
value: 'fixedValue',
},
},
default: '',
},
],
},
node: {
parameters: {},
},
},
output: [
[
{
json: {
headers: {},
statusCode: 200,
requestOptions: {
url: '/test-url',
headers: {},
qs: {},
body: {
toEmail: 'fixedValue',
},
baseURL: 'http://127.0.0.1:5678',
returnFullResponse: true,
timeout: 300000,
},
},
},
],
],
},
{
description:
'single parameter, only send defined, using expression, using requestDefaults with overwrite',
input: {
node: {
parameters: {
email: 'test@test.com',
},
},
nodeType: {
requestDefaults: {
baseURL: 'http://127.0.0.1:5678',
url: '/test-url',
},
properties: [
{
displayName: 'Email',
name: 'email',
type: 'string',
routing: {
send: {
property: 'toEmail',
type: 'body',
value: '={{$value.toUpperCase()}}',
},
request: {
url: '/overwritten',
},
},
default: '',
},
],
},
},
output: [
[
{
json: {
headers: {},
statusCode: 200,
requestOptions: {
url: '/overwritten',
headers: {},
qs: {},
body: {
toEmail: 'TEST@TEST.COM',
},
baseURL: 'http://127.0.0.1:5678',
returnFullResponse: true,
timeout: 300000,
},
},
},
],
],
},
{
description:
'single parameter, only send defined, using expression, using requestDefaults with overwrite and expressions',
input: {
node: {
parameters: {
endpoint: 'custom-overwritten',
},
},
nodeType: {
requestDefaults: {
baseURL: 'http://127.0.0.1:5678',
url: '/test-url',
},
properties: [
{
displayName: 'Endpoint',
name: 'endpoint',
type: 'string',
routing: {
send: {
property: '={{"theProperty"}}',
type: 'body',
value: '={{$value}}',
},
request: {
url: '=/{{$value}}',
},
},
default: '',
},
],
},
},
output: [
[
{
json: {
headers: {},
statusCode: 200,
requestOptions: {
url: '/custom-overwritten',
headers: {},
qs: {},
body: {
theProperty: 'custom-overwritten',
},
baseURL: 'http://127.0.0.1:5678',
returnFullResponse: true,
timeout: 300000,
},
},
},
],
],
},
{
description: 'single parameter, send and operations defined, fixed value with pagination',
input: {
node: {
parameters: {},
},
nodeType: {
properties: [
{
displayName: 'Email',
name: 'email',
type: 'string',
routing: {
send: {
property: 'toEmail',
type: 'body',
value: 'fixedValue',
paginate: true,
},
operations: {
pagination: {
type: 'offset',
properties: {
limitParameter: 'limit',
offsetParameter: 'offset',
pageSize: 10,
type: 'body',
},
},
},
},
default: '',
},
],
},
},
output: [
[
{
json: {
headers: {},
statusCode: 200,
requestOptions: {
qs: {},
headers: {},
body: {
toEmail: 'fixedValue',
limit: 10,
offset: 10,
},
returnFullResponse: true,
timeout: 300000,
},
},
},
],
],
},
{
description: 'multiple parameters, from applyDeclarativeNodeOptionParameters',
input: {
specialTestOptions: {
applyDeclarativeNodeOptionParameters: true,
numberOfItems: 5,
sleepCalls: [[500], [500]],
},
node: {
parameters: {
requestOptions: {
allowUnauthorizedCerts: true,
batching: {
batch: {
batchSize: 2,
batchInterval: 500,
},
},
proxy: 'http://user:password@127.0.0.1:8080',
timeout: 123,
},
},
},
nodeType: {
properties: [],
},
},
output: [
[
{
json: {
headers: {},
statusCode: 200,
requestOptions: {
qs: {},
headers: {},
proxy: {
auth: {
username: 'user',
password: 'password',
},
host: '127.0.0.1',
protocol: 'http',
port: 8080,
},
body: {},
returnFullResponse: true,
skipSslCertificateValidation: true,
timeout: 123,
},
},
},
{
json: {
headers: {},
statusCode: 200,
requestOptions: {
qs: {},
headers: {},
proxy: {
auth: {
username: 'user',
password: 'password',
},
host: '127.0.0.1',
protocol: 'http',
port: 8080,
},
body: {},
returnFullResponse: true,
skipSslCertificateValidation: true,
timeout: 123,
},
},
},
{
json: {
headers: {},
statusCode: 200,
requestOptions: {
qs: {},
headers: {},
proxy: {
auth: {
username: 'user',
password: 'password',
},
host: '127.0.0.1',
protocol: 'http',
port: 8080,
},
body: {},
returnFullResponse: true,
skipSslCertificateValidation: true,
timeout: 123,
},
},
},
{
json: {
headers: {},
statusCode: 200,
requestOptions: {
qs: {},
headers: {},
proxy: {
auth: {
username: 'user',
password: 'password',
},
host: '127.0.0.1',
protocol: 'http',
port: 8080,
},
body: {},
returnFullResponse: true,
skipSslCertificateValidation: true,
timeout: 123,
},
},
},
{
json: {
headers: {},
statusCode: 200,
requestOptions: {
qs: {},
headers: {},
proxy: {
auth: {
username: 'user',
password: 'password',
},
host: '127.0.0.1',
protocol: 'http',
port: 8080,
},
body: {},
returnFullResponse: true,
skipSslCertificateValidation: true,
timeout: 123,
},
},
},
],
],
},
{
description: 'multiple parameters, complex example with everything',
input: {
node: {
parameters: {
value1: '={{"test"}}',
multipleFields: {
value1: 'v1',
value2: 'v2',
value3: 'v3',
value4: 4,
lowerLevel: {
lowLevelValue1: 1,
lowLevelValue2: 'llv2',
},
customPropertiesSingle1: {
property: {
name: 'cSName1',
value: 'cSValue1',
},
},
customPropertiesMulti: {
property0: [
{
name: 'cM0Name1',
value: 'cM0Value1',
},
{
name: 'cM0Name2',
value: 'cM0Value2',
},
],
property1: [
{
name: 'cM1Name2',
value: 'cM1Value2',
},
{
name: 'cM1Name2',
value: 'cM1Value2',
},
],
},
customPropertiesMultiExp: {
property0: [
{
name: '={{$parameter["value1"]}}N',
value: '={{$parameter["value1"]}}V',
},
],
},
},
},
},
nodeType: {
properties: [
{
displayName: 'Value 1',
name: 'value1',
type: 'string',
default: '',
},
{
displayName: 'Multiple Fields',
name: 'multipleFields',
type: 'collection',
placeholder: 'Add Field',
routing: {
request: {
method: 'GET',
url: '/destination1',
},
operations: {
pagination: {
type: 'offset',
properties: {
limitParameter: 'limit1',
offsetParameter: 'offset1',
pageSize: 1,
rootProperty: 'data1',
type: 'body',
},
},
},
output: {
maxResults: 10,
postReceive: [postReceiveFunction1],
},
},
default: {},
options: [
{
displayName: 'Value 1',
name: 'value1',
type: 'string',
routing: {
send: {
property: 'value1',
type: 'body',
},
},
default: '',
},
{
displayName: 'Value 2',
name: 'value2',
type: 'string',
routing: {
send: {
property: 'topLevel.value2',
propertyInDotNotation: false,
type: 'body',
preSend: [preSendFunction1],
},
},
default: '',
},
{
displayName: 'Value 3',
name: 'value3',
type: 'string',
routing: {
send: {
property: 'lowerLevel.value3',
type: 'body',
},
},
default: '',
},
{
displayName: 'Value 4',
name: 'value4',
type: 'number',
default: 0,
routing: {
send: {
property: 'value4',
type: 'query',
},
output: {
maxResults: '={{$value}}',
},
operations: {
pagination: {
type: 'offset',
properties: {
limitParameter: 'limit100',
offsetParameter: 'offset100',
pageSize: 100,
rootProperty: 'data100',
type: 'query',
},
},
},
},
},
// This one should not be included
{
displayName: 'Value 5',
name: 'value5',
type: 'number',
displayOptions: {
show: {
value4: [1],
},
},
default: 5,
routing: {
send: {
property: 'value5',
type: 'query',
},
operations: {
pagination: {
type: 'offset',
properties: {
limitParameter: 'limit10',
offsetParameter: 'offset10',
pageSize: 10,
rootProperty: 'data10',
type: 'body',
},
},
},
},
},
{
displayName: 'Lower Level',
name: 'lowerLevel',
type: 'collection',
placeholder: 'Add Field',
default: {},
options: [
{
displayName: 'Low Level Value1',
name: 'lowLevelValue1',
type: 'number',
default: 0,
routing: {
send: {
property: 'llvalue1',
type: 'query',
},
},
},
{
displayName: 'Low Level Value2',
name: 'lowLevelValue2',
type: 'string',
default: '',
routing: {
send: {
property: 'llvalue2',
type: 'query',
preSend: [preSendFunction1],
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'requestOptions',
},
},
],
},
},
},
],
},
// Test fixed collection1: multipleValues=false
{
displayName: 'Custom Properties1 (single)',
name: 'customPropertiesSingle1',
placeholder: 'Add Custom Property',
type: 'fixedCollection',
default: {},
options: [
{
name: 'property',
displayName: 'Property',
values: [
// To set: { single-customValues: { name: 'name', value: 'value'} }
{
displayName: 'Property Name',
name: 'name',
type: 'string',
default: '',
routing: {
request: {
method: 'POST',
url: '=/{{$value}}',
},
send: {
property: 'single-customValues.name',
},
},
},
{
displayName: 'Property Value',
name: 'value',
type: 'string',
default: '',
routing: {
send: {
property: 'single-customValues.value',
},
},
},
],
},
],
},
// Test fixed collection: multipleValues=true
{
displayName: 'Custom Properties (multi)',
name: 'customPropertiesMulti',
placeholder: 'Add Custom Property',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'property0',
displayName: 'Property0',
values: [
// To set: { name0: 'value0', name1: 'value1' }
{
displayName: 'Property Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the property to set.',
},
{
displayName: 'Property Value',
name: 'value',
type: 'string',
default: '',
routing: {
send: {
property: '=customMulti0.{{$parent.name}}',
type: 'body',
},
},
description: 'Value of the property to set.',
},
],
},
{
name: 'property1',
displayName: 'Property1',
values: [
// To set: { customValues: [ { name: 'name0', value: 'value0'}, { name: 'name1', value: 'value1'} ]}
{
displayName: 'Property Name',
name: 'name',
type: 'string',
default: '',
routing: {
send: {
property: '=customMulti1[{{$index}}].name',
type: 'body',
},
},
},
{
displayName: 'Property Value',
name: 'value',
type: 'string',
default: '',
routing: {
send: {
property: '=customMulti1[{{$index}}].value',
type: 'body',
},
},
},
],
},
],
},
// Test fixed collection: multipleValues=true with expression which references an expression
{
displayName: 'Custom Properties (multi)',
name: 'customPropertiesMultiExp',
placeholder: 'Add Custom Property',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'property0',
displayName: 'Property0',
values: [
// To set: { name0: 'value0', name1: 'value1' }
{
displayName: 'Property Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the property to set.',
},
{
displayName: 'Property Value',
name: 'value',
type: 'string',
default: '',
routing: {
send: {
property: '={{$parent.name}}',
type: 'body',
},
},
description: 'Value of the property to set.',
},
],
},
],
},
],
},
],
},
},
output: [
[
{
json: {
url: '/cSName1',
qs: {
value4: 4,
llvalue1: 1,
llvalue2: 'llv2',
'single-customValues': {
name: 'cSName1',
value: 'cSValue1',
},
},
body: {
value1: 'v1',
'topLevel.value2': 'v2',
lowerLevel: {
value3: 'v3',
},
customMulti0: {
cM0Name1: 'cM0Value1',
cM0Name2: 'cM0Value2',
},
customMulti1: [
{
name: 'cM1Name2',
value: 'cM1Value2',
},
{
name: 'cM1Name2',
value: 'cM1Value2',
},
],
testN: 'testV',
},
method: 'POST',
headers: {
addedIn: 'preSendFunction1',
},
returnFullResponse: true,
timeout: 300000,
},
},
],
],
},
{
description: 'single parameter, postReceive: set',
input: {
nodeType: {
requestDefaults: {
baseURL: 'http://127.0.0.1:5678',
url: '/test-url',
},
properties: [
{
displayName: 'JSON Data',
name: 'jsonData',
type: 'string',
routing: {
send: {
property: 'jsonData',
type: 'body',
},
output: {
postReceive: [
{
type: 'set',
properties: {
value: '={{ { "value": $value, "response": $response } }}',
},
},
],
},
},
default: '',
},
],
},
node: {
parameters: {
jsonData: {
root: [
{
name: 'Jim',
age: 34,
},
{
name: 'James',
age: 44,
},
],
},
},
},
},
output: [
[
{
json: {
value: {
root: [
{
name: 'Jim',
age: 34,
},
{
name: 'James',
age: 44,
},
],
},
response: {
body: {
headers: {},
statusCode: 200,
requestOptions: {
headers: {},
qs: {},
body: {
jsonData: {
root: [
{
name: 'Jim',
age: 34,
},
{
name: 'James',
age: 44,
},
],
},
},
baseURL: 'http://127.0.0.1:5678',
url: '/test-url',
returnFullResponse: true,
timeout: 300000,
},
},
},
},
},
],
],
},
{
description: 'single parameter, postReceive: rootProperty',
input: {
nodeType: {
requestDefaults: {
baseURL: 'http://127.0.0.1:5678',
url: '/test-url',
},
properties: [
{
displayName: 'JSON Data',
name: 'jsonData',
type: 'string',
routing: {
send: {
property: 'jsonData',
type: 'body',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'requestOptions',
},
},
{
type: 'rootProperty',
properties: {
property: 'body.jsonData.root',
},
},
],
},
},
default: '',
},
],
},
node: {
parameters: {
jsonData: {
root: [
{
name: 'Jim',
age: 34,
},
{
name: 'James',
age: 44,
},
],
},
},
},
},
output: [
[
{
json: {
name: 'Jim',
age: 34,
},
},
{
json: {
name: 'James',
age: 44,
},
},
],
],
},
{
description: 'single parameter, multiple postReceive: rootProperty, setKeyValue, sort',
input: {
nodeType: {
requestDefaults: {
baseURL: 'http://127.0.0.1:5678',
url: '/test-url',
},
properties: [
{
displayName: 'JSON Data',
name: 'jsonData',
type: 'string',
routing: {
send: {
property: 'jsonData',
type: 'body',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'requestOptions.body.jsonData.root',
},
},
{
type: 'setKeyValue',
properties: {
display1: '={{$responseItem.name}} ({{$responseItem.age}})',
display2: '={{$responseItem.name}} is {{$responseItem.age}}',
},
},
{
type: 'sort',
properties: {
key: 'display1',
},
},
],
},
},
default: '',
},
],
},
node: {
parameters: {
jsonData: {
root: [
{
name: 'Jim',
age: 34,
},
{
name: 'James',
age: 44,
},
],
},
},
},
},
output: [
[
{
json: {
display1: 'James (44)',
display2: 'James is 44',
},
},
{
json: {
display1: 'Jim (34)',
display2: 'Jim is 34',
},
},
],
],
},
];
const nodeTypes = Helpers.NodeTypes();
const baseNode: INode = {
parameters: {},
name: 'test',
type: 'test.set',
typeVersion: 1,
id: 'uuid-1234',
position: [0, 0],
};
const mode = 'internal';
const runIndex = 0;
const itemIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
const runExecutionData: IRunExecutionData = { resultData: { runData: {} } };
const nodeType = nodeTypes.getByNameAndVersion(baseNode.type);
applyDeclarativeNodeOptionParameters(nodeType);
const propertiesOriginal = nodeType.description.properties;
const inputData: ITaskDataConnections = {
main: [[]],
};
for (const testData of tests) {
test(testData.description, async () => {
const node: INode = { ...baseNode, ...testData.input.node };
const workflowData = {
nodes: [node],
connections: {},
};
nodeType.description = { ...testData.input.nodeType } as INodeTypeDescription;
if (testData.input.specialTestOptions?.applyDeclarativeNodeOptionParameters) {
nodeType.description.properties = propertiesOriginal;
}
const workflow = new Workflow({
nodes: workflowData.nodes,
connections: workflowData.connections,
active: false,
nodeTypes,
});
const routingNode = new RoutingNode(
workflow,
node,
connectionInputData,
runExecutionData ?? null,
additionalData,
mode,
);
const executeData = {
data: {},
node,
source: null,
} as IExecuteData;
const executeFunctions = mock<IExecuteFunctions>();
const executeSingleFunctions = Helpers.getExecuteSingleFunctions(
workflow,
runExecutionData,
runIndex,
node,
itemIndex,
);
const nodeExecuteFunctions: Partial<INodeExecuteFunctions> = {
getExecuteFunctions: () => executeFunctions,
getExecuteSingleFunctions: () => executeSingleFunctions,
};
const numberOfItems = testData.input.specialTestOptions?.numberOfItems ?? 1;
if (!inputData.main[0] || inputData.main[0].length !== numberOfItems) {
inputData.main[0] = [];
for (let i = 0; i < numberOfItems; i++) {
inputData.main[0].push({ json: {} });
}
}
const spy = jest.spyOn(utilsModule, 'sleep').mockReturnValue(
new Promise((resolve) => {
resolve();
}),
);
spy.mockClear();
executeFunctions.getNodeParameter.mockImplementation(
(parameterName: string) => testData.input.node.parameters[parameterName] || {},
);
const getNodeParameter = executeSingleFunctions.getNodeParameter;
executeSingleFunctions.getNodeParameter = (parameterName: string) =>
parameterName in testData.input.node.parameters
? testData.input.node.parameters[parameterName]
: getNodeParameter(parameterName) ?? {};
const result = await routingNode.runNode(
inputData,
runIndex,
nodeType,
executeData,
nodeExecuteFunctions as INodeExecuteFunctions,
);
if (testData.input.specialTestOptions?.sleepCalls) {
expect(spy.mock.calls).toEqual(testData.input.specialTestOptions?.sleepCalls);
} else {
expect(spy).toHaveBeenCalledTimes(0);
}
expect(result).toEqual(testData.output);
});
}
});
describe('itemIndex', () => {
const tests: Array<{
description: string;
input: {
nodeType: Partial<INodeTypeDescription>;
node: {
parameters: INodeParameters;
};
};
output: INodeExecutionData[][] | undefined;
}> = [
{
description: 'single parameter, only send defined, fixed value, using requestDefaults',
input: {
nodeType: {
requestDefaults: {
baseURL: 'http://127.0.0.1:5678',
url: '/test-url',
},
properties: [
{
displayName: 'Email',
name: 'email',
type: 'string',
routing: {
send: {
property: 'toEmail',
type: 'body',
value: 'fixedValue',
},
},
default: '',
},
],
},
node: {
parameters: {},
},
},
output: [
[
{
json: {
headers: {},
statusCode: 200,
requestOptions: {
url: '/test-url',
qs: {},
body: {
toEmail: 'fixedValue',
},
baseURL: 'http://127.0.0.1:5678',
returnFullResponse: true,
timeout: 300000,
},
},
},
],
],
},
];
const nodeTypes = Helpers.NodeTypes();
const baseNode: INode = {
parameters: {},
name: 'test',
type: 'test.set',
typeVersion: 1,
id: 'uuid-1234',
position: [0, 0],
};
const mode = 'internal';
const runIndex = 0;
const itemIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
const runExecutionData: IRunExecutionData = { resultData: { runData: {} } };
const nodeType = nodeTypes.getByNameAndVersion(baseNode.type);
const inputData: ITaskDataConnections = {
main: [
[
{
json: {},
},
{
json: {},
},
{
json: {},
},
],
],
};
for (const testData of tests) {
test(testData.description, async () => {
const node: INode = { ...baseNode, ...testData.input.node };
const workflowData = {
nodes: [node],
connections: {},
};
nodeType.description = { ...testData.input.nodeType } as INodeTypeDescription;
const workflow = new Workflow({
nodes: workflowData.nodes,
connections: workflowData.connections,
active: false,
nodeTypes,
});
const routingNode = new RoutingNode(
workflow,
node,
connectionInputData,
runExecutionData ?? null,
additionalData,
mode,
);
const executeData = {
data: {},
node,
source: null,
} as IExecuteData;
let currentItemIndex = 0;
for (let iteration = 0; iteration < inputData.main[0]!.length; iteration++) {
const nodeExecuteFunctions: Partial<INodeExecuteFunctions> = {
getExecuteSingleFunctions: () => {
return Helpers.getExecuteSingleFunctions(
workflow,
runExecutionData,
runIndex,
node,
itemIndex + iteration,
);
},
};
if (!nodeExecuteFunctions.getExecuteSingleFunctions) {
fail('Expected nodeExecuteFunctions to contain getExecuteSingleFunctions');
}
const routingNodeExecutionContext = nodeExecuteFunctions.getExecuteSingleFunctions(
routingNode.workflow,
routingNode.runExecutionData,
runIndex,
routingNode.connectionInputData,
inputData,
routingNode.node,
iteration,
routingNode.additionalData,
executeData,
routingNode.mode,
);
currentItemIndex = routingNodeExecutionContext.getItemIndex();
}
const expectedItemIndex = inputData.main[0]!.length - 1;
expect(currentItemIndex).toEqual(expectedItemIndex);
});
}
});
});