test(OpenAI Node): Add tests (#20711)

This commit is contained in:
yehorkardash 2025-10-20 10:06:36 +03:00 committed by GitHub
parent d20a2e585e
commit 30ce501fa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 5009 additions and 7 deletions

View File

@ -1,7 +1,21 @@
import { AIMessage, HumanMessage } from '@langchain/core/messages';
import type { Tool } from '@langchain/core/tools';
import { BufferWindowMemory } from 'langchain/memory';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { getChatMessages } from '../helpers/utils';
import {
formatToOpenAIFunction,
formatToOpenAITool,
formatToOpenAIAssistantTool,
formatToOpenAIResponsesTool,
getChatMessages,
} from '../helpers/utils';
jest.mock('zod-to-json-schema', () => ({
zodToJsonSchema: jest.fn(),
}));
const mockZodToJsonSchema = jest.mocked(zodToJsonSchema);
describe('OpenAI message history', () => {
it('should only get a limited number of messages', async () => {
@ -44,3 +58,236 @@ describe('OpenAI message history', () => {
]);
});
});
describe('OpenAI formatting functions', () => {
const createMockTool = (name: string, description: string, schema: z.ZodSchema): Tool =>
({
name,
description,
schema,
func: jest.fn(),
call: jest.fn(),
returnDirect: false,
verboseParsingErrors: false,
lc_namespace: ['test'],
lc_serializable: true,
}) as unknown as Tool;
beforeEach(() => {
jest.clearAllMocks();
});
describe('formatToOpenAIFunction', () => {
it('should format a tool to OpenAI function format', () => {
const mockSchema = { type: 'object', properties: { name: { type: 'string' } } };
mockZodToJsonSchema.mockReturnValue(mockSchema);
const tool = createMockTool('test-tool', 'A test tool', z.object({ name: z.string() }));
const result = formatToOpenAIFunction(tool);
expect(result).toEqual({
name: 'test-tool',
description: 'A test tool',
parameters: mockSchema,
});
expect(mockZodToJsonSchema).toHaveBeenCalledWith(tool.schema);
});
it('should handle tool with empty description', () => {
const mockSchema = { type: 'object' };
mockZodToJsonSchema.mockReturnValue(mockSchema);
const tool = createMockTool('test-tool', '', z.object({}));
const result = formatToOpenAIFunction(tool);
expect(result).toEqual({
name: 'test-tool',
description: '',
parameters: mockSchema,
});
});
});
describe('formatToOpenAITool', () => {
it('should format a tool to OpenAI tool format', () => {
const mockSchema = { type: 'object', properties: { value: { type: 'number' } } };
mockZodToJsonSchema.mockReturnValue(mockSchema);
const tool = createMockTool(
'calculator',
'A calculator tool',
z.object({ value: z.number() }),
);
const result = formatToOpenAITool(tool);
expect(result).toEqual({
type: 'function',
function: {
name: 'calculator',
description: 'A calculator tool',
parameters: mockSchema,
},
});
expect(mockZodToJsonSchema).toHaveBeenCalledWith(tool.schema);
});
it('should handle complex schema', () => {
const mockSchema = {
type: 'object',
properties: {
query: { type: 'string' },
limit: { type: 'number' },
options: { type: 'object' },
},
required: ['query'],
};
mockZodToJsonSchema.mockReturnValue(mockSchema);
const tool = createMockTool(
'search-tool',
'Search functionality',
z.object({
query: z.string(),
limit: z.number().optional(),
options: z.object({}).optional(),
}),
);
const result = formatToOpenAITool(tool);
expect(result).toEqual({
type: 'function',
function: {
name: 'search-tool',
description: 'Search functionality',
parameters: mockSchema,
},
});
});
});
describe('formatToOpenAIAssistantTool', () => {
it('should format a tool to OpenAI assistant tool format', () => {
const mockSchema = { type: 'object', properties: { message: { type: 'string' } } };
mockZodToJsonSchema.mockReturnValue(mockSchema);
const tool = createMockTool(
'message-tool',
'Send a message',
z.object({ message: z.string() }),
);
const result = formatToOpenAIAssistantTool(tool);
expect(result).toEqual({
type: 'function',
function: {
name: 'message-tool',
description: 'Send a message',
parameters: mockSchema,
},
});
expect(mockZodToJsonSchema).toHaveBeenCalledWith(tool.schema);
});
it('should handle tool with no required fields', () => {
const mockSchema = { type: 'object', properties: {} };
mockZodToJsonSchema.mockReturnValue(mockSchema);
const tool = createMockTool('simple-tool', 'A simple tool', z.object({}));
const result = formatToOpenAIAssistantTool(tool);
expect(result).toEqual({
type: 'function',
function: {
name: 'simple-tool',
description: 'A simple tool',
parameters: mockSchema,
},
});
});
});
describe('formatToOpenAIResponsesTool', () => {
it('should format a tool to OpenAI responses tool format with strict mode', () => {
const mockSchema = {
type: 'object',
properties: { input: { type: 'string' } },
required: ['input'],
};
mockZodToJsonSchema.mockReturnValue(mockSchema);
const tool = createMockTool('input-tool', 'Process input', z.object({ input: z.string() }));
const result = formatToOpenAIResponsesTool(tool);
expect(result).toEqual({
type: 'function',
name: 'input-tool',
parameters: mockSchema,
strict: true,
description: 'Process input',
});
expect(mockZodToJsonSchema).toHaveBeenCalledWith(tool.schema);
});
it('should format a tool with non-strict mode when not all properties are required', () => {
const mockSchema = {
type: 'object',
properties: {
required: { type: 'string' },
optional: { type: 'string' },
},
required: ['required'],
};
mockZodToJsonSchema.mockReturnValue(mockSchema);
const tool = createMockTool(
'mixed-tool',
'Tool with required and optional fields',
z.object({
required: z.string(),
optional: z.string().optional(),
}),
);
const result = formatToOpenAIResponsesTool(tool);
expect(result).toEqual({
type: 'function',
name: 'mixed-tool',
parameters: mockSchema,
strict: false,
description: 'Tool with required and optional fields',
});
});
it('should handle schema without required field', () => {
const mockSchema = {
type: 'object',
properties: { field: { type: 'string' } },
};
mockZodToJsonSchema.mockReturnValue(mockSchema);
const tool = createMockTool(
'no-required-tool',
'Tool with no required fields',
z.object({ field: z.string().optional() }),
);
const result = formatToOpenAIResponsesTool(tool);
expect(result).toEqual({
type: 'function',
name: 'no-required-tool',
parameters: mockSchema,
strict: false,
description: 'Tool with no required fields',
});
});
});
});

View File

@ -2,12 +2,12 @@ import FormData from 'form-data';
import get from 'lodash/get';
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
import * as assistant from '../v1/actions/assistant';
import * as audio from '../v1/actions/audio';
import * as file from '../v1/actions/file';
import * as image from '../v1/actions/image';
import * as text from '../v1/actions/text';
import * as transport from '../transport';
import * as assistant from '../../v1/actions/assistant';
import * as audio from '../../v1/actions/audio';
import * as file from '../../v1/actions/file';
import * as image from '../../v1/actions/image';
import * as text from '../../v1/actions/text';
import * as transport from '../../transport';
const createExecuteFunctionsMock = (parameters: IDataObject) => {
const nodeParameters = parameters;

View File

@ -0,0 +1,912 @@
import { mock, mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import * as binaryDataHelpers from '../../../../helpers/binary-data';
import type { ChatResponse } from '../../../../helpers/interfaces';
import * as transport from '../../../../transport';
import { execute } from '../../../../v2/actions/image/analyze.operation';
jest.mock('../../../../helpers/binary-data');
jest.mock('../../../../transport');
describe('Image Analyze Operation', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockNode: INode;
const apiRequestSpy = jest.spyOn(transport, 'apiRequest');
const getBinaryDataFileSpy = jest.spyOn(binaryDataHelpers, 'getBinaryDataFile');
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockNode = mock<INode>({
id: 'test-node',
name: 'OpenAI Image Analyze',
type: 'n8n-nodes-base.openAi',
typeVersion: 2,
position: [0, 0],
parameters: {},
});
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.helpers.binaryToBuffer = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
describe('successful execution with URL input', () => {
beforeEach(() => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o',
text: "What's in this image?",
inputType: 'url',
imageUrls: 'https://example.com/image1.jpg',
simplify: true,
options: {},
};
return params[paramName as keyof typeof params];
});
});
it('should analyze single image from URL with simplified output', async () => {
const mockResponse = {
id: 'response-123',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: 'This image shows a beautiful landscape with mountains and a lake.',
},
],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: {
model: 'gpt-4o',
input: [
{
role: 'user',
content: [
{
type: 'input_text',
text: "What's in this image?",
},
{
type: 'input_image',
detail: 'auto',
image_url: 'https://example.com/image1.jpg',
},
],
},
],
max_output_tokens: 300,
},
});
expect(result).toEqual([
{
json: mockResponse.output,
pairedItem: { item: 0 },
},
]);
});
it('should analyze multiple images from URLs', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o',
text: 'Compare these images',
inputType: 'url',
imageUrls: 'https://example.com/image1.jpg, https://example.com/image2.png',
simplify: true,
options: {},
};
return params[paramName as keyof typeof params];
});
const mockResponse = {
id: 'response-456',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: 'The first image shows a landscape, while the second shows a cityscape.',
},
],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: {
model: 'gpt-4o',
input: [
{
role: 'user',
content: [
{
type: 'input_text',
text: 'Compare these images',
},
{
type: 'input_image',
detail: 'auto',
image_url: 'https://example.com/image1.jpg',
},
{
type: 'input_image',
detail: 'auto',
image_url: 'https://example.com/image2.png',
},
],
},
],
max_output_tokens: 300,
},
});
expect(result).toHaveLength(1);
});
it('should handle URLs with extra whitespace', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o',
text: 'Analyze these images',
inputType: 'url',
imageUrls: ' https://example.com/image1.jpg , https://example.com/image2.png ',
simplify: true,
options: {},
};
return params[paramName as keyof typeof params];
});
const mockResponse = {
id: 'response-789',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Analysis complete.' }],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
await execute.call(mockExecuteFunctions, 0);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: expect.objectContaining({
input: [
{
role: 'user',
content: [
expect.objectContaining({ type: 'input_text' }),
expect.objectContaining({
type: 'input_image',
image_url: 'https://example.com/image1.jpg',
}),
expect.objectContaining({
type: 'input_image',
image_url: 'https://example.com/image2.png',
}),
],
},
],
}),
});
});
it('should use custom options for URL analysis', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o-mini',
text: 'Describe this image in detail',
inputType: 'url',
imageUrls: 'https://example.com/detailed-image.jpg',
simplify: false,
options: {
detail: 'high',
maxTokens: 500,
},
};
return params[paramName as keyof typeof params];
});
const mockResponse = {
id: 'response-detailed',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Detailed analysis...' }],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: {
model: 'gpt-4o-mini',
input: [
{
role: 'user',
content: [
{
type: 'input_text',
text: 'Describe this image in detail',
},
{
type: 'input_image',
detail: 'high',
image_url: 'https://example.com/detailed-image.jpg',
},
],
},
],
max_output_tokens: 500,
},
});
expect(result).toEqual([
{
json: mockResponse,
pairedItem: { item: 0 },
},
]);
});
});
describe('successful execution with binary input', () => {
beforeEach(() => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o',
text: 'Analyze this image',
inputType: 'base64',
binaryPropertyName: 'image_data',
simplify: true,
options: {},
};
return params[paramName as keyof typeof params];
});
});
it('should analyze single binary image', async () => {
const mockBinaryFile = {
fileContent: Buffer.from('mock-image-data'),
contentType: 'image/jpeg',
filename: 'test.jpg',
};
const mockResponse = {
id: 'response-binary',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'This is a JPEG image showing...' }],
},
],
} as ChatResponse;
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
mockBinaryFile.fileContent,
);
apiRequestSpy.mockResolvedValue(mockResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(getBinaryDataFileSpy).toHaveBeenCalledWith(mockExecuteFunctions, 0, 'image_data');
expect(mockExecuteFunctions.helpers.binaryToBuffer).toHaveBeenCalledWith(
mockBinaryFile.fileContent,
);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: {
model: 'gpt-4o',
input: [
{
role: 'user',
content: [
{
type: 'input_text',
text: 'Analyze this image',
},
{
type: 'input_image',
detail: 'auto',
image_url: `data:image/jpeg;base64,${mockBinaryFile.fileContent.toString('base64')}`,
},
],
},
],
max_output_tokens: 300,
},
});
expect(result).toEqual([
{
json: mockResponse.output,
pairedItem: { item: 0 },
},
]);
});
it('should analyze multiple binary images', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o',
text: 'Compare these images',
inputType: 'base64',
binaryPropertyName: 'image1, image2',
simplify: true,
options: { detail: 'low' },
};
return params[paramName as keyof typeof params];
});
const mockBinaryFile1 = {
fileContent: Buffer.from('mock-image-data-1'),
contentType: 'image/png',
filename: 'image1.png',
};
const mockBinaryFile2 = {
fileContent: Buffer.from('mock-image-data-2'),
contentType: 'image/gif',
filename: 'image2.gif',
};
const mockResponse = {
id: 'response-multi-binary',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Comparison of the two images...' }],
},
],
} as ChatResponse;
getBinaryDataFileSpy
.mockResolvedValueOnce(mockBinaryFile1)
.mockResolvedValueOnce(mockBinaryFile2);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock)
.mockResolvedValueOnce(mockBinaryFile1.fileContent)
.mockResolvedValueOnce(mockBinaryFile2.fileContent);
apiRequestSpy.mockResolvedValue(mockResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(getBinaryDataFileSpy).toHaveBeenCalledTimes(2);
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(1, mockExecuteFunctions, 0, 'image1');
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(2, mockExecuteFunctions, 0, 'image2');
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: {
model: 'gpt-4o',
input: [
{
role: 'user',
content: [
{
type: 'input_text',
text: 'Compare these images',
},
{
type: 'input_image',
detail: 'low',
image_url: `data:image/png;base64,${mockBinaryFile1.fileContent.toString('base64')}`,
},
{
type: 'input_image',
detail: 'low',
image_url: `data:image/gif;base64,${mockBinaryFile2.fileContent.toString('base64')}`,
},
],
},
],
max_output_tokens: 300,
},
});
expect(result).toHaveLength(1);
});
it('should handle binary property names with whitespace', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o',
text: 'Analyze images',
inputType: 'base64',
binaryPropertyName: ' image1 , image2 ',
simplify: true,
options: {},
};
return params[paramName as keyof typeof params];
});
const mockBinaryFile1 = {
fileContent: Buffer.from('mock-image-data-1'),
contentType: 'image/png',
filename: 'image1.png',
};
const mockBinaryFile2 = {
fileContent: Buffer.from('mock-image-data-2'),
contentType: 'image/jpeg',
filename: 'image2.jpg',
};
const mockResponse = {
id: 'response-whitespace',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Analysis complete.' }],
},
],
} as ChatResponse;
getBinaryDataFileSpy
.mockResolvedValueOnce(mockBinaryFile1)
.mockResolvedValueOnce(mockBinaryFile2);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock)
.mockResolvedValueOnce(mockBinaryFile1.fileContent)
.mockResolvedValueOnce(mockBinaryFile2.fileContent);
apiRequestSpy.mockResolvedValue(mockResponse);
await execute.call(mockExecuteFunctions, 0);
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(1, mockExecuteFunctions, 0, 'image1');
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(2, mockExecuteFunctions, 0, 'image2');
});
});
describe('parameter validation and edge cases', () => {
it('should use default model when not specified', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation(
(paramName: string, _index: number, defaultValue?: any) => {
const params: Record<string, any> = {
text: 'Analyze this image',
inputType: 'url',
imageUrls: 'https://example.com/image.jpg',
simplify: true,
options: {},
};
if (paramName === 'modelId') {
return defaultValue; // Should return 'gpt-4o' as default
}
return params[paramName];
},
);
const mockResponse = {
id: 'response-default-model',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Analysis with default model.' }],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
await execute.call(mockExecuteFunctions, 0);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: expect.objectContaining({
model: 'gpt-4o',
}),
});
});
it('should use default text when not specified', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation(
(paramName: string, _index: number, defaultValue?: any) => {
const params: Record<string, any> = {
modelId: 'gpt-4o',
inputType: 'url',
imageUrls: 'https://example.com/image.jpg',
simplify: true,
options: {},
};
if (paramName === 'text') {
return defaultValue; // Should return empty string as default
}
return params[paramName];
},
);
const mockResponse = {
id: 'response-default-text',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Analysis with default text.' }],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
await execute.call(mockExecuteFunctions, 0);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: expect.objectContaining({
input: [
{
role: 'user',
content: [
{
type: 'input_text',
text: '',
},
expect.any(Object),
],
},
],
}),
});
});
it('should use default maxTokens when not specified in options', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o',
text: 'Analyze this image',
inputType: 'url',
imageUrls: 'https://example.com/image.jpg',
simplify: true,
options: {}, // No maxTokens specified
};
return params[paramName as keyof typeof params];
});
const mockResponse = {
id: 'response-default-tokens',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Analysis with default token limit.' }],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
await execute.call(mockExecuteFunctions, 0);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: expect.objectContaining({
max_output_tokens: 300,
}),
});
});
it('should use default detail level when not specified in options', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o',
text: 'Analyze this image',
inputType: 'url',
imageUrls: 'https://example.com/image.jpg',
simplify: true,
options: {}, // No detail specified
};
return params[paramName as keyof typeof params];
});
const mockResponse = {
id: 'response-default-detail',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Analysis with default detail level.' }],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
await execute.call(mockExecuteFunctions, 0);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: expect.objectContaining({
input: [
{
role: 'user',
content: [
expect.any(Object),
expect.objectContaining({
type: 'input_image',
detail: 'auto',
}),
],
},
],
}),
});
});
it('should handle mixed empty and valid URLs', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o',
text: 'Analyze these images',
inputType: 'url',
imageUrls: 'https://example.com/image1.jpg, , https://example.com/image2.png',
simplify: true,
options: {},
};
return params[paramName as keyof typeof params];
});
const mockResponse = {
id: 'response-mixed-urls',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Analysis of valid images.' }],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
await execute.call(mockExecuteFunctions, 0);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: expect.objectContaining({
input: [
{
role: 'user',
content: [
expect.any(Object),
expect.objectContaining({
image_url: 'https://example.com/image1.jpg',
}),
expect.objectContaining({
image_url: '',
}),
expect.objectContaining({
image_url: 'https://example.com/image2.png',
}),
],
},
],
}),
});
});
});
describe('output formatting', () => {
it('should return simplified output when simplify is true', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o',
text: 'Analyze this image',
inputType: 'url',
imageUrls: 'https://example.com/image.jpg',
simplify: true,
options: {},
};
return params[paramName as keyof typeof params];
});
const mockResponse = {
id: 'response-simplified',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'This is the analysis result.' }],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(result).toEqual([
{
json: mockResponse.output,
pairedItem: { item: 0 },
},
]);
});
it('should return full response when simplify is false', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o',
text: 'Analyze this image',
inputType: 'url',
imageUrls: 'https://example.com/image.jpg',
simplify: false,
options: {},
};
return params[paramName as keyof typeof params];
});
const mockResponse = {
id: 'response-full',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'This is the analysis result.' }],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(result).toEqual([
{
json: mockResponse,
pairedItem: { item: 0 },
},
]);
});
});
describe('options handling', () => {
it('should apply all available options correctly', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o-mini',
text: 'Provide detailed analysis',
inputType: 'url',
imageUrls: 'https://example.com/complex-image.jpg',
simplify: false,
options: {
detail: 'high',
maxTokens: 1000,
},
};
return params[paramName as keyof typeof params];
});
const mockResponse = {
id: 'response-options',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Detailed analysis...' }],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
await execute.call(mockExecuteFunctions, 0);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: {
model: 'gpt-4o-mini',
input: [
{
role: 'user',
content: [
{
type: 'input_text',
text: 'Provide detailed analysis',
},
{
type: 'input_image',
detail: 'high',
image_url: 'https://example.com/complex-image.jpg',
},
],
},
],
max_output_tokens: 1000,
},
});
});
it('should handle partial options correctly', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'gpt-4o',
text: 'Quick analysis',
inputType: 'url',
imageUrls: 'https://example.com/image.jpg',
simplify: true,
options: {
detail: 'low',
// maxTokens not specified, should use default
},
};
return params[paramName as keyof typeof params];
});
const mockResponse = {
id: 'response-partial-options',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Quick analysis result.' }],
},
],
} as ChatResponse;
apiRequestSpy.mockResolvedValue(mockResponse);
await execute.call(mockExecuteFunctions, 0);
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/responses', {
body: expect.objectContaining({
max_output_tokens: 300, // Default value
input: [
{
role: 'user',
content: [
expect.any(Object),
expect.objectContaining({
detail: 'low',
}),
],
},
],
}),
});
});
});
});

