mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
test(OpenAI Node): Add tests (#20711)
This commit is contained in:
parent
d20a2e585e
commit
30ce501fa0
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
912
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/image/analyze.test.ts
vendored
Normal file
912
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/image/analyze.test.ts
vendored
Normal 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',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
692
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/image/edit.operation.test.ts
vendored
Normal file
692
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/image/edit.operation.test.ts
vendored
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
883
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/text/response.operation.test.ts
vendored
Normal file
883
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/text/response.operation.test.ts
vendored
Normal 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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1469
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/text/responses.test.ts
vendored
Normal file
1469
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/text/responses.test.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
488
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/video/generate.operation.test.ts
vendored
Normal file
488
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/video/generate.operation.test.ts
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
311
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/conversation.test.ts
vendored
Normal file
311
packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/conversation.test.ts
vendored
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user