n8n/packages/nodes-base/nodes/HelpScout/HelpScout.node.test.ts
Michael Kret 0523cc35bc
feat: Binary data merge and expressions simplification (#23270)
Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com>
2026-01-12 13:29:36 +00:00

472 lines
14 KiB
TypeScript

import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { Readable } from 'stream';
import { HelpScout } from './HelpScout.node';
import * as GenericFunctions from './GenericFunctions';
jest.mock('./GenericFunctions');
describe('HelpScout Node', () => {
let helpScout: HelpScout;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
const mockHelpscoutApiRequest = GenericFunctions.helpscoutApiRequest as jest.MockedFunction<
typeof GenericFunctions.helpscoutApiRequest
>;
beforeEach(() => {
helpScout = new HelpScout();
mockExecuteFunctions = mock<IExecuteFunctions>({
helpers: {
assertBinaryData: jest.fn(),
getBinaryStream: jest.fn(),
binaryToBuffer: jest.fn(),
constructExecutionMetaData: jest.fn((data: any) => data),
returnJsonArray: jest.fn((data: any) =>
Array.isArray(data) ? data.map((d) => ({ json: d })) : [{ json: data }],
),
},
});
jest.clearAllMocks();
});
describe('Thread create operation - binary attachments', () => {
it('should stream large binary files using getBinaryStream when binary data has an id', async () => {
const inputData: INodeExecutionData[] = [
{
json: {},
binary: {
file1: {
data: '',
mimeType: 'image/png',
fileName: 'test-image.png',
fileExtension: 'png',
id: 'binary-data-id-123',
},
},
},
];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('thread')
.mockReturnValueOnce('create')
.mockReturnValueOnce('conv-123')
.mockReturnValueOnce('Test message')
.mockReturnValueOnce({ customerId: 'customer-123' })
.mockReturnValueOnce({
attachmentsBinary: [{ property: 'file1' }],
})
.mockReturnValueOnce('note');
mockExecuteFunctions.getNode.mockReturnValue({
name: 'HelpScout',
type: 'n8n-nodes-base.helpScout',
typeVersion: 1,
id: 'test-node-id',
} as any);
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
const mockBuffer = Buffer.from('mock-binary-content');
const assertBinaryData = mockExecuteFunctions.helpers.assertBinaryData as jest.Mock;
const getBinaryStream = mockExecuteFunctions.helpers.getBinaryStream as jest.Mock;
const binaryToBuffer = mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock;
assertBinaryData.mockReturnValue({
data: '',
mimeType: 'image/png',
fileName: 'test-image.png',
fileExtension: 'png',
id: 'binary-data-id-123',
});
getBinaryStream.mockResolvedValue(Readable.from(mockBuffer) as any);
binaryToBuffer.mockResolvedValue(mockBuffer);
mockHelpscoutApiRequest.mockResolvedValue({ success: true });
await helpScout.execute.call(mockExecuteFunctions);
expect(mockExecuteFunctions.helpers.assertBinaryData).toHaveBeenCalledWith(0, 'file1');
expect(mockExecuteFunctions.helpers.getBinaryStream).toHaveBeenCalledWith(
'binary-data-id-123',
256 * 1024,
);
expect(mockExecuteFunctions.helpers.binaryToBuffer).toHaveBeenCalled();
expect(mockHelpscoutApiRequest).toHaveBeenCalledWith(
'POST',
'/v2/conversations/conv-123/notes',
expect.objectContaining({
text: 'Test message',
customer: { id: 'customer-123' },
attachments: expect.arrayContaining([
expect.objectContaining({
fileName: 'test-image.png',
mimeType: 'image/png',
data: mockBuffer.toString('base64'),
}),
]),
}),
);
});
it('should use direct base64 data when binary data does not have an id', async () => {
const base64Data = Buffer.from('test-file-content').toString('base64');
const inputData: INodeExecutionData[] = [
{
json: {},
binary: {
file1: {
data: base64Data,
mimeType: 'application/pdf',
fileName: 'document.pdf',
fileExtension: 'pdf',
},
},
},
];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('thread')
.mockReturnValueOnce('create')
.mockReturnValueOnce('conv-456')
.mockReturnValueOnce('Test message with PDF')
.mockReturnValueOnce({ customerEmail: 'customer@example.com' })
.mockReturnValueOnce({
attachmentsBinary: [{ property: 'file1' }],
})
.mockReturnValueOnce('note');
mockExecuteFunctions.getNode.mockReturnValue({
name: 'HelpScout',
type: 'n8n-nodes-base.helpScout',
typeVersion: 1,
id: 'test-node-id',
} as any);
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
const assertBinaryData = mockExecuteFunctions.helpers.assertBinaryData as jest.Mock;
assertBinaryData.mockReturnValue({
data: base64Data,
mimeType: 'application/pdf',
fileName: 'document.pdf',
fileExtension: 'pdf',
});
mockHelpscoutApiRequest.mockResolvedValue({ success: true });
await helpScout.execute.call(mockExecuteFunctions);
expect(assertBinaryData).toHaveBeenCalledWith(0, 'file1');
expect(mockExecuteFunctions.helpers.getBinaryStream).not.toHaveBeenCalled();
expect(mockHelpscoutApiRequest).toHaveBeenCalledWith(
'POST',
'/v2/conversations/conv-456/notes',
expect.objectContaining({
text: 'Test message with PDF',
customer: { email: 'customer@example.com' },
attachments: expect.arrayContaining([
expect.objectContaining({
fileName: 'document.pdf',
mimeType: 'application/pdf',
data: base64Data,
}),
]),
}),
);
});
it('should process multiple binary attachments in a single request', async () => {
const inputData: INodeExecutionData[] = [
{
json: {},
binary: {
file1: {
data: Buffer.from('file1-content').toString('base64'),
mimeType: 'image/png',
fileName: 'image1.png',
fileExtension: 'png',
},
file2: {
data: Buffer.from('file2-content').toString('base64'),
mimeType: 'image/jpeg',
fileName: 'image2.jpg',
fileExtension: 'jpg',
},
},
},
];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('thread')
.mockReturnValueOnce('create')
.mockReturnValueOnce('conv-789')
.mockReturnValueOnce('Multiple attachments')
.mockReturnValueOnce({ customerId: 'customer-789' })
.mockReturnValueOnce({
attachmentsBinary: [{ property: 'file1' }, { property: 'file2' }],
})
.mockReturnValueOnce('note');
mockExecuteFunctions.getNode.mockReturnValue({
name: 'HelpScout',
type: 'n8n-nodes-base.helpScout',
typeVersion: 1,
id: 'test-node-id',
} as any);
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
const assertBinaryData = mockExecuteFunctions.helpers.assertBinaryData as jest.Mock;
assertBinaryData
.mockReturnValueOnce({
data: Buffer.from('file1-content').toString('base64'),
mimeType: 'image/png',
fileName: 'image1.png',
fileExtension: 'png',
})
.mockReturnValueOnce({
data: Buffer.from('file2-content').toString('base64'),
mimeType: 'image/jpeg',
fileName: 'image2.jpg',
fileExtension: 'jpg',
});
mockHelpscoutApiRequest.mockResolvedValue({ success: true });
await helpScout.execute.call(mockExecuteFunctions);
expect(assertBinaryData).toHaveBeenCalledTimes(2);
expect(assertBinaryData).toHaveBeenNthCalledWith(1, 0, 'file1');
expect(assertBinaryData).toHaveBeenNthCalledWith(2, 0, 'file2');
const callArgs = mockHelpscoutApiRequest.mock.calls[0];
expect(callArgs[2].attachments).toHaveLength(2);
expect(callArgs[2].attachments).toEqual(
expect.arrayContaining([
expect.objectContaining({
fileName: 'image1.png',
mimeType: 'image/png',
}),
expect.objectContaining({
fileName: 'image2.jpg',
mimeType: 'image/jpeg',
}),
]),
);
});
it('should throw error when specified binary property does not exist on input', async () => {
const inputData: INodeExecutionData[] = [
{
json: {},
binary: {
existingFile: {
data: 'base64data',
mimeType: 'image/png',
fileName: 'existing.png',
fileExtension: 'png',
},
},
},
];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('thread')
.mockReturnValueOnce('create')
.mockReturnValueOnce('conv-error')
.mockReturnValueOnce('Test error')
.mockReturnValueOnce({ customerId: 'customer-error' })
.mockReturnValueOnce({
attachmentsBinary: [{ property: 'nonExistentFile' }],
})
.mockReturnValueOnce('note');
mockExecuteFunctions.getNode.mockReturnValue({
name: 'HelpScout',
type: 'n8n-nodes-base.helpScout',
typeVersion: 1,
id: 'test-node-id',
} as any);
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
const assertBinaryData = mockExecuteFunctions.helpers.assertBinaryData as jest.Mock;
assertBinaryData.mockImplementation(() => {
throw new NodeOperationError(
mockExecuteFunctions.getNode(),
'Binary property nonExistentFile does not exist on input',
{ itemIndex: 0 },
);
});
await expect(helpScout.execute.call(mockExecuteFunctions)).rejects.toThrow(
'Binary property nonExistentFile does not exist on input',
);
expect(assertBinaryData).toHaveBeenCalledWith(0, 'nonExistentFile');
});
it('should use default fileName "unknown" when binary data has no fileName', async () => {
const inputData: INodeExecutionData[] = [
{
json: {},
binary: {
file1: {
data: Buffer.from('content').toString('base64'),
mimeType: 'application/octet-stream',
fileExtension: 'bin',
} as any,
},
},
];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('thread')
.mockReturnValueOnce('create')
.mockReturnValueOnce('conv-default')
.mockReturnValueOnce('Default filename test')
.mockReturnValueOnce({ customerId: 'customer-default' })
.mockReturnValueOnce({
attachmentsBinary: [{ property: 'file1' }],
})
.mockReturnValueOnce('note');
mockExecuteFunctions.getNode.mockReturnValue({
name: 'HelpScout',
type: 'n8n-nodes-base.helpScout',
typeVersion: 1,
id: 'test-node-id',
} as any);
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
const assertBinaryData = mockExecuteFunctions.helpers.assertBinaryData as jest.Mock;
assertBinaryData.mockReturnValue({
data: Buffer.from('content').toString('base64'),
mimeType: 'application/octet-stream',
fileExtension: 'bin',
} as any);
mockHelpscoutApiRequest.mockResolvedValue({ success: true });
await helpScout.execute.call(mockExecuteFunctions);
expect(mockHelpscoutApiRequest).toHaveBeenCalledWith(
'POST',
'/v2/conversations/conv-default/notes',
expect.objectContaining({
attachments: expect.arrayContaining([
expect.objectContaining({
fileName: 'unknown',
mimeType: 'application/octet-stream',
}),
]),
}),
);
});
it('should handle IBinaryData object passed directly as property value', async () => {
const base64Data = Buffer.from('direct-binary-content').toString('base64');
const inputData: INodeExecutionData[] = [
{
json: {},
binary: {},
},
];
const binaryDataObject = {
data: base64Data,
mimeType: 'image/gif',
fileName: 'animation.gif',
fileExtension: 'gif',
};
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('thread')
.mockReturnValueOnce('create')
.mockReturnValueOnce('conv-obj')
.mockReturnValueOnce('Test with IBinaryData object')
.mockReturnValueOnce({ customerId: 'customer-obj' })
.mockReturnValueOnce({
attachmentsBinary: [{ property: binaryDataObject }],
})
.mockReturnValueOnce('note');
mockExecuteFunctions.getNode.mockReturnValue({
name: 'HelpScout',
type: 'n8n-nodes-base.helpScout',
typeVersion: 1,
id: 'test-node-id',
} as any);
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
const assertBinaryData = mockExecuteFunctions.helpers.assertBinaryData as jest.Mock;
assertBinaryData.mockReturnValue(binaryDataObject);
mockHelpscoutApiRequest.mockResolvedValue({ success: true });
await helpScout.execute.call(mockExecuteFunctions);
expect(assertBinaryData).toHaveBeenCalledWith(0, binaryDataObject);
expect(mockHelpscoutApiRequest).toHaveBeenCalledWith(
'POST',
'/v2/conversations/conv-obj/notes',
expect.objectContaining({
attachments: expect.arrayContaining([
expect.objectContaining({
fileName: 'animation.gif',
mimeType: 'image/gif',
data: base64Data,
}),
]),
}),
);
});
it('should throw error when neither customer email nor customer ID is provided', async () => {
const inputData: INodeExecutionData[] = [
{
json: {},
},
];
mockExecuteFunctions.getInputData.mockReturnValue(inputData);
mockExecuteFunctions.getNodeParameter
.mockReturnValueOnce('thread')
.mockReturnValueOnce('create')
.mockReturnValueOnce('conv-no-customer')
.mockReturnValueOnce('Test without customer')
.mockReturnValueOnce({})
.mockReturnValueOnce({})
.mockReturnValueOnce('note');
mockExecuteFunctions.getNode.mockReturnValue({
name: 'HelpScout',
type: 'n8n-nodes-base.helpScout',
typeVersion: 1,
id: 'test-node-id',
} as any);
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
await expect(helpScout.execute.call(mockExecuteFunctions)).rejects.toThrow(
'Either customer email or customer ID must be set',
);
});
});
});