View File

@ -0,0 +1,692 @@
import FormData from 'form-data';
import { mock, mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import * as binaryDataHelpers from '../../../../helpers/binary-data';
import * as transport from '../../../../transport';
import { execute } from '../../../../v2/actions/image/edit.operation';
jest.mock('../../../../helpers/binary-data');
jest.mock('../../../../transport');
jest.mock('form-data', () => jest.fn());
const mockFormData = jest.mocked(FormData);
describe('Image Edit Operation', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockNode: INode;
let mockFormDataInstance: jest.Mocked<FormData>;
const apiRequestSpy = jest.spyOn(transport, 'apiRequest');
const getBinaryDataFileSpy = jest.spyOn(binaryDataHelpers, 'getBinaryDataFile');
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockNode = mock<INode>({
id: 'test-node',
name: 'OpenAI Image Edit',
type: 'n8n-nodes-base.openAi',
typeVersion: 2,
position: [0, 0],
parameters: {},
});
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.helpers.prepareBinaryData = jest.fn();
mockExecuteFunctions.helpers.binaryToBuffer = jest.fn();
mockFormDataInstance = {
append: jest.fn(),
getHeaders: jest.fn().mockReturnValue({ 'content-type': 'multipart/form-data' }),
} as unknown as jest.Mocked<FormData>;
mockFormData.mockImplementation(() => mockFormDataInstance);
});
afterEach(() => {
jest.resetAllMocks();
});
describe('successful execution with DALL-E 2', () => {
beforeEach(() => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
model: 'dall-e-2',
prompt: 'Add a rainbow to this landscape',
binaryPropertyName: 'image_data',
n: 1,
size: '1024x1024',
quality: 'standard',
responseFormat: 'url',
options: {},
};
return params[paramName as keyof typeof params];
});
});
it('should edit image with basic parameters', async () => {
const mockBinaryFile = {
fileContent: Buffer.from('mock-image-data'),
contentType: 'image/png',
filename: 'test.png',
};
const mockApiResponse = {
data: [
{
url: 'https://example.com/edited-image.png',
revised_prompt: 'Add a rainbow to this landscape',
},
],
};
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
mockBinaryFile.fileContent,
);
apiRequestSpy.mockResolvedValue(mockApiResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(getBinaryDataFileSpy).toHaveBeenCalledWith(mockExecuteFunctions, 0, 'image_data');
expect(mockExecuteFunctions.helpers.binaryToBuffer).toHaveBeenCalledWith(
mockBinaryFile.fileContent,
);
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
'image',
mockBinaryFile.fileContent,
{
filename: 'test.png',
contentType: 'image/png',
},
);
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
'prompt',
'Add a rainbow to this landscape',
);
expect(mockFormDataInstance.append).toHaveBeenCalledWith('model', 'dall-e-2');
expect(mockFormDataInstance.append).toHaveBeenCalledWith('n', '1');
expect(mockFormDataInstance.append).toHaveBeenCalledWith('size', '1024x1024');
expect(mockFormDataInstance.append).toHaveBeenCalledWith('response_format', 'url');
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/images/edits', {
option: { formData: mockFormDataInstance },
headers: { 'content-type': 'multipart/form-data' },
});
expect(result).toEqual([
{
json: {
url: 'https://example.com/edited-image.png',
revised_prompt: 'Add a rainbow to this landscape',
},
pairedItem: { item: 0 },
},
]);
});
it('should handle base64 response format', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
model: 'dall-e-2',
prompt: 'Edit this image',
binaryPropertyName: 'image_data',
n: 1,
size: '512x512',
quality: 'standard',
responseFormat: 'b64_json',
options: {},
};
return params[paramName as keyof typeof params];
});
const mockBinaryFile = {
fileContent: Buffer.from('mock-image-data'),
contentType: 'image/png',
filename: 'test.png',
};
const mockApiResponse = {
data: [
{
b64_json: 'base64encodedimagedata',
},
],
};
const mockBinaryData = {
data: 'base64encodedimagedata',
mimeType: 'image/png',
fileName: 'data',
};
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
mockBinaryFile.fileContent,
);
apiRequestSpy.mockResolvedValue(mockApiResponse);
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
mockBinaryData,
);
const result = await execute.call(mockExecuteFunctions, 0);
expect(mockExecuteFunctions.helpers.prepareBinaryData).toHaveBeenCalledWith(
expect.any(Buffer),
'data',
);
expect(result).toEqual([
{
json: {
data: undefined,
mimeType: 'image/png',
fileName: 'data',
},
binary: {
data: mockBinaryData,
},
pairedItem: { item: 0 },
},
]);
});
it('should handle multiple images generation', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
model: 'dall-e-2',
prompt: 'Create variations of this image',
binaryPropertyName: 'image_data',
n: 3,
size: '256x256',
quality: 'standard',
responseFormat: 'url',
options: {},
};
return params[paramName as keyof typeof params];
});
const mockBinaryFile = {
fileContent: Buffer.from('mock-image-data'),
contentType: 'image/png',
filename: 'test.png',
};
const mockApiResponse = {
data: [
{ url: 'https://example.com/edited-image-1.png' },
{ url: 'https://example.com/edited-image-2.png' },
{ url: 'https://example.com/edited-image-3.png' },
],
};
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
mockBinaryFile.fileContent,
);
apiRequestSpy.mockResolvedValue(mockApiResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(mockFormDataInstance.append).toHaveBeenCalledWith('n', '3');
expect(result).toHaveLength(3);
expect(result[0].json.url).toBe('https://example.com/edited-image-1.png');
expect(result[1].json.url).toBe('https://example.com/edited-image-2.png');
expect(result[2].json.url).toBe('https://example.com/edited-image-3.png');
});
it('should handle image mask option', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
model: 'dall-e-2',
prompt: 'Edit specific area of image',
binaryPropertyName: 'image_data',
n: 1,
size: '1024x1024',
quality: 'standard',
responseFormat: 'url',
options: {
imageMask: 'mask_data',
user: 'test-user-123',
},
};
return params[paramName as keyof typeof params];
});
const mockImageFile = {
fileContent: Buffer.from('mock-image-data'),
contentType: 'image/png',
filename: 'test.png',
};
const mockMaskFile = {
fileContent: Buffer.from('mock-mask-data'),
contentType: 'image/png',
filename: 'mask.png',
};
const mockApiResponse = {
data: [{ url: 'https://example.com/edited-image.png' }],
};
getBinaryDataFileSpy.mockResolvedValueOnce(mockImageFile).mockResolvedValueOnce(mockMaskFile);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock)
.mockResolvedValueOnce(mockImageFile.fileContent)
.mockResolvedValueOnce(mockMaskFile.fileContent);
apiRequestSpy.mockResolvedValue(mockApiResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(getBinaryDataFileSpy).toHaveBeenCalledTimes(2);
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(
1,
mockExecuteFunctions,
0,
'image_data',
);
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(2, mockExecuteFunctions, 0, 'mask_data');
expect(mockFormDataInstance.append).toHaveBeenCalledWith('image', mockImageFile.fileContent, {
filename: 'test.png',
contentType: 'image/png',
});
expect(mockFormDataInstance.append).toHaveBeenCalledWith('mask', mockMaskFile.fileContent, {
filename: 'mask.png',
contentType: 'image/png',
});
expect(mockFormDataInstance.append).toHaveBeenCalledWith('user', 'test-user-123');
expect(result).toHaveLength(1);
});
});
describe('successful execution with GPT Image 1', () => {
beforeEach(() => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
model: 'gpt-image-1',
prompt: 'Transform this image with AI magic',
images: {
values: [{ binaryPropertyName: 'image1' }, { binaryPropertyName: 'image2' }],
},
n: 1,
size: '1536x1024',
quality: 'high',
options: {
background: 'transparent',
inputFidelity: 'high',
outputFormat: 'webp',
outputCompression: 85,
},
};
return params[paramName as keyof typeof params];
});
});
it('should edit image with GPT Image 1 model', async () => {
const mockBinaryFile1 = {
fileContent: Buffer.from('mock-image-data-1'),
contentType: 'image/jpeg',
filename: 'image1.jpg',
};
const mockBinaryFile2 = {
fileContent: Buffer.from('mock-image-data-2'),
contentType: 'image/png',
filename: 'image2.png',
};
const mockApiResponse = {
data: [
{
b64_json: 'base64encodedimagedata',
},
],
};
const mockBinaryData = {
data: 'base64encodedimagedata',
mimeType: 'image/webp',
fileName: 'data',
};
getBinaryDataFileSpy
.mockResolvedValueOnce(mockBinaryFile1)
.mockResolvedValueOnce(mockBinaryFile2);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock)
.mockResolvedValueOnce(mockBinaryFile1.fileContent)
.mockResolvedValueOnce(mockBinaryFile2.fileContent);
apiRequestSpy.mockResolvedValue(mockApiResponse);
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
mockBinaryData,
);
const result = await execute.call(mockExecuteFunctions, 0);
expect(getBinaryDataFileSpy).toHaveBeenCalledTimes(2);
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(1, mockExecuteFunctions, 0, 'image1');
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(2, mockExecuteFunctions, 0, 'image2');
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
'image[]',
mockBinaryFile1.fileContent,
{
filename: 'image1.jpg',
contentType: 'image/jpeg',
},
);
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
'image[]',
mockBinaryFile2.fileContent,
{
filename: 'image2.png',
contentType: 'image/png',
},
);
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
'prompt',
'Transform this image with AI magic',
);
expect(mockFormDataInstance.append).toHaveBeenCalledWith('model', 'gpt-image-1');
expect(mockFormDataInstance.append).toHaveBeenCalledWith('background', 'transparent');
expect(mockFormDataInstance.append).toHaveBeenCalledWith('input_fidelity', 'high');
expect(mockFormDataInstance.append).toHaveBeenCalledWith('output_format', 'webp');
expect(mockFormDataInstance.append).toHaveBeenCalledWith('output_compression', '85');
expect(mockFormDataInstance.append).toHaveBeenCalledWith('quality', 'high');
expect(result).toEqual([
{
json: {
data: undefined,
mimeType: 'image/webp',
fileName: 'data',
},
binary: {
data: mockBinaryData,
},
pairedItem: { item: 0 },
},
]);
});
it('should handle default images parameter for GPT Image 1', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
model: 'gpt-image-1',
prompt: 'Edit this image',
images: {
values: [{ binaryPropertyName: 'data' }],
},
n: 1,
size: 'auto',
quality: 'auto',
options: {},
};
return params[paramName as keyof typeof params];
});
const mockBinaryFile = {
fileContent: Buffer.from('mock-image-data'),
contentType: 'image/png',
filename: 'data.png',
};
const mockApiResponse = {
data: [{ b64_json: 'base64encodedimagedata' }],
};
const mockBinaryData = {
data: 'base64encodedimagedata',
mimeType: 'image/png',
fileName: 'data',
};
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
mockBinaryFile.fileContent,
);
apiRequestSpy.mockResolvedValue(mockApiResponse);
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
mockBinaryData,
);
const result = await execute.call(mockExecuteFunctions, 0);
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
'image[]',
mockBinaryFile.fileContent,
{
filename: 'data.png',
contentType: 'image/png',
},
);
expect(result).toHaveLength(1);
});
});
describe('parameter validation and edge cases', () => {
it('should handle missing response data', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
model: 'dall-e-2',
prompt: 'Edit this image',
binaryPropertyName: 'image_data',
n: 1,
size: '1024x1024',
quality: 'standard',
responseFormat: 'url',
options: {},
};
return params[paramName as keyof typeof params];
});
const mockBinaryFile = {
fileContent: Buffer.from('mock-image-data'),
contentType: 'image/png',
filename: 'test.png',
};
const mockApiResponse = {};
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
mockBinaryFile.fileContent,
);
apiRequestSpy.mockResolvedValue(mockApiResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(result).toEqual([]);
});
it('should handle zero output compression value', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
model: 'gpt-image-1',
prompt: 'Edit this image',
images: {
values: [{ binaryPropertyName: 'data' }],
},
n: 1,
size: '1024x1024',
quality: 'auto',
options: {
outputCompression: 0,
},
};
return params[paramName as keyof typeof params];
});
const mockBinaryFile = {
fileContent: Buffer.from('mock-image-data'),
contentType: 'image/png',
filename: 'data.png',
};
const mockApiResponse = {
data: [{ b64_json: 'base64encodedimagedata' }],
};
const mockBinaryData = {
data: 'base64encodedimagedata',
mimeType: 'image/png',
fileName: 'data',
};
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
mockBinaryFile.fileContent,
);
apiRequestSpy.mockResolvedValue(mockApiResponse);
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
mockBinaryData,
);
const result = await execute.call(mockExecuteFunctions, 0);
expect(mockFormDataInstance.append).toHaveBeenCalledWith('output_compression', '0');
expect(result).toHaveLength(1);
});
it('should not append optional parameters when not provided', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
model: 'dall-e-2',
prompt: 'Edit this image',
binaryPropertyName: 'image_data',
n: 0,
size: '',
quality: '',
responseFormat: 'url',
options: {},
};
return params[paramName as keyof typeof params];
});
const mockBinaryFile = {
fileContent: Buffer.from('mock-image-data'),
contentType: 'image/png',
filename: 'test.png',
};
const mockApiResponse = {
data: [{ url: 'https://example.com/edited-image.png' }],
};
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
mockBinaryFile.fileContent,
);
apiRequestSpy.mockResolvedValue(mockApiResponse);
await execute.call(mockExecuteFunctions, 0);
expect(mockFormDataInstance.append).not.toHaveBeenCalledWith('n', '0');
expect(mockFormDataInstance.append).not.toHaveBeenCalledWith('size', '');
expect(mockFormDataInstance.append).not.toHaveBeenCalledWith('quality', '');
});
});
describe('FormData handling', () => {
it('should create FormData with correct headers', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
model: 'dall-e-2',
prompt: 'Edit this image',
binaryPropertyName: 'image_data',
n: 1,
size: '1024x1024',
quality: 'standard',
responseFormat: 'url',
options: {},
};
return params[paramName as keyof typeof params];
});
const mockBinaryFile = {
fileContent: Buffer.from('mock-image-data'),
contentType: 'image/png',
filename: 'test.png',
};
const mockApiResponse = {
data: [{ url: 'https://example.com/edited-image.png' }],
};
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
mockBinaryFile.fileContent,
);
apiRequestSpy.mockResolvedValue(mockApiResponse);
await execute.call(mockExecuteFunctions, 0);
expect(mockFormDataInstance.getHeaders).toHaveBeenCalled();
expect(apiRequestSpy).toHaveBeenCalledWith('POST', '/images/edits', {
option: { formData: mockFormDataInstance },
headers: { 'content-type': 'multipart/form-data' },
});
});
it('should filter out empty binary property names for GPT Image 1', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
model: 'gpt-image-1',
prompt: 'Edit this image',
images: {
values: [
{ binaryPropertyName: 'image1' },
{ binaryPropertyName: '' },
{ binaryPropertyName: 'image2' },
{ binaryPropertyName: undefined },
{},
],
},
n: 1,
size: '1024x1024',
quality: 'auto',
options: {},
};
return params[paramName as keyof typeof params];
});
const mockBinaryFile1 = {
fileContent: Buffer.from('mock-image-data-1'),
contentType: 'image/png',
filename: 'image1.png',
};
const mockBinaryFile2 = {
fileContent: Buffer.from('mock-image-data-2'),
contentType: 'image/jpeg',
filename: 'image2.jpg',
};
const mockApiResponse = {
data: [{ b64_json: 'base64encodedimagedata' }],
};
const mockBinaryData = {
data: 'base64encodedimagedata',
mimeType: 'image/png',
fileName: 'data',
};
getBinaryDataFileSpy
.mockResolvedValueOnce(mockBinaryFile1)
.mockResolvedValueOnce(mockBinaryFile2);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock)
.mockResolvedValueOnce(mockBinaryFile1.fileContent)
.mockResolvedValueOnce(mockBinaryFile2.fileContent);
apiRequestSpy.mockResolvedValue(mockApiResponse);
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
mockBinaryData,
);
await execute.call(mockExecuteFunctions, 0);
expect(getBinaryDataFileSpy).toHaveBeenCalledTimes(2);
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(1, mockExecuteFunctions, 0, 'image1');
expect(getBinaryDataFileSpy).toHaveBeenNthCalledWith(2, mockExecuteFunctions, 0, 'image2');
});
});
});

