mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-31 04:55:25 +02:00
1445 lines
43 KiB
TypeScript
1445 lines
43 KiB
TypeScript
import FormData from 'form-data';
|
|
import type {
|
|
IExecuteFunctions,
|
|
INodeTypeBaseDescription,
|
|
JsonObject,
|
|
JsonValue,
|
|
} from 'n8n-workflow';
|
|
|
|
import { HttpRequestV3 } from '../../V3/HttpRequestV3.node';
|
|
import { createErrorDetails } from '../../V3/utils/error-details';
|
|
import type { Mock } from 'vitest';
|
|
|
|
describe('HttpRequestV3', () => {
|
|
let node: HttpRequestV3;
|
|
let executeFunctions: IExecuteFunctions;
|
|
|
|
const baseUrl = 'http://example.com';
|
|
const options = {
|
|
redirect: '',
|
|
batching: { batch: { batchSize: 1, batchInterval: 1 } },
|
|
proxy: '',
|
|
timeout: '',
|
|
allowUnauthoridCerts: '',
|
|
queryParameterArrays: '',
|
|
response: '',
|
|
lowercaseHeaders: '',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
const baseDescription: INodeTypeBaseDescription = {
|
|
displayName: 'HTTP Request',
|
|
name: 'httpRequest',
|
|
description: 'Makes an HTTP request and returns the response data',
|
|
group: [],
|
|
};
|
|
node = new HttpRequestV3(baseDescription);
|
|
executeFunctions = {
|
|
getInputData: vi.fn(),
|
|
getNodeParameter: vi.fn(),
|
|
getNode: vi.fn(() => {
|
|
return {
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 3,
|
|
};
|
|
}),
|
|
getCredentials: vi.fn(),
|
|
helpers: {
|
|
request: vi.fn(),
|
|
requestOAuth1: vi.fn(
|
|
async () =>
|
|
await Promise.resolve({
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
}),
|
|
),
|
|
requestOAuth2: vi.fn(
|
|
async () =>
|
|
await Promise.resolve({
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
}),
|
|
),
|
|
requestWithAuthentication: vi.fn(),
|
|
requestWithAuthenticationPaginated: vi.fn(),
|
|
assertBinaryData: vi.fn(),
|
|
getBinaryStream: vi.fn(),
|
|
getBinaryMetadata: vi.fn(),
|
|
binaryToString: vi.fn((buffer: Buffer) => {
|
|
return buffer.toString();
|
|
}),
|
|
prepareBinaryData: vi.fn(),
|
|
},
|
|
getContext: vi.fn(),
|
|
sendMessageToUI: vi.fn(),
|
|
continueOnFail: vi.fn(),
|
|
getMode: vi.fn(),
|
|
} as unknown as IExecuteFunctions;
|
|
});
|
|
|
|
it('should make a GET request', async () => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
const response = {
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
};
|
|
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
|
|
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
|
|
});
|
|
|
|
it('should handle authentication', async () => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'genericCredentialType';
|
|
case 'genericAuthType':
|
|
return 'httpBasicAuth';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
(executeFunctions.getCredentials as Mock).mockResolvedValue({
|
|
user: 'username',
|
|
password: 'password',
|
|
});
|
|
const response = {
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
|
|
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
|
|
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
auth: {
|
|
user: 'username',
|
|
pass: 'password',
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should pass multipart binary uploads as FormData', async () => {
|
|
(executeFunctions.getNode as Mock).mockReturnValue({
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 4.4,
|
|
});
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'POST';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'sendBody':
|
|
return true;
|
|
case 'contentType':
|
|
return 'multipart-form-data';
|
|
case 'specifyBody':
|
|
return 'keypair';
|
|
case 'bodyParameters.parameters':
|
|
return [
|
|
{
|
|
parameterType: 'formBinaryData',
|
|
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
|
name: 'file',
|
|
value: '',
|
|
inputDataFieldName: 'data0',
|
|
},
|
|
];
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
(executeFunctions.helpers.assertBinaryData as Mock).mockReturnValue({
|
|
data: Buffer.from('test file').toString('base64'),
|
|
fileName: 'invoice.pdf',
|
|
mimeType: 'application/pdf',
|
|
});
|
|
const response = {
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
await node.execute.call(executeFunctions);
|
|
|
|
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
formData: expect.any(FormData),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should include a fallback filename for multipart binary uploads without fileName', async () => {
|
|
(executeFunctions.getNode as Mock).mockReturnValue({
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 4.4,
|
|
});
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'POST';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'sendBody':
|
|
return true;
|
|
case 'contentType':
|
|
return 'multipart-form-data';
|
|
case 'specifyBody':
|
|
return 'keypair';
|
|
case 'bodyParameters.parameters':
|
|
return [
|
|
{
|
|
parameterType: 'formBinaryData',
|
|
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
|
name: 'file',
|
|
value: '',
|
|
inputDataFieldName: 'data0',
|
|
},
|
|
];
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
(executeFunctions.helpers.assertBinaryData as Mock).mockReturnValue({
|
|
data: Buffer.from('test file').toString('base64'),
|
|
mimeType: 'application/pdf',
|
|
});
|
|
const response = {
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
await node.execute.call(executeFunctions);
|
|
|
|
const requestOptions = (executeFunctions.helpers.request as Mock).mock.calls[0][0];
|
|
const body = (requestOptions.formData as FormData).getBuffer().toString('utf8');
|
|
|
|
expect(body).toContain('filename="file"');
|
|
});
|
|
|
|
describe('Authentication Handling', () => {
|
|
const authenticationTypes = [
|
|
{
|
|
genericCredentialType: 'httpBasicAuth',
|
|
credentials: { user: 'username', password: 'password' },
|
|
authField: 'auth',
|
|
authValue: { user: 'username', pass: 'password' },
|
|
},
|
|
{
|
|
genericCredentialType: 'httpBearerAuth',
|
|
credentials: { token: 'bearerToken123' },
|
|
authField: 'headers',
|
|
authValue: { Authorization: 'Bearer bearerToken123' },
|
|
},
|
|
{
|
|
genericCredentialType: 'httpDigestAuth',
|
|
credentials: { user: 'username', password: 'password' },
|
|
authField: 'auth',
|
|
authValue: { user: 'username', pass: 'password', sendImmediately: false },
|
|
},
|
|
{
|
|
genericCredentialType: 'httpHeaderAuth',
|
|
credentials: { name: 'Authorization', value: 'Bearer token' },
|
|
authField: 'headers',
|
|
authValue: { Authorization: 'Bearer token' },
|
|
},
|
|
{
|
|
genericCredentialType: 'httpQueryAuth',
|
|
credentials: { name: 'Token', value: 'secretToken' },
|
|
authField: 'qs',
|
|
authValue: { Token: 'secretToken' },
|
|
},
|
|
{
|
|
genericCredentialType: 'oAuth1Api',
|
|
credentials: { oauth_token: 'token', oauth_token_secret: 'secret' },
|
|
authField: 'oauth',
|
|
authValue: { oauth_token: 'token', oauth_token_secret: 'secret' },
|
|
},
|
|
{
|
|
genericCredentialType: 'oAuth2Api',
|
|
credentials: { access_token: 'accessToken' },
|
|
authField: 'auth',
|
|
authValue: { bearer: 'accessToken' },
|
|
},
|
|
];
|
|
|
|
it.each(authenticationTypes)(
|
|
'should handle $genericCredentialType authentication',
|
|
async ({ genericCredentialType, credentials, authField, authValue }) => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'genericCredentialType';
|
|
case 'genericAuthType':
|
|
return genericCredentialType;
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
(executeFunctions.getCredentials as Mock).mockResolvedValue(credentials);
|
|
const response = {
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
|
|
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
|
|
if (genericCredentialType === 'oAuth1Api') {
|
|
expect(executeFunctions.helpers.requestOAuth1).toHaveBeenCalled();
|
|
} else if (genericCredentialType === 'oAuth2Api') {
|
|
expect(executeFunctions.helpers.requestOAuth2).toHaveBeenCalled();
|
|
} else {
|
|
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
[authField]: expect.objectContaining(authValue),
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('URL Parameter Validation', () => {
|
|
it('should throw error when URL is undefined', async () => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return undefined;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
await expect(node.execute.call(executeFunctions)).rejects.toThrow(
|
|
'URL parameter must be a string, got undefined',
|
|
);
|
|
});
|
|
|
|
it('should throw error when URL is null', async () => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return null;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
await expect(node.execute.call(executeFunctions)).rejects.toThrow(
|
|
'URL parameter must be a string, got null',
|
|
);
|
|
});
|
|
|
|
it('should throw error when URL is a number', async () => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return 42;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
await expect(node.execute.call(executeFunctions)).rejects.toThrow(
|
|
'URL parameter must be a string, got number',
|
|
);
|
|
});
|
|
it('should throw error when URL is only whitespace', async () => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return ' ';
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
await expect(node.execute.call(executeFunctions)).rejects.toThrow(
|
|
'URL parameter cannot be empty',
|
|
);
|
|
});
|
|
|
|
it('should trim whitespace from valid URL', async () => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return ' http://example.com ';
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
const response = {
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
|
|
expect(executeFunctions.helpers.request).toHaveBeenCalledTimes(1);
|
|
const requestArgs = (executeFunctions.helpers.request as Mock).mock.calls[0][0];
|
|
expect(requestArgs.uri ?? requestArgs.url).toBe('http://example.com');
|
|
});
|
|
});
|
|
|
|
describe('JSON Parameter Validation', () => {
|
|
it.each([
|
|
{
|
|
field: 'body',
|
|
params: {
|
|
sendBody: true,
|
|
specifyBody: 'json',
|
|
jsonBody: '{"valid": true}',
|
|
'bodyParameters.parameters': [],
|
|
},
|
|
},
|
|
{
|
|
field: 'query',
|
|
params: {
|
|
sendQuery: true,
|
|
specifyQuery: 'json',
|
|
jsonQuery: '{"key": "value"}',
|
|
'queryParameters.parameters': [],
|
|
},
|
|
},
|
|
{
|
|
field: 'headers',
|
|
params: {
|
|
sendHeaders: true,
|
|
specifyHeaders: 'json',
|
|
jsonHeaders: '{"X-Custom": "header"}',
|
|
'headerParameters.parameters': [],
|
|
},
|
|
},
|
|
])('should accept valid JSON in $field parameter', async ({ params }) => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'POST';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return params[paramName as keyof typeof params] ?? undefined;
|
|
}
|
|
});
|
|
const response = {
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
|
|
expect(result).toBeDefined();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
field: 'body',
|
|
fieldName: 'JSON Body',
|
|
params: {
|
|
sendBody: true,
|
|
specifyBody: 'json',
|
|
jsonBody: '{"invalid: json}',
|
|
'bodyParameters.parameters': [],
|
|
},
|
|
},
|
|
{
|
|
field: 'query',
|
|
fieldName: 'JSON Query Parameters',
|
|
params: {
|
|
sendQuery: true,
|
|
specifyQuery: 'json',
|
|
jsonQuery: '{not valid}',
|
|
'queryParameters.parameters': [],
|
|
},
|
|
},
|
|
{
|
|
field: 'headers',
|
|
fieldName: 'JSON Headers',
|
|
params: {
|
|
sendHeaders: true,
|
|
specifyHeaders: 'json',
|
|
jsonHeaders: 'not json at all',
|
|
'headerParameters.parameters': [],
|
|
},
|
|
},
|
|
])(
|
|
'should throw descriptive error for invalid JSON in $field parameter',
|
|
async ({ fieldName, params }) => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'POST';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return params[paramName as keyof typeof params] ?? undefined;
|
|
}
|
|
});
|
|
const response = {
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
await expect(node.execute.call(executeFunctions)).rejects.toThrow(
|
|
`The value in the "${fieldName}" field is not valid JSON`,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('Response parsing', () => {
|
|
it('should return empty object for autodetect JSON response with empty body', async () => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
const response = {
|
|
headers: { 'content-type': 'application/json', 'content-length': '0' },
|
|
body: Buffer.from(''),
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
|
|
expect(result).toEqual([[{ json: {}, pairedItem: { item: 0 } }]]);
|
|
});
|
|
|
|
it('should return empty object for JSON response format with empty body', async () => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return {
|
|
...options,
|
|
response: {
|
|
response: {
|
|
responseFormat: 'json',
|
|
},
|
|
},
|
|
};
|
|
case 'options.response.response.responseFormat':
|
|
return 'json';
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
const response = {
|
|
headers: { 'content-type': 'application/json', 'content-length': '0' },
|
|
body: '',
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
|
|
expect(result).toEqual([[{ json: {}, pairedItem: { item: 0 } }]]);
|
|
});
|
|
});
|
|
|
|
describe('Continued request errors', () => {
|
|
beforeEach(() => {
|
|
(executeFunctions.getNode as Mock).mockReturnValue({
|
|
typeVersion: 4.5,
|
|
});
|
|
});
|
|
|
|
it('should return null details if response parsing fails', () => {
|
|
const requestError: JsonObject = {};
|
|
Object.defineProperty(requestError, 'response', {
|
|
get: () => {
|
|
throw new Error('Unable to read response');
|
|
},
|
|
});
|
|
|
|
expect(createErrorDetails(executeFunctions.getNode(), requestError, 0)).toBeNull();
|
|
});
|
|
|
|
it('should use the body from a legacy request error', () => {
|
|
const responseBody = { error: 'Bad Request' };
|
|
const requestError: JsonObject = {
|
|
statusCode: 400,
|
|
error: responseBody,
|
|
response: {
|
|
headers: { 'x-request-id': 'request-1' },
|
|
status: 400,
|
|
},
|
|
};
|
|
|
|
expect(createErrorDetails(executeFunctions.getNode(), requestError, 0)).toMatchObject({
|
|
httpCode: '400',
|
|
body: responseBody,
|
|
context: { itemIndex: 0 },
|
|
});
|
|
});
|
|
|
|
const errorResponseBodies: Array<{
|
|
name: string;
|
|
body: JsonValue | Buffer;
|
|
expectedBody: JsonValue;
|
|
}> = [
|
|
{
|
|
name: 'an object response body',
|
|
body: { error: 'Bad Request' },
|
|
expectedBody: { error: 'Bad Request' },
|
|
},
|
|
{
|
|
name: 'a JSON response body encoded as text',
|
|
body: '{"error":"Bad Request"}',
|
|
expectedBody: { error: 'Bad Request' },
|
|
},
|
|
{
|
|
name: 'a plain-text response body',
|
|
body: 'The supplied value is invalid',
|
|
expectedBody: 'The supplied value is invalid',
|
|
},
|
|
{
|
|
name: 'a Buffer-backed response body',
|
|
body: Buffer.from('{"error":"Bad Request"}'),
|
|
expectedBody: { error: 'Bad Request' },
|
|
},
|
|
{
|
|
name: 'a null response body',
|
|
body: null,
|
|
expectedBody: null,
|
|
},
|
|
{
|
|
name: 'an array response body',
|
|
body: ['Bad Request'],
|
|
expectedBody: ['Bad Request'],
|
|
},
|
|
{
|
|
name: 'an invalid JSON response body',
|
|
body: '{"error":"Bad Request"',
|
|
expectedBody: '{"error":"Bad Request"',
|
|
},
|
|
];
|
|
|
|
it.each(errorResponseBodies)(
|
|
'should expose $name without changing the legacy error',
|
|
async ({ body, expectedBody }) => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.continueOnFail as Mock).mockReturnValue(true);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
|
|
const requestError = {
|
|
message: 'Request failed with status code 400',
|
|
name: 'RequestError',
|
|
status: 400,
|
|
statusCode: 400,
|
|
response: {
|
|
headers: { 'x-request-id': 'request-1' },
|
|
data: body,
|
|
},
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockRejectedValue(requestError);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
const expectedContext = {
|
|
itemIndex: 0,
|
|
...(body !== null &&
|
|
!Buffer.isBuffer(body) &&
|
|
!Array.isArray(body) &&
|
|
typeof body === 'object' && {
|
|
data: body,
|
|
}),
|
|
};
|
|
|
|
expect(result[0][0]).toMatchObject({
|
|
json: {
|
|
error: requestError,
|
|
details: {
|
|
httpCode: '400',
|
|
body: expectedBody,
|
|
context: expectedContext,
|
|
},
|
|
},
|
|
pairedItem: { item: 0 },
|
|
});
|
|
},
|
|
);
|
|
|
|
it('should retain the matching response body when requests complete out of order', async () => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }, { json: {} }]);
|
|
(executeFunctions.continueOnFail as Mock).mockReturnValue(true);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation(
|
|
(paramName: string, itemIndex: number) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return `${baseUrl}/${itemIndex}`;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
},
|
|
);
|
|
|
|
(executeFunctions.helpers.request as Mock).mockImplementation(
|
|
async (requestOptions: { uri: string }) =>
|
|
await new Promise((_, reject) => {
|
|
const delay = requestOptions.uri.endsWith('/0') ? 10 : 0;
|
|
setTimeout(() => {
|
|
reject({
|
|
message: 'Request failed',
|
|
statusCode: 400,
|
|
response: { data: { request: requestOptions.uri } },
|
|
});
|
|
}, delay);
|
|
}),
|
|
);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
|
|
expect(result[0][0].json.details).toMatchObject({
|
|
body: { request: `${baseUrl}/0` },
|
|
});
|
|
expect(result[0][1].json.details).toMatchObject({
|
|
body: { request: `${baseUrl}/1` },
|
|
});
|
|
});
|
|
|
|
it('should retain the matching sanitized request when requests complete out of order', async () => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }, { json: {} }]);
|
|
(executeFunctions.continueOnFail as Mock).mockReturnValue(false);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation(
|
|
(paramName: string, itemIndex: number) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return `${baseUrl}/${itemIndex}`;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return {
|
|
...options,
|
|
batching: { batch: { batchSize: 1, batchInterval: 0 } },
|
|
};
|
|
default:
|
|
return undefined;
|
|
}
|
|
},
|
|
);
|
|
|
|
const rejectRequests: Array<(error: Error) => void> = [];
|
|
(executeFunctions.helpers.request as Mock).mockImplementation(
|
|
async () =>
|
|
await new Promise((_, reject) => {
|
|
rejectRequests.push(reject);
|
|
}),
|
|
);
|
|
|
|
const execution = node.execute.call(executeFunctions);
|
|
const requestError = Object.assign(new Error('Request failed'), { statusCode: 400 });
|
|
rejectRequests[1](requestError);
|
|
rejectRequests[0](requestError);
|
|
|
|
await expect(execution).rejects.toMatchObject({
|
|
context: {
|
|
itemIndex: 0,
|
|
request: {
|
|
uri: `${baseUrl}/0`,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Cross-Origin Redirects', () => {
|
|
it('should pass sendCredentialsOnCrossOriginRedirect = true to the request by default for node versions < 4.4', async () => {
|
|
(executeFunctions.getNode as Mock).mockReturnValue({
|
|
typeVersion: 4.3,
|
|
});
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'genericCredentialType';
|
|
case 'genericAuthType':
|
|
return 'httpBasicAuth';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
(executeFunctions.getCredentials as Mock).mockResolvedValue({
|
|
user: 'username',
|
|
password: 'password',
|
|
});
|
|
const response = {
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
|
|
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
|
|
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sendCredentialsOnCrossOriginRedirect: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should pass sendCredentialsOnCrossOriginRedirect = false to the request by default for node versions >= 4.4', async () => {
|
|
(executeFunctions.getNode as Mock).mockReturnValue({
|
|
typeVersion: 4.4,
|
|
});
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'genericCredentialType';
|
|
case 'genericAuthType':
|
|
return 'httpBasicAuth';
|
|
case 'options':
|
|
return options;
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
(executeFunctions.getCredentials as Mock).mockResolvedValue({
|
|
user: 'username',
|
|
password: 'password',
|
|
});
|
|
const response = {
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
|
|
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
|
|
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sendCredentialsOnCrossOriginRedirect: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should use the sendCredentialsOnCrossOriginRedirect parameter to the request if provided', async () => {
|
|
(executeFunctions.getNode as Mock).mockReturnValue({
|
|
typeVersion: 4.4,
|
|
});
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation((paramName: string) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'genericCredentialType';
|
|
case 'genericAuthType':
|
|
return 'httpBasicAuth';
|
|
case 'options':
|
|
return { ...options, sendCredentialsOnCrossOriginRedirect: true };
|
|
default:
|
|
return undefined;
|
|
}
|
|
});
|
|
(executeFunctions.getCredentials as Mock).mockResolvedValue({
|
|
user: 'username',
|
|
password: 'password',
|
|
});
|
|
const response = {
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
};
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue(response);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
|
|
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
|
|
expect(executeFunctions.helpers.request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sendCredentialsOnCrossOriginRedirect: true,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Pagination parameter validation', () => {
|
|
it('should keep valid pagination parameters and ignore invalid parameter names', async () => {
|
|
const paginationTestOptions = {
|
|
...options,
|
|
response: {
|
|
response: {
|
|
neverError: false,
|
|
responseFormat: 'json',
|
|
fullResponse: false,
|
|
outputPropertyName: 'data',
|
|
},
|
|
},
|
|
};
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation(
|
|
(paramName: string, _itemIndex: number, defaultValue: unknown) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return paginationTestOptions;
|
|
case 'options.pagination.pagination':
|
|
return {
|
|
paginationMode: 'updateAParameterInEachRequest',
|
|
parameters: {
|
|
parameters: [
|
|
{
|
|
type: 'qs',
|
|
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
|
name: 'page',
|
|
value: '1',
|
|
},
|
|
{
|
|
type: 'qs',
|
|
name: 'constructor',
|
|
value: 'ignored',
|
|
},
|
|
],
|
|
},
|
|
paginationCompleteWhen: 'responseIsEmpty',
|
|
statusCodesWhenComplete: '',
|
|
completeExpression: '',
|
|
limitPagesFetched: false,
|
|
maxRequests: 10,
|
|
requestInterval: 0,
|
|
};
|
|
case 'options.response.response.responseFormat':
|
|
return 'json';
|
|
default:
|
|
return defaultValue;
|
|
}
|
|
},
|
|
);
|
|
(executeFunctions.helpers.requestWithAuthenticationPaginated as Mock).mockResolvedValue([
|
|
{
|
|
headers: { 'content-type': 'application/json' },
|
|
body: { success: true },
|
|
statusCode: 200,
|
|
},
|
|
]);
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
|
|
expect(result).toEqual([[{ json: { success: true }, pairedItem: { item: 0 } }]]);
|
|
expect(executeFunctions.helpers.requestWithAuthenticationPaginated).toHaveBeenCalledTimes(1);
|
|
const paginationData = (executeFunctions.helpers.requestWithAuthenticationPaginated as Mock)
|
|
.mock.calls[0][2] as {
|
|
request: {
|
|
qs: Record<string, unknown>;
|
|
};
|
|
};
|
|
expect(paginationData.request.qs).toEqual({ page: '1' });
|
|
expect(Object.prototype.hasOwnProperty.call(paginationData.request.qs, 'constructor')).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('should reject invalid pagination parameter type values', async () => {
|
|
const paginationTestOptions = {
|
|
...options,
|
|
response: {
|
|
response: {
|
|
neverError: false,
|
|
responseFormat: 'json',
|
|
fullResponse: false,
|
|
outputPropertyName: 'data',
|
|
},
|
|
},
|
|
};
|
|
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.helpers.requestWithAuthenticationPaginated as Mock).mockResolvedValue([
|
|
{
|
|
headers: { 'content-type': 'application/json' },
|
|
body: { success: true },
|
|
statusCode: 200,
|
|
},
|
|
]);
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue({
|
|
headers: { 'content-type': 'application/json' },
|
|
body: Buffer.from(JSON.stringify({ success: true })),
|
|
});
|
|
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation(
|
|
(paramName: string, _itemIndex: number, defaultValue: unknown) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return paginationTestOptions;
|
|
case 'options.pagination.pagination':
|
|
return {
|
|
paginationMode: 'updateAParameterInEachRequest',
|
|
parameters: {
|
|
parameters: [
|
|
{
|
|
type: '__proto__' as unknown as 'qs',
|
|
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
|
name: 'page',
|
|
value: '1',
|
|
},
|
|
],
|
|
},
|
|
paginationCompleteWhen: 'responseIsEmpty',
|
|
statusCodesWhenComplete: '',
|
|
completeExpression: '',
|
|
limitPagesFetched: false,
|
|
maxRequests: 10,
|
|
requestInterval: 0,
|
|
};
|
|
default:
|
|
return defaultValue;
|
|
}
|
|
},
|
|
);
|
|
|
|
await expect(node.execute.call(executeFunctions)).rejects.toThrow(
|
|
'Parameter type must be one of: body, headers, qs for parameter [1] in pagination settings',
|
|
);
|
|
expect(executeFunctions.helpers.requestWithAuthenticationPaginated).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should pass a redacted request snapshot to the paginated helper for a generic header credential', async () => {
|
|
const headerName = 'x-owner-domain-secret';
|
|
const headerValue = 'super-secret-value';
|
|
|
|
const paginationTestOptions = {
|
|
...options,
|
|
response: {
|
|
response: {
|
|
neverError: false,
|
|
responseFormat: 'json',
|
|
fullResponse: false,
|
|
outputPropertyName: 'data',
|
|
},
|
|
},
|
|
};
|
|
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }]);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation(
|
|
(paramName: string, _itemIndex: number, defaultValue: unknown) => {
|
|
switch (paramName) {
|
|
case 'method':
|
|
return 'GET';
|
|
case 'url':
|
|
return baseUrl;
|
|
case 'authentication':
|
|
return 'genericCredentialType';
|
|
case 'genericAuthType':
|
|
return 'httpHeaderAuth';
|
|
case 'options':
|
|
return paginationTestOptions;
|
|
case 'options.pagination.pagination':
|
|
return {
|
|
paginationMode: 'responseContainsNextURL',
|
|
nextURL: `={{ $request.headers['${headerName}'] }}`,
|
|
paginationCompleteWhen: 'receiveSpecificStatusCodes',
|
|
statusCodesWhenComplete: '200',
|
|
completeExpression: '',
|
|
limitPagesFetched: true,
|
|
maxRequests: 1,
|
|
requestInterval: 0,
|
|
};
|
|
default:
|
|
return defaultValue;
|
|
}
|
|
},
|
|
);
|
|
(executeFunctions.getCredentials as Mock).mockResolvedValue({
|
|
name: headerName,
|
|
value: headerValue,
|
|
});
|
|
(executeFunctions.helpers.requestWithAuthenticationPaginated as Mock).mockResolvedValue([
|
|
{
|
|
headers: { 'content-type': 'application/json' },
|
|
body: { success: true },
|
|
statusCode: 200,
|
|
},
|
|
]);
|
|
|
|
await node.execute.call(executeFunctions);
|
|
|
|
expect(executeFunctions.helpers.requestWithAuthenticationPaginated).toHaveBeenCalledTimes(1);
|
|
|
|
// The live request options (1st arg) keep the credential header so the
|
|
// actual outgoing requests stay authenticated.
|
|
const liveRequest = (executeFunctions.helpers.requestWithAuthenticationPaginated as Mock).mock
|
|
.calls[0][0] as { headers: Record<string, unknown> };
|
|
expect(liveRequest.headers[headerName]).toBe(headerValue);
|
|
|
|
// The sanitized snapshot (6th arg) exposed to pagination expressions has
|
|
// the credential-derived header redacted.
|
|
const sanitizedRequest = (executeFunctions.helpers.requestWithAuthenticationPaginated as Mock)
|
|
.mock.calls[0][5] as { headers: Record<string, unknown> };
|
|
expect(sanitizedRequest.headers[headerName]).toBe('**hidden**');
|
|
});
|
|
});
|
|
|
|
describe('Parallel item scoping fixes', () => {
|
|
// Build the nested options object the node reads via getNodeParameter('options', itemIndex)
|
|
const makeOptions = (item: Record<string, unknown>) => ({
|
|
batching: { batch: { batchSize: 1, batchInterval: 0 } },
|
|
redirect: '',
|
|
proxy: '',
|
|
timeout: '',
|
|
allowUnauthorizedCerts: false,
|
|
queryParameterArrays: '',
|
|
lowercaseHeaders: true,
|
|
response: {
|
|
response: {
|
|
responseFormat: item.responseFormat ?? 'autodetect',
|
|
outputPropertyName: item.outputPropertyName ?? 'data',
|
|
fullResponse: item.fullResponse ?? false,
|
|
neverError: item.neverError ?? false,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Configures getInputData and getNodeParameter for multiple items.
|
|
// Per-item values (url, responseFormat, outputPropertyName, fullResponse, neverError)
|
|
// are stored in each item's json and returned based on itemIndex.
|
|
const setupItems = (items: Array<{ json: Record<string, unknown> }>) => {
|
|
(executeFunctions.getInputData as Mock).mockReturnValue(items);
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation(
|
|
(paramName: string, itemIndex: number, fallback: unknown) => {
|
|
const item = items[itemIndex]?.json ?? {};
|
|
switch (paramName) {
|
|
case 'url':
|
|
return item.url;
|
|
case 'method':
|
|
return 'GET';
|
|
case 'authentication':
|
|
return 'none';
|
|
case 'options':
|
|
return makeOptions(item);
|
|
case 'options.response.response.responseFormat':
|
|
return item.responseFormat ?? fallback;
|
|
case 'options.response.response.fullResponse':
|
|
return item.fullResponse ?? fallback;
|
|
case 'options.response.response.neverError':
|
|
return item.neverError ?? fallback;
|
|
case 'options.response.response.outputPropertyName':
|
|
return item.outputPropertyName ?? fallback;
|
|
default:
|
|
return fallback;
|
|
}
|
|
},
|
|
);
|
|
};
|
|
|
|
// Mimics prepareBinaryData: derives fileExtension from mimeType but does NOT set fileName.
|
|
// This lets setFilename() derive the name from requestOptions.uri or responseFileName.
|
|
const mimeToExt: Record<string, string> = {
|
|
'image/png': 'png',
|
|
'text/plain': 'txt',
|
|
'application/pdf': 'pdf',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
(executeFunctions.helpers.prepareBinaryData as Mock).mockImplementation(
|
|
async (buffer: Buffer, _filePath?: string, mimeType?: string) => {
|
|
const fileExtension = mimeType ? mimeToExt[mimeType] : undefined;
|
|
const bin: Record<string, unknown> = { data: buffer.toString('base64'), mimeType };
|
|
if (fileExtension) bin.fileExtension = fileExtension;
|
|
return bin;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('should preserve a fileName already set by prepareBinaryData', async () => {
|
|
setupItems([
|
|
{
|
|
json: {
|
|
url: 'http://example.com/download',
|
|
responseFormat: 'file',
|
|
outputPropertyName: 'data',
|
|
},
|
|
},
|
|
]);
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue({
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'application/octet-stream' },
|
|
body: Buffer.from('data'),
|
|
});
|
|
// Simulate prepareBinaryData parsing Content-Disposition and setting fileName directly
|
|
(executeFunctions.helpers.prepareBinaryData as Mock).mockResolvedValueOnce({
|
|
data: 'ZGF0YQ==',
|
|
mimeType: 'application/octet-stream',
|
|
fileName: 'custom.txt',
|
|
});
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
// setFilename returns preparedBinaryData.fileName unchanged when already set
|
|
expect(result[0][0].binary?.data.fileName).toBe('custom.txt');
|
|
});
|
|
|
|
it('should derive filename from the original request URI when no Content-Disposition is present', async () => {
|
|
setupItems([
|
|
{
|
|
json: {
|
|
url: 'http://example.com/file1.png',
|
|
responseFormat: 'file',
|
|
outputPropertyName: 'data',
|
|
},
|
|
},
|
|
{
|
|
json: {
|
|
url: 'http://example.com/file2.png',
|
|
responseFormat: 'file',
|
|
outputPropertyName: 'data',
|
|
},
|
|
},
|
|
]);
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue({
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'image/png' },
|
|
body: Buffer.from('img'),
|
|
});
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
// setFilename: fileExtension='png', uri ends with 'png' → uri.split('/').pop()
|
|
expect(result[0][0].binary?.data.fileName).toBe('file1.png');
|
|
expect(result[0][1].binary?.data.fileName).toBe('file2.png');
|
|
});
|
|
|
|
it('should use per-item original request URI as filename fallback', async () => {
|
|
setupItems([
|
|
{
|
|
json: {
|
|
url: 'http://example.com/fileA.txt',
|
|
responseFormat: 'file',
|
|
outputPropertyName: 'data',
|
|
},
|
|
},
|
|
{
|
|
json: {
|
|
url: 'http://example.com/fileB.txt',
|
|
responseFormat: 'file',
|
|
outputPropertyName: 'data',
|
|
},
|
|
},
|
|
]);
|
|
// Both redirect to same URL; requests[itemIndex].options.uri stays as the original
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue({
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'text/plain' },
|
|
body: Buffer.from('txt'),
|
|
});
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
expect(result[0][0].binary?.data.fileName).toBe('fileA.txt');
|
|
expect(result[0][1].binary?.data.fileName).toBe('fileB.txt');
|
|
});
|
|
|
|
it('should not crash when an earlier item fails with continueOnFail (requests[] alignment)', async () => {
|
|
// Item 0 has an invalid URL → fails in request-build loop → continueOnFail
|
|
// Item 1 has a valid URL → must still be processed correctly
|
|
// Without the requests[] placeholder fix, requests[1] === undefined → TypeError
|
|
(executeFunctions.getInputData as Mock).mockReturnValue([{ json: {} }, { json: {} }]);
|
|
(executeFunctions.continueOnFail as Mock).mockReturnValue(true);
|
|
|
|
(executeFunctions.getNodeParameter as Mock).mockImplementation(
|
|
(paramName: string, itemIndex: number, fallback: unknown) => {
|
|
if (paramName === 'url') {
|
|
// Item 0 → null URL (triggers NodeOperationError in build loop)
|
|
// Item 1 → valid URL
|
|
return itemIndex === 0 ? null : 'http://example.com/ok';
|
|
}
|
|
if (paramName === 'method') return 'GET';
|
|
if (paramName === 'authentication') return 'none';
|
|
if (paramName === 'options') {
|
|
return {
|
|
batching: { batch: { batchSize: 1, batchInterval: 0 } },
|
|
redirect: '',
|
|
proxy: '',
|
|
timeout: '',
|
|
allowUnauthorizedCerts: false,
|
|
queryParameterArrays: '',
|
|
lowercaseHeaders: true,
|
|
response: {
|
|
response: {
|
|
responseFormat: 'json',
|
|
outputPropertyName: 'data',
|
|
fullResponse: false,
|
|
neverError: false,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (paramName === 'options.response.response.responseFormat') return 'json';
|
|
if (paramName === 'options.response.response.fullResponse') return false;
|
|
if (paramName === 'options.response.response.neverError') return false;
|
|
if (paramName === 'options.response.response.outputPropertyName') return 'data';
|
|
return fallback;
|
|
},
|
|
);
|
|
|
|
(executeFunctions.helpers.request as Mock).mockResolvedValue({
|
|
statusCode: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: { ok: true },
|
|
});
|
|
|
|
const result = await node.execute.call(executeFunctions);
|
|
|
|
// Item 0 → error output (continueOnFail)
|
|
expect(result[0][0].json.error).toBeDefined();
|
|
// Item 1 → valid response, no crash
|
|
expect(result[0][1].json.ok).toBe(true);
|
|
});
|
|
});
|
|
});
|