View File

@ -0,0 +1,883 @@
import { mock, mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { getConnectedTools } from '@utils/helpers';
import { pollUntilAvailable } from '../../../../helpers/polling';
import * as transport from '../../../../transport';
import * as helpers from '../../../../v2/actions/text/helpers/responses';
import { execute } from '../../../../v2/actions/text/response.operation';
import { formatToOpenAIResponsesTool } from '../../../../helpers/utils';
import type { Tool } from 'langchain/tools';
jest.mock('../../../../transport');
jest.mock('../../../../v2/actions/text/helpers/responses');
jest.mock('@utils/helpers');
jest.mock('../../../../helpers/polling');
jest.mock('../../../../helpers/utils');
const mockFormatToOpenAIResponsesTool = formatToOpenAIResponsesTool as jest.MockedFunction<
typeof formatToOpenAIResponsesTool
>;
const mockApiRequest = transport.apiRequest as jest.MockedFunction<typeof transport.apiRequest>;
const mockCreateRequest = helpers.createRequest as jest.MockedFunction<
typeof helpers.createRequest
>;
const mockGetConnectedTools = getConnectedTools as jest.MockedFunction<typeof getConnectedTools>;
const mockPollUntilAvailable = pollUntilAvailable as jest.MockedFunction<typeof pollUntilAvailable>;
describe('OpenAI Response Operation', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockNode: jest.Mocked<INode>;
beforeEach(() => {
jest.clearAllMocks();
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockNode = mock<INode>({
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-langchain.openAi',
typeVersion: 2,
position: [0, 0],
parameters: {},
});
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.getExecutionCancelSignal.mockReturnValue(new AbortController().signal);
mockExecuteFunctions.getNodeParameter.mockImplementation(
(param: string, _itemIndex: number, defaultValue?: unknown) => {
const mockParams: Record<string, unknown> = {
modelId: 'gpt-4o',
'responses.values': [
{
role: 'user',
type: 'text',
content: 'Hello, how are you?',
},
],
options: {},
builtInTools: {},
simplify: true,
hideTools: 'show',
'options.maxToolsIterations': 15,
};
return (mockParams[param] ?? defaultValue) as any;
},
);
mockFormatToOpenAIResponsesTool.mockImplementation((tool: Tool) => {
return {
type: 'function',
name: tool.name,
parameters: {},
strict: false,
description: tool.description,
};
});
});
describe('Successful Execution', () => {
it('should execute successfully with basic text message', async () => {
const mockResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: 'I am doing well, thank you for asking!',
},
],
},
],
};
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [{ role: 'user', content: [{ type: 'input_text', text: 'Hello, how are you?' }] }],
});
mockApiRequest.mockResolvedValue(mockResponse);
mockGetConnectedTools.mockResolvedValue([]);
const result = await execute.call(mockExecuteFunctions, 0);
expect(result).toEqual([
{
json: mockResponse.output,
pairedItem: { item: 0 },
},
]);
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/responses', {
body: expect.any(Object),
});
});
it('should execute with simplified output disabled', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'simplify') return false;
return 'default';
});
const mockResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Response text' }],
},
],
};
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
});
mockApiRequest.mockResolvedValue(mockResponse);
mockGetConnectedTools.mockResolvedValue([]);
const result = await execute.call(mockExecuteFunctions, 0);
expect(result).toEqual([
{
json: mockResponse,
pairedItem: { item: 0 },
},
]);
});
it('should handle multiple output items with simplified output', async () => {
const mockResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'First response' }],
},
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Second response' }],
},
],
};
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
});
mockApiRequest.mockResolvedValue(mockResponse);
mockGetConnectedTools.mockResolvedValue([]);
const result = await execute.call(mockExecuteFunctions, 0);
// With simplify=true, should return the entire output array as a single item
expect(result).toHaveLength(1);
expect(result[0].json).toEqual(mockResponse.output);
});
});
describe('Background Mode', () => {
it('should handle background mode execution with polling', async () => {
const initialResponse = {
id: 'resp_123',
status: 'in_progress',
output: [],
};
const completedResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Background response' }],
},
],
};
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'options.backgroundMode.values.enabled') return true;
if (param === 'options.backgroundMode.values.timeout') return 300;
return 'default';
});
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
background: true,
});
mockApiRequest.mockResolvedValueOnce(initialResponse);
mockPollUntilAvailable.mockResolvedValue(completedResponse);
mockGetConnectedTools.mockResolvedValue([]);
const result = await execute.call(mockExecuteFunctions, 0);
expect(mockPollUntilAvailable).toHaveBeenCalledWith(
mockExecuteFunctions,
expect.any(Function),
expect.any(Function),
300,
10,
);
expect(result).toEqual([
{
json: completedResponse.output,
pairedItem: { item: 0 },
},
]);
});
it('should throw error when background mode fails', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'options.backgroundMode.values.enabled') return true;
return 'default';
});
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
background: true,
});
mockApiRequest.mockResolvedValueOnce({ id: 'resp_123', status: 'in_progress' });
mockPollUntilAvailable.mockImplementation(async (context, pollFn, checkFn) => {
const response = await pollFn();
if (checkFn(response)) {
throw new NodeOperationError(context.getNode(), 'Background mode error', {
description: 'Background processing failed',
});
}
return response;
});
await expect(execute.call(mockExecuteFunctions, 0)).rejects.toThrow(NodeOperationError);
});
});
describe('Tool Calls', () => {
it('should execute tool calls with external tools', async () => {
const mockTool = {
name: 'test_tool',
invoke: jest.fn().mockResolvedValue('Tool response'),
schema: {
typeName: 'ZodObject',
_def: { typeName: 'ZodObject', shape: () => ({}) },
parse: jest.fn(),
safeParse: jest.fn(),
},
call: jest.fn(),
description: 'Test tool',
returnDirect: false,
} as any;
const initialResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'function_call',
call_id: 'call_123',
name: 'test_tool',
arguments: JSON.stringify({ input: 'test input' }),
},
],
};
const finalResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Final response' }],
},
],
};
mockGetConnectedTools.mockResolvedValue([mockTool]);
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
});
mockApiRequest.mockResolvedValueOnce(initialResponse).mockResolvedValueOnce(finalResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(mockTool.invoke).toHaveBeenCalledWith('test input');
expect(mockApiRequest).toHaveBeenCalledTimes(2);
expect(result).toEqual([
{
json: finalResponse.output,
pairedItem: { item: 0 },
},
]);
});
it('should handle tool call with object response', async () => {
const mockTool = {
name: 'test_tool',
invoke: jest.fn().mockResolvedValue({ result: 'success', data: 'test data' }),
schema: {
typeName: 'ZodObject',
_def: { typeName: 'ZodObject', shape: () => ({}) },
parse: jest.fn(),
safeParse: jest.fn(),
},
call: jest.fn(),
description: 'Test tool',
returnDirect: false,
} as any;
const initialResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'function_call',
call_id: 'call_123',
name: 'test_tool',
arguments: JSON.stringify({ data: 'test input' }),
},
],
};
const finalResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Final response' }],
},
],
};
mockGetConnectedTools.mockResolvedValue([mockTool]);
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
});
mockApiRequest.mockResolvedValueOnce(initialResponse).mockResolvedValueOnce(finalResponse);
await execute.call(mockExecuteFunctions, 0);
expect(mockTool.invoke).toHaveBeenCalledWith({ data: 'test input' });
});
it('should respect max tool iterations limit', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'options.maxToolsIterations') return 2;
return 'default';
});
const mockTool = {
name: 'test_tool',
invoke: jest.fn().mockResolvedValue('Tool response'),
schema: {
typeName: 'ZodObject',
_def: { typeName: 'ZodObject', shape: () => ({}) },
parse: jest.fn(),
safeParse: jest.fn(),
},
call: jest.fn(),
description: 'Test tool',
returnDirect: false,
} as any;
const responseWithToolCalls = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'function_call',
call_id: 'call_123',
name: 'test_tool',
arguments: JSON.stringify({ input: 'test input' }),
},
],
};
mockGetConnectedTools.mockResolvedValue([mockTool]);
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
});
mockApiRequest.mockResolvedValue(responseWithToolCalls);
await execute.call(mockExecuteFunctions, 0);
expect(mockApiRequest).toHaveBeenCalledTimes(3); // Initial + 2 iterations
});
it('should handle abort signal during tool calls', async () => {
const abortController = new AbortController();
abortController.abort();
mockExecuteFunctions.getExecutionCancelSignal.mockReturnValue(abortController.signal);
const mockTool = {
name: 'test_tool',
invoke: jest.fn().mockResolvedValue('Tool response'),
schema: {
typeName: 'ZodObject',
_def: { typeName: 'ZodObject', shape: () => ({}) },
parse: jest.fn(),
safeParse: jest.fn(),
},
call: jest.fn(),
description: 'Test tool',
returnDirect: false,
} as any;
const responseWithToolCalls = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'function_call',
call_id: 'call_123',
name: 'test_tool',
arguments: JSON.stringify({ input: 'test input' }),
},
],
};
mockGetConnectedTools.mockResolvedValue([mockTool]);
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
});
mockApiRequest.mockResolvedValue(responseWithToolCalls);
await execute.call(mockExecuteFunctions, 0);
expect(mockApiRequest).toHaveBeenCalledTimes(1); // Only initial call
});
it('should handle reasoning models with reasoning items in tool calls', async () => {
const mockTool = {
name: 'test_tool',
invoke: jest.fn().mockResolvedValue('Tool response'),
schema: {
typeName: 'ZodObject',
_def: { typeName: 'ZodObject', shape: () => ({}) },
parse: jest.fn(),
safeParse: jest.fn(),
},
call: jest.fn(),
description: 'Test tool',
returnDirect: false,
} as any;
const initialResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'reasoning',
content: 'I need to use the test tool to get information',
},
{
type: 'function_call',
call_id: 'call_123',
name: 'test_tool',
arguments: JSON.stringify({ input: 'test input' }),
},
],
};
const finalResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Final response' }],
},
],
};
mockGetConnectedTools.mockResolvedValue([mockTool]);
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
});
mockApiRequest.mockResolvedValueOnce(initialResponse).mockResolvedValueOnce(finalResponse);
const result = await execute.call(mockExecuteFunctions, 0);
expect(mockTool.invoke).toHaveBeenCalledWith('test input');
expect(mockApiRequest).toHaveBeenCalledTimes(2);
expect(result).toEqual([
{
json: finalResponse.output,
pairedItem: { item: 0 },
},
]);
});
it('should handle reasoning models with only reasoning items (no function calls)', async () => {
const responseWithOnlyReasoning = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'reasoning',
content: 'I am thinking about this problem',
},
{
type: 'reasoning',
content: 'I have reached a conclusion',
},
],
};
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
});
mockApiRequest.mockResolvedValue(responseWithOnlyReasoning);
mockGetConnectedTools.mockResolvedValue([]);
const result = await execute.call(mockExecuteFunctions, 0);
// Should not make additional API calls since there are no function calls
expect(mockApiRequest).toHaveBeenCalledTimes(1);
expect(result).toEqual([
{
json: responseWithOnlyReasoning.output,
pairedItem: { item: 0 },
},
]);
});
});
describe('JSON Format Handling', () => {
it('should handle JSON parsing errors gracefully', async () => {
const mockResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [
{
type: 'output_text',
text: 'invalid json',
},
],
},
],
};
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
text: { format: { type: 'json_object' } },
});
mockApiRequest.mockResolvedValue(mockResponse);
mockGetConnectedTools.mockResolvedValue([]);
const result = await execute.call(mockExecuteFunctions, 0);
expect((result[0].json as any)[0].content[0].text).toBe('invalid json');
});
});
describe('Edge Cases', () => {
it('should handle empty messages array', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'responses.values') return [];
return 'default';
});
const mockResponse = {
id: 'resp_123',
status: 'completed',
output: [],
};
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
});
mockApiRequest.mockResolvedValue(mockResponse);
mockGetConnectedTools.mockResolvedValue([]);
const result = await execute.call(mockExecuteFunctions, 0);
expect(result).toEqual([
{
json: [],
pairedItem: { item: 0 },
},
]);
});
it('should handle tools hidden for unsupported models', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'hideTools') return 'hide';
return 'default';
});
const mockResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Response without tools' }],
},
],
};
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
});
mockApiRequest.mockResolvedValue(mockResponse);
mockGetConnectedTools.mockResolvedValue([]);
const result = await execute.call(mockExecuteFunctions, 0);
expect(mockGetConnectedTools).not.toHaveBeenCalled();
expect(result).toEqual([
{
json: mockResponse.output,
pairedItem: { item: 0 },
},
]);
});
it('should handle non-function-call items in output', async () => {
const mockResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Regular message' }],
},
{
type: 'function_call',
call_id: 'call_123',
name: 'test_tool',
arguments: JSON.stringify({ input: 'test input' }),
},
],
};
const mockTool = {
name: 'test_tool',
invoke: jest.fn().mockResolvedValue('Tool response'),
schema: {
typeName: 'ZodObject',
_def: { typeName: 'ZodObject', shape: () => ({}) },
parse: jest.fn(),
safeParse: jest.fn(),
},
call: jest.fn(),
description: 'Test tool',
returnDirect: false,
} as any;
mockGetConnectedTools.mockResolvedValue([mockTool]);
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: false }],
});
mockApiRequest.mockResolvedValueOnce(mockResponse).mockResolvedValueOnce({
...mockResponse,
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Final response' }],
},
],
});
await execute.call(mockExecuteFunctions, 0);
expect(mockTool.invoke).toHaveBeenCalledTimes(1);
});
it('should use dynamic strict parameter calculation for tools', async () => {
const mockTool = {
name: 'test_tool',
invoke: jest.fn().mockResolvedValue('Tool response'),
schema: {
typeName: 'ZodObject',
_def: { typeName: 'ZodObject', shape: () => ({}) },
parse: jest.fn(),
safeParse: jest.fn(),
},
call: jest.fn(),
description: 'Test tool',
returnDirect: false,
} as any;
// Mock the formatToOpenAIResponsesTool to return different strict values
mockFormatToOpenAIResponsesTool.mockImplementation((tool: Tool) => {
return {
type: 'function',
name: tool.name,
parameters: {
type: 'object',
properties: { input: { type: 'string' } },
required: ['input'],
},
strict: true, // This should be calculated dynamically based on schema
description: tool.description,
};
});
const responseWithToolCalls = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'function_call',
call_id: 'call_123',
name: 'test_tool',
arguments: JSON.stringify({ input: 'test input' }),
},
],
};
const finalResponse = {
id: 'resp_123',
status: 'completed',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Final response' }],
},
],
};
mockGetConnectedTools.mockResolvedValue([mockTool]);
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
tools: [{ name: 'test_tool', type: 'function', parameters: {}, strict: true }],
});
mockApiRequest
.mockResolvedValueOnce(responseWithToolCalls)
.mockResolvedValueOnce(finalResponse);
await execute.call(mockExecuteFunctions, 0);
expect(mockFormatToOpenAIResponsesTool).toHaveBeenCalledWith(
mockTool,
expect.anything(),
expect.anything(),
);
expect(mockTool.invoke).toHaveBeenCalledWith('test input');
});
});
describe('Parameter Handling', () => {
it('should pass correct parameters to createRequest', async () => {
const mockMessages = [
{
role: 'user',
type: 'text',
content: 'Test message',
},
];
const mockOptions = {
maxTokens: 100,
temperature: 0.7,
};
const mockBuiltInTools = {
webSearch: { searchContextSize: 'high' },
};
mockExecuteFunctions.getNodeParameter.mockImplementation((param: string) => {
if (param === 'modelId') return 'gpt-4o';
if (param === 'responses.values') return mockMessages;
if (param === 'options') return mockOptions;
if (param === 'builtInTools') return mockBuiltInTools;
return 'default';
});
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
});
mockApiRequest.mockResolvedValue({
id: 'resp_123',
status: 'completed',
output: [],
});
mockGetConnectedTools.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, 0);
expect(mockCreateRequest).toHaveBeenCalledWith(0, {
model: 'gpt-4o',
messages: mockMessages,
options: mockOptions,
tools: undefined,
builtInTools: mockBuiltInTools,
});
});
it('should handle default values for optional parameters', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation(
(param: string, _itemIndex: number, defaultValue?: unknown) => {
if (param === 'modelId') return 'gpt-4o';
if (param === 'responses.values') return [];
if (param === 'options') return {};
if (param === 'builtInTools') return {};
return defaultValue as any;
},
);
mockCreateRequest.mockResolvedValue({
model: 'gpt-4o',
input: [],
});
mockApiRequest.mockResolvedValue({
id: 'resp_123',
status: 'completed',
output: [],
});
mockGetConnectedTools.mockResolvedValue([]);
await execute.call(mockExecuteFunctions, 0);
expect(mockCreateRequest).toHaveBeenCalledWith(0, {
model: 'gpt-4o',
messages: [],
options: {},
tools: undefined,
builtInTools: {},
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,488 @@
import { mock, mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import * as binaryDataHelpers from '../../../../helpers/binary-data';
import type { VideoJob } from '../../../../helpers/interfaces';
import * as pollingHelpers from '../../../../helpers/polling';
import * as transport from '../../../../transport';
import { execute } from '../../../../v2/actions/video/generate.operation';
import FormData from 'form-data';
jest.mock('../../../../helpers/binary-data');
jest.mock('../../../../helpers/polling');
jest.mock('../../../../transport');
jest.mock('form-data', () => jest.fn());
const mockFormData = jest.mocked(FormData);
describe('Video Generate Operation', () => {
let mockExecuteFunctions: jest.Mocked<IExecuteFunctions>;
let mockNode: INode;
let mockFormDataInstance: jest.Mocked<FormData>;
const apiRequestSpy = jest.spyOn(transport, 'apiRequest');
const getBinaryDataFileSpy = jest.spyOn(binaryDataHelpers, 'getBinaryDataFile');
const pollUntilAvailableSpy = jest.spyOn(pollingHelpers, 'pollUntilAvailable');
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockNode = mock<INode>({
id: 'test-node',
name: 'OpenAI Video Generate',
type: 'n8n-nodes-base.openAi',
typeVersion: 2,
position: [0, 0],
parameters: {},
});
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.helpers.prepareBinaryData = jest.fn();
mockExecuteFunctions.helpers.binaryToBuffer = jest.fn();
mockFormDataInstance = {
append: jest.fn(),
getHeaders: jest.fn().mockReturnValue({ 'content-type': 'multipart/form-data' }),
} as unknown as jest.Mocked<FormData>;
mockFormData.mockImplementation(() => mockFormDataInstance);
});
afterEach(() => {
jest.resetAllMocks();
});
describe('successful execution', () => {
beforeEach(() => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'sora-2',
prompt: 'A cat playing with a ball',
seconds: 4,
size: '1280x720',
options: {},
};
return params[paramName as keyof typeof params];
});
});
it('should generate video with basic parameters', async () => {
const mockVideoJob: VideoJob = {
id: 'video-123',
created_at: Date.now(),
model: 'sora-2',
object: 'video',
seconds: '4',
size: '1280x720',
status: 'queued',
};
const mockCompletedJob: VideoJob = {
...mockVideoJob,
status: 'completed',
completed_at: Date.now(),
};
const mockContentResponse = {
headers: { 'content-type': 'video/mp4' },
body: Buffer.from('mock-video-data'),
};
const mockBinaryData = {
data: 'base64-encoded-video',
mimeType: 'video/mp4',
fileName: 'data',
};
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
pollUntilAvailableSpy.mockResolvedValue(mockCompletedJob);
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
mockBinaryData,
);
const result = await execute.call(mockExecuteFunctions, 0);
expect(apiRequestSpy).toHaveBeenCalledTimes(2);
expect(apiRequestSpy).toHaveBeenNthCalledWith(1, 'POST', '/videos', {
option: { formData: expect.any(Object) },
headers: expect.any(Object),
});
expect(apiRequestSpy).toHaveBeenNthCalledWith(2, 'GET', '/videos/video-123/content', {
option: {
useStream: true,
resolveWithFullResponse: true,
json: false,
encoding: null,
},
});
expect(pollUntilAvailableSpy).toHaveBeenCalledWith(
mockExecuteFunctions,
expect.any(Function),
expect.any(Function),
300,
10,
);
expect(result).toEqual([
{
json: {
data: undefined,
mimeType: 'video/mp4',
fileName: 'data',
},
binary: {
data: mockBinaryData,
},
pairedItem: { item: 0 },
},
]);
});
it('should generate video with custom options', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'sora-2',
prompt: 'A beautiful sunset over mountains',
seconds: 8,
size: '1792x1024',
options: {
waitTime: 600,
fileName: 'sunset_video',
},
};
return params[paramName as keyof typeof params];
});
const mockVideoJob: VideoJob = {
id: 'video-456',
created_at: Date.now(),
model: 'sora-2',
object: 'video',
seconds: '8',
size: '1792x1024',
status: 'completed',
completed_at: Date.now(),
};
const mockContentResponse = {
headers: { 'content-type': 'video/mp4' },
body: Buffer.from('mock-video-data'),
};
const mockBinaryData = {
data: 'base64-encoded-video',
mimeType: 'video/mp4',
fileName: 'sunset_video',
};
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
pollUntilAvailableSpy.mockResolvedValue(mockVideoJob);
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
mockBinaryData,
);
const result = await execute.call(mockExecuteFunctions, 0);
expect(pollUntilAvailableSpy).toHaveBeenCalledWith(
mockExecuteFunctions,
expect.any(Function),
expect.any(Function),
600,
10,
);
expect(mockExecuteFunctions.helpers.prepareBinaryData).toHaveBeenCalledWith(
mockContentResponse.body,
'sunset_video',
'video/mp4',
);
expect(result[0].json.fileName).toBe('sunset_video');
});
it('should generate video with binary reference image', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'sora-2',
prompt: 'Continue this scene with motion',
seconds: 6,
size: '1280x720',
options: {
binaryPropertyNameReference: 'reference_image',
},
};
return params[paramName as keyof typeof params];
});
const mockBinaryFile = {
fileContent: Buffer.from('mock-image-data'),
contentType: 'image/jpeg',
filename: 'reference.jpg',
};
const mockVideoJob: VideoJob = {
id: 'video-789',
created_at: Date.now(),
model: 'sora-2',
object: 'video',
seconds: '6',
size: '1280x720',
status: 'completed',
};
const mockContentResponse = {
headers: { 'content-type': 'video/mp4' },
body: Buffer.from('mock-video-data'),
};
const mockBinaryData = {
data: 'base64-encoded-video',
mimeType: 'video/mp4',
fileName: 'data',
};
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
mockBinaryFile.fileContent,
);
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
pollUntilAvailableSpy.mockResolvedValue(mockVideoJob);
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
mockBinaryData,
);
const result = await execute.call(mockExecuteFunctions, 0);
expect(getBinaryDataFileSpy).toHaveBeenCalledWith(mockExecuteFunctions, 0, 'reference_image');
expect(mockExecuteFunctions.helpers.binaryToBuffer).toHaveBeenCalledWith(
mockBinaryFile.fileContent,
);
expect(result).toHaveLength(1);
expect(result[0].binary?.data).toBe(mockBinaryData);
});
});
describe('FormData handling', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should create FormData with correct parameters', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'sora-2',
prompt: 'Test video generation',
seconds: 6,
size: '1024x1792',
options: {},
};
return params[paramName as keyof typeof params];
});
const mockVideoJob: VideoJob = {
id: 'video-formdata',
created_at: Date.now(),
model: 'sora-2',
object: 'video',
seconds: '6',
size: '1024x1792',
status: 'completed',
};
const mockContentResponse = {
headers: { 'content-type': 'video/mp4' },
body: Buffer.from('mock-video-data'),
};
const mockBinaryData = {
data: 'base64-encoded-video',
mimeType: 'video/mp4',
fileName: 'data',
};
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
pollUntilAvailableSpy.mockResolvedValue(mockVideoJob);
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
mockBinaryData,
);
await execute.call(mockExecuteFunctions, 0);
expect(mockFormDataInstance.append).toHaveBeenCalledWith('model', 'sora-2');
expect(mockFormDataInstance.append).toHaveBeenCalledWith('prompt', 'Test video generation');
expect(mockFormDataInstance.append).toHaveBeenCalledWith('seconds', '6');
expect(mockFormDataInstance.append).toHaveBeenCalledWith('size', '1024x1792');
expect(mockFormDataInstance.getHeaders).toHaveBeenCalled();
});
it('should append binary reference when provided', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'sora-2',
prompt: 'Test with reference',
seconds: 4,
size: '1280x720',
options: {
binaryPropertyNameReference: 'reference',
},
};
return params[paramName as keyof typeof params];
});
const mockBinaryFile = {
fileContent: Buffer.from('mock-image-data'),
contentType: 'image/png',
filename: 'reference.png',
};
const mockVideoJob: VideoJob = {
id: 'video-with-ref',
created_at: Date.now(),
model: 'sora-2',
object: 'video',
seconds: '4',
size: '1280x720',
status: 'completed',
};
const mockContentResponse = {
headers: { 'content-type': 'video/mp4' },
body: Buffer.from('mock-video-data'),
};
const mockBinaryData = {
data: 'base64-encoded-video',
mimeType: 'video/mp4',
fileName: 'data',
};
getBinaryDataFileSpy.mockResolvedValue(mockBinaryFile);
(mockExecuteFunctions.helpers.binaryToBuffer as jest.Mock).mockResolvedValue(
mockBinaryFile.fileContent,
);
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
pollUntilAvailableSpy.mockResolvedValue(mockVideoJob);
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
mockBinaryData,
);
await execute.call(mockExecuteFunctions, 0);
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
'input_reference',
mockBinaryFile.fileContent,
{
filename: 'reference.png',
contentType: 'image/png',
},
);
});
});
describe('binary data processing', () => {
it('should process video content with correct MIME type', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'sora-2',
prompt: 'Test MIME type',
seconds: 4,
size: '1280x720',
options: {},
};
return params[paramName as keyof typeof params];
});
const mockVideoJob: VideoJob = {
id: 'video-mime',
created_at: Date.now(),
model: 'sora-2',
object: 'video',
seconds: '4',
size: '1280x720',
status: 'completed',
};
const mockContentResponse = {
headers: { 'content-type': 'video/webm' },
body: Buffer.from('mock-webm-video-data'),
};
const mockBinaryData = {
data: 'base64-encoded-webm-video',
mimeType: 'video/webm',
fileName: 'data',
};
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
pollUntilAvailableSpy.mockResolvedValue(mockVideoJob);
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
mockBinaryData,
);
const result = await execute.call(mockExecuteFunctions, 0);
expect(mockExecuteFunctions.helpers.prepareBinaryData).toHaveBeenCalledWith(
mockContentResponse.body,
'data',
'video/webm',
);
expect(result[0].json.mimeType).toBe('video/webm');
});
it('should handle missing content-type header', async () => {
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName: string) => {
const params = {
modelId: 'sora-2',
prompt: 'Test no MIME type',
seconds: 4,
size: '1280x720',
options: {},
};
return params[paramName as keyof typeof params];
});
const mockVideoJob: VideoJob = {
id: 'video-no-mime',
created_at: Date.now(),
model: 'sora-2',
object: 'video',
seconds: '4',
size: '1280x720',
status: 'completed',
};
const mockContentResponse = {
headers: {},
body: Buffer.from('mock-video-data'),
};
const mockBinaryData = {
data: 'base64-encoded-video',
mimeType: undefined,
fileName: 'data',
};
apiRequestSpy.mockResolvedValueOnce(mockVideoJob).mockResolvedValueOnce(mockContentResponse);
pollUntilAvailableSpy.mockResolvedValue(mockVideoJob);
(mockExecuteFunctions.helpers.prepareBinaryData as jest.Mock).mockResolvedValue(
mockBinaryData,
);
const result = await execute.call(mockExecuteFunctions, 0);
expect(mockExecuteFunctions.helpers.prepareBinaryData).toHaveBeenCalledWith(
mockContentResponse.body,
'data',
undefined,
);
expect(result[0].json.mimeType).toBeUndefined();
});
});
});

View File

@ -0,0 +1,311 @@
import lodashGet from 'lodash/get';
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
import * as transport from '../../transport';
import * as create from '../../v2/actions/conversation/create.operation';
import * as getOperation from '../../v2/actions/conversation/get.operation';
import * as remove from '../../v2/actions/conversation/remove.operation';
import * as update from '../../v2/actions/conversation/update.operation';
jest.mock('../../transport', () => ({
apiRequest: jest.fn(),
}));
jest.mock('../../v2/actions/text/helpers/responses', () => ({
formatInputMessages: jest.fn().mockResolvedValue([
{
role: 'user',
content: [{ type: 'input_text', text: 'Hello' }],
},
]),
}));
const createExecuteFunctionsMock = (parameters: IDataObject): IExecuteFunctions => {
const nodeParameters = parameters;
return {
getExecutionCancelSignal() {
return new AbortController().signal;
},
getNodeParameter(parameter: string, _itemIndex: number, defaultValue?: unknown) {
return lodashGet(nodeParameters, parameter, defaultValue);
},
getNode() {
return {
id: 'test-node',
name: 'Test Node',
type: 'n8n-nodes-langchain.openAi',
typeVersion: 2,
position: [0, 0],
parameters: {},
};
},
getInputConnectionData() {
return undefined;
},
helpers: {
prepareBinaryData: jest.fn().mockResolvedValue({
data: 'base64data',
mimeType: 'text/plain',
fileName: 'test.txt',
}),
assertBinaryData: jest.fn().mockReturnValue({
filename: 'test.txt',
contentType: 'text/plain',
}),
getBinaryDataBuffer: jest.fn().mockReturnValue(Buffer.from('test data')),
binaryToBuffer: jest.fn().mockResolvedValue(Buffer.from('test data')),
},
} as unknown as IExecuteFunctions;
};
describe('OpenAI Conversation Operations', () => {
const mockApiRequest = transport.apiRequest as jest.MockedFunction<typeof transport.apiRequest>;
beforeEach(() => {
jest.clearAllMocks();
});
describe('Create Operation', () => {
it('should create a conversation with messages successfully', async () => {
const mockResponse = {
id: 'conv_1234567890',
object: 'conversation',
created_at: 1234567890,
items: [
{
id: 'item_1',
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'Hello' }],
},
],
};
mockApiRequest.mockResolvedValueOnce(mockResponse);
const executeFunctions = createExecuteFunctionsMock({
'messages.values': [
{
role: 'user',
type: 'text',
content: 'Hello',
},
],
options: {},
});
const result = await create.execute.call(executeFunctions, 0);
expect(result).toEqual([
{
json: mockResponse,
pairedItem: { item: 0 },
},
]);
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/conversations', {
body: {
items: expect.any(Array),
},
});
});
it('should create a conversation with metadata', async () => {
const mockResponse = {
id: 'conv_1234567890',
object: 'conversation',
created_at: 1234567890,
};
mockApiRequest.mockResolvedValueOnce(mockResponse);
const executeFunctions = createExecuteFunctionsMock({
'messages.values': [
{
role: 'user',
type: 'text',
content: 'Hello',
},
],
options: {
metadata: '{"custom": "value", "source": "test"}',
},
});
const result = await create.execute.call(executeFunctions, 0);
expect(result).toEqual([
{
json: mockResponse,
pairedItem: { item: 0 },
},
]);
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/conversations', {
body: {
items: expect.any(Array),
metadata: { custom: 'value', source: 'test' },
},
});
});
it('should handle empty metadata gracefully', async () => {
const mockResponse = {
id: 'conv_1234567890',
object: 'conversation',
};
mockApiRequest.mockResolvedValueOnce(mockResponse);
const executeFunctions = createExecuteFunctionsMock({
'messages.values': [
{
role: 'user',
type: 'text',
content: 'Hello',
},
],
options: {
metadata: '{}',
},
});
const result = await create.execute.call(executeFunctions, 0);
expect(result).toEqual([
{
json: mockResponse,
pairedItem: { item: 0 },
},
]);
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/conversations', {
body: {
items: expect.any(Array),
},
});
});
it('should throw error for invalid JSON metadata', async () => {
const executeFunctions = createExecuteFunctionsMock({
'messages.values': [
{
role: 'user',
type: 'text',
content: 'Hello',
},
],
options: {
metadata: 'invalid json',
},
});
await expect(create.execute.call(executeFunctions, 0)).rejects.toThrow(
'Invalid JSON in metadata field',
);
});
});
describe('Get Operation', () => {
it('should retrieve a conversation successfully', async () => {
const mockResponse = {
id: 'conv_1234567890',
object: 'conversation',
created_at: 1234567890,
items: [
{
id: 'item_1',
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'Hello' }],
},
],
};
mockApiRequest.mockResolvedValueOnce(mockResponse);
const executeFunctions = createExecuteFunctionsMock({
conversationId: 'conv_1234567890',
});
const result = await getOperation.execute.call(executeFunctions, 0);
expect(result).toEqual([
{
json: mockResponse,
pairedItem: { item: 0 },
},
]);
expect(mockApiRequest).toHaveBeenCalledWith('GET', '/conversations/conv_1234567890');
});
});
describe('Remove Operation', () => {
it('should delete a conversation successfully', async () => {
const mockResponse = {
id: 'conv_1234567890',
object: 'conversation',
deleted: true,
};
mockApiRequest.mockResolvedValueOnce(mockResponse);
const executeFunctions = createExecuteFunctionsMock({
conversationId: 'conv_1234567890',
});
const result = await remove.execute.call(executeFunctions, 0);
expect(result).toEqual([
{
json: mockResponse,
pairedItem: { item: 0 },
},
]);
expect(mockApiRequest).toHaveBeenCalledWith('DELETE', '/conversations/conv_1234567890');
});
});
describe('Update Operation', () => {
it('should update a conversation with metadata successfully', async () => {
const mockResponse = {
id: 'conv_1234567890',
object: 'conversation',
updated_at: 1234567890,
metadata: {
custom: 'value',
source: 'test',
},
};
mockApiRequest.mockResolvedValueOnce(mockResponse);
const executeFunctions = createExecuteFunctionsMock({
conversationId: 'conv_1234567890',
metadata: '{"custom": "value", "source": "test"}',
});
const result = await update.execute.call(executeFunctions, 0);
expect(result).toEqual([
{
json: mockResponse,
pairedItem: { item: 0 },
},
]);
expect(mockApiRequest).toHaveBeenCalledWith('POST', '/conversations/conv_1234567890', {
body: {
metadata: { custom: 'value', source: 'test' },
},
});
});
it('should throw error for invalid JSON metadata', async () => {
const executeFunctions = createExecuteFunctionsMock({
conversationId: 'conv_1234567890',
metadata: 'invalid json',
});
await expect(update.execute.call(executeFunctions, 0)).rejects.toThrow(
'Invalid JSON in metadata field',
);
});
});
});