diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/utils.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/utils.test.ts index 1ab07ff1772..5af00887bb6 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/utils.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/utils.test.ts @@ -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', + }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAI.workflow.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v1/OpenAI.workflow.test.ts similarity index 100% rename from packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAI.workflow.test.ts rename to packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v1/OpenAI.workflow.test.ts diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAi.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v1/OpenAi.node.test.ts similarity index 98% rename from packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAi.node.test.ts rename to packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v1/OpenAi.node.test.ts index ee070efce5c..ebe87959ade 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/OpenAi.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v1/OpenAi.node.test.ts @@ -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; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/list-assistants.workflow.json b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v1/list-assistants.workflow.json similarity index 100% rename from packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/list-assistants.workflow.json rename to packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v1/list-assistants.workflow.json diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/image/analyze.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/image/analyze.test.ts new file mode 100644 index 00000000000..325002ffaf8 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/image/analyze.test.ts @@ -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; + let mockNode: INode; + const apiRequestSpy = jest.spyOn(transport, 'apiRequest'); + const getBinaryDataFileSpy = jest.spyOn(binaryDataHelpers, 'getBinaryDataFile'); + + beforeEach(() => { + mockExecuteFunctions = mockDeep(); + mockNode = mock({ + 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 = { + 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 = { + 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', + }), + ], + }, + ], + }), + }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/image/edit.operation.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/image/edit.operation.test.ts new file mode 100644 index 00000000000..759114e4b19 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/image/edit.operation.test.ts @@ -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; + let mockNode: INode; + let mockFormDataInstance: jest.Mocked; + const apiRequestSpy = jest.spyOn(transport, 'apiRequest'); + const getBinaryDataFileSpy = jest.spyOn(binaryDataHelpers, 'getBinaryDataFile'); + + beforeEach(() => { + mockExecuteFunctions = mockDeep(); + mockNode = mock({ + 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; + 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'); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/text/response.operation.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/text/response.operation.test.ts new file mode 100644 index 00000000000..f241ed2a12b --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/text/response.operation.test.ts @@ -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; +const mockCreateRequest = helpers.createRequest as jest.MockedFunction< + typeof helpers.createRequest +>; +const mockGetConnectedTools = getConnectedTools as jest.MockedFunction; +const mockPollUntilAvailable = pollUntilAvailable as jest.MockedFunction; + +describe('OpenAI Response Operation', () => { + let mockExecuteFunctions: jest.Mocked; + let mockNode: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockExecuteFunctions = mockDeep(); + mockNode = mock({ + 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 = { + 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: {}, + }); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/text/responses.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/text/responses.test.ts new file mode 100644 index 00000000000..a793cd37331 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/text/responses.test.ts @@ -0,0 +1,1469 @@ +import lodashGet from 'lodash/get'; +import type { IDataObject, IExecuteFunctions } from 'n8n-workflow'; +import type { FunctionTool } from 'openai/resources/responses/responses'; + +import { getBinaryDataFile } from '../../../../helpers/binary-data'; +import { formatInputMessages, createRequest } from '../../../../v2/actions/text/helpers/responses'; + +jest.mock('../../../../helpers/binary-data', () => ({ + getBinaryDataFile: jest.fn(), +})); + +const mockGetBinaryDataFile = jest.mocked(getBinaryDataFile); + +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')), + getBinaryStream: jest.fn().mockResolvedValue(Buffer.from('test data')), + }, + } as unknown as IExecuteFunctions; +}; + +describe('OpenAI Responses Helper Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetBinaryDataFile.mockResolvedValue({ + filename: 'test.png', + contentType: 'image/png', + fileContent: Buffer.from('test image data'), + }); + }); + + describe('formatInputMessages', () => { + it('should format text messages correctly', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const messages = [ + { + role: 'user', + type: 'text', + content: 'Hello, how are you?', + }, + ]; + + const result = await formatInputMessages.call(executeFunctions, 0, messages); + + expect(result).toEqual([ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello, how are you?' }], + }, + ]); + }); + + it('should format text messages without type (defaults to text)', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const messages = [ + { + role: 'assistant', + content: 'I am doing well, thank you!', + }, + ]; + + const result = await formatInputMessages.call(executeFunctions, 0, messages); + + expect(result).toEqual([ + { + role: 'assistant', + content: [{ type: 'input_text', text: 'I am doing well, thank you!' }], + }, + ]); + }); + + it('should format image messages with URL', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const messages = [ + { + role: 'user', + type: 'image', + imageType: 'url', + imageUrl: 'https://example.com/image.png', + imageDetail: 'high', + }, + ]; + + const result = await formatInputMessages.call(executeFunctions, 0, messages); + + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'input_image', + detail: 'high', + image_url: 'https://example.com/image.png', + }, + ], + }, + ]); + }); + + it('should format image messages with file ID', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const messages = [ + { + role: 'user', + type: 'image', + imageType: 'fileId', + fileId: 'file-1234567890', + imageDetail: 'low', + }, + ]; + + const result = await formatInputMessages.call(executeFunctions, 0, messages); + + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'input_image', + detail: 'low', + file_id: 'file-1234567890', + }, + ], + }, + ]); + }); + + it('should format image messages with base64 data', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const messages = [ + { + role: 'user', + type: 'image', + imageType: 'base64', + binaryPropertyName: 'imageData', + imageDetail: 'auto', + }, + ]; + + const result = await formatInputMessages.call(executeFunctions, 0, messages); + + expect(mockGetBinaryDataFile).toHaveBeenCalledWith(executeFunctions, 0, 'imageData'); + expect(executeFunctions.helpers.binaryToBuffer).toHaveBeenCalledWith( + Buffer.from('test image data'), + ); + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'input_image', + detail: 'auto', + image_url: 'data:image/png;base64,dGVzdCBkYXRh', + }, + ], + }, + ]); + }); + + it('should format image messages with default detail when not specified', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const messages = [ + { + role: 'user', + type: 'image', + imageType: 'url', + imageUrl: 'https://example.com/image.png', + }, + ]; + + const result = await formatInputMessages.call(executeFunctions, 0, messages); + + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'input_image', + detail: 'auto', + image_url: 'https://example.com/image.png', + }, + ], + }, + ]); + }); + + it('should format file messages with URL', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const messages = [ + { + role: 'user', + type: 'file', + fileType: 'url', + fileUrl: 'https://example.com/document.pdf', + }, + ]; + + const result = await formatInputMessages.call(executeFunctions, 0, messages); + + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'input_file', + file_url: 'https://example.com/document.pdf', + }, + ], + }, + ]); + }); + + it('should format file messages with file ID', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const messages = [ + { + role: 'user', + type: 'file', + fileType: 'fileId', + fileId: 'file-1234567890', + }, + ]; + + const result = await formatInputMessages.call(executeFunctions, 0, messages); + + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'input_file', + file_id: 'file-1234567890', + }, + ], + }, + ]); + }); + + it('should format file messages with base64 data', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const messages = [ + { + role: 'user', + type: 'file', + fileType: 'base64', + binaryPropertyName: 'fileData', + fileName: 'document.pdf', + }, + ]; + + const result = await formatInputMessages.call(executeFunctions, 0, messages); + + expect(mockGetBinaryDataFile).toHaveBeenCalledWith(executeFunctions, 0, 'fileData'); + expect(executeFunctions.helpers.binaryToBuffer).toHaveBeenCalledWith( + Buffer.from('test image data'), + ); + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'input_file', + filename: 'document.pdf', + file_data: 'data:image/png;base64,dGVzdCBkYXRh', + }, + ], + }, + ]); + }); + + it('should format file messages without file name', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const messages = [ + { + role: 'user', + type: 'file', + fileType: 'url', + fileUrl: 'https://example.com/document.pdf', + }, + ]; + + const result = await formatInputMessages.call(executeFunctions, 0, messages); + + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'input_file', + file_url: 'https://example.com/document.pdf', + }, + ], + }, + ]); + }); + + it('should handle multiple messages with different types', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const messages = [ + { + role: 'system', + type: 'text', + content: 'You are a helpful assistant.', + }, + { + role: 'user', + type: 'image', + imageType: 'url', + imageUrl: 'https://example.com/image.png', + }, + { + role: 'assistant', + content: 'I can see the image you shared.', + }, + ]; + + const result = await formatInputMessages.call(executeFunctions, 0, messages); + + expect(result).toEqual([ + { + role: 'system', + content: [{ type: 'input_text', text: 'You are a helpful assistant.' }], + }, + { + role: 'user', + content: [ + { + type: 'input_image', + detail: 'auto', + image_url: 'https://example.com/image.png', + }, + ], + }, + { + role: 'assistant', + content: [{ type: 'input_text', text: 'I can see the image you shared.' }], + }, + ]); + }); + + it('should handle empty messages array', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const messages: IDataObject[] = []; + + const result = await formatInputMessages.call(executeFunctions, 0, messages); + + expect(result).toEqual([]); + }); + }); + + describe('createRequest', () => { + it('should create a basic request with minimal options', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: {}, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + background: false, + }); + }); + + it('should create a request with all basic options', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + instructions: 'You are a helpful assistant', + maxTokens: 1000, + previousResponseId: 'resp_123', + promptCacheKey: 'cache_key', + safetyIdentifier: 'safety_123', + serviceTier: 'fast', + temperature: 0.7, + topP: 0.9, + topLogprobs: 5, + maxToolCalls: 10, + parallelToolCalls: false, + store: false, + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + instructions: 'You are a helpful assistant', + max_output_tokens: 1000, + previous_response_id: 'resp_123', + prompt_cache_key: 'cache_key', + safety_identifier: 'safety_123', + service_tier: 'fast', + temperature: 0.7, + top_p: 0.9, + top_logprobs: 5, + max_tool_calls: 10, + parallel_tool_calls: false, + store: false, + background: false, + }); + }); + + it('should handle truncation option', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + truncation: true, + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + truncation: 'auto', + background: false, + }); + }); + + it('should handle truncation disabled', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + truncation: false, + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + truncation: 'disabled', + background: false, + }); + }); + + it('should handle conversation ID', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + conversationId: 'conv_1234567890', + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + conversation: 'conv_1234567890', + background: false, + }); + }); + + it('should handle include options', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + include: ['usage', 'prompt_annotations'], + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + include: ['usage', 'prompt_annotations'], + background: false, + }); + }); + + it('should handle metadata', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + metadata: '{"custom": "value", "source": "test"}', + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + metadata: { custom: 'value', source: 'test' }, + background: false, + }); + }); + + it('should handle prompt configuration', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + promptConfig: { + promptOptions: { + promptId: 'prompt_123', + version: 'v1', + variables: '{"name": "John"}', + }, + }, + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + prompt: { + id: 'prompt_123', + version: 'v1', + variables: { name: 'John' }, + }, + background: false, + }); + }); + + it('should handle prompt configuration without variables', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + promptConfig: { + promptOptions: { + promptId: 'prompt_123', + version: 'v1', + }, + }, + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + prompt: { + id: 'prompt_123', + version: 'v1', + }, + background: false, + }); + }); + + it('should handle reasoning configuration', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + reasoning: { + reasoningOptions: { + effort: 'high', + summary: 'detailed', + }, + }, + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + reasoning: { + effort: 'high', + summary: 'detailed', + }, + background: false, + }); + }); + + it('should handle reasoning configuration with none summary', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + reasoning: { + reasoningOptions: { + effort: 'high', + summary: 'none', + }, + }, + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + reasoning: { + effort: 'high', + }, + background: false, + }); + }); + + it('should handle text format configuration with JSON schema', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + textFormat: { + textOptions: { + type: 'json_schema', + name: 'response_schema', + schema: '{"type": "object", "properties": {"message": {"type": "string"}}}', + verbosity: 'medium', + }, + }, + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + text: { + verbosity: 'medium', + format: { + type: 'json_schema', + name: 'response_schema', + schema: { type: 'object', properties: { message: { type: 'string' } } }, + }, + }, + background: false, + }); + }); + + it('should handle text format configuration with JSON object', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + textFormat: { + textOptions: { + type: 'json_object', + verbosity: 'high', + }, + }, + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'system', + content: [ + { type: 'input_text', text: 'You are a helpful assistant designed to output JSON.' }, + ], + }, + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + text: { + verbosity: 'high', + format: { + type: 'json_object', + }, + }, + background: false, + }); + }); + + it('should handle text format configuration with text type', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + textFormat: { + textOptions: { + type: 'text', + verbosity: 'low', + }, + }, + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + text: { + verbosity: 'low', + format: { + type: 'text', + }, + }, + background: false, + }); + }); + + it('should handle built-in tools - web search', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: {}, + builtInTools: { + webSearch: { + searchContextSize: 'high', + allowedDomains: 'example.com, test.com', + country: 'US', + city: 'New York', + region: 'NY', + }, + }, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + tools: [ + { + type: 'web_search', + search_context_size: 'high', + user_location: { + type: 'approximate', + country: 'US', + city: 'New York', + region: 'NY', + }, + filters: { + allowed_domains: ['example.com', 'test.com'], + }, + }, + ], + background: false, + }); + }); + + it('should handle built-in tools - code interpreter', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: {}, + builtInTools: { + codeInterpreter: true, + }, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + tools: [ + { + type: 'code_interpreter', + container: { + type: 'auto', + }, + }, + ], + background: false, + }); + }); + + it('should handle built-in tools - local shell', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: {}, + builtInTools: { + localShell: true, + }, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + tools: [ + { + type: 'local_shell', + }, + ], + background: false, + }); + }); + + it('should handle built-in tools - file search', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: {}, + builtInTools: { + fileSearch: { + vectorStoreIds: '["vs_123", "vs_456"]', + filters: '{"file_type": "pdf"}', + maxResults: 10, + }, + }, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + tools: [ + { + type: 'file_search', + vector_store_ids: ['vs_123', 'vs_456'], + filters: { file_type: 'pdf' }, + max_num_results: 10, + }, + ], + background: false, + }); + }); + + it('should handle MCP servers', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: {}, + builtInTools: { + mcpServers: { + mcpServerOptions: [ + { + serverLabel: 'Test Server', + serverUrl: 'https://example.com/mcp', + connectorId: 'conn_123', + authorization: 'Bearer token', + allowedTools: 'tool1, tool2', + headers: '{"X-Custom": "value"}', + serverDescription: 'Test MCP server', + }, + ], + }, + }, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + tools: [ + { + type: 'mcp', + server_label: 'Test Server', + server_url: 'https://example.com/mcp', + connector_id: 'conn_123', + authorization: 'Bearer token', + allowed_tools: ['tool1', 'tool2'], + headers: { 'X-Custom': 'value' }, + require_approval: 'never', + server_description: 'Test MCP server', + }, + ], + background: false, + }); + }); + + it('should handle external tools', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const externalTools = [ + { + type: 'function', + function: { + name: 'test_function', + description: 'A test function', + parameters: { type: 'object' }, + }, + }, + ]; + + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: {}, + builtInTools: undefined, + tools: externalTools as unknown as FunctionTool[], + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + tools: externalTools, + background: false, + }); + }); + + it('should handle background mode', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + backgroundMode: { + values: { + enabled: true, + }, + }, + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + background: true, + }); + }); + + it('should remove empty properties from final result', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + instructions: '', + maxTokens: undefined, + temperature: 0, + }, + builtInTools: undefined, + tools: undefined, + }; + + const result = await createRequest.call(executeFunctions, 0, options); + + expect(result).toEqual({ + model: 'gpt-4', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + parallel_tool_calls: true, + store: true, + temperature: 0, + background: false, + }); + }); + }); + + describe('Error Handling', () => { + it('should handle invalid JSON in metadata', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + metadata: 'invalid json', + }, + builtInTools: undefined, + tools: undefined, + }; + + await expect(createRequest.call(executeFunctions, 0, options)).rejects.toThrow( + 'Failed to parse metadata', + ); + }); + + it('should handle invalid JSON in prompt variables', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + promptConfig: { + promptOptions: { + promptId: 'prompt_123', + version: 'v1', + variables: 'invalid json', + }, + }, + }, + builtInTools: undefined, + tools: undefined, + }; + + await expect(createRequest.call(executeFunctions, 0, options)).rejects.toThrow( + 'Failed to parse prompt variables', + ); + }); + + it('should handle invalid JSON in text format schema', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: { + textFormat: { + textOptions: { + type: 'json_schema', + name: 'response_schema', + schema: 'invalid json', + verbosity: 'medium', + }, + }, + }, + builtInTools: undefined, + tools: undefined, + }; + + await expect(createRequest.call(executeFunctions, 0, options)).rejects.toThrow( + 'Failed to parse schema', + ); + }); + + it('should handle invalid JSON in file search filters', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: {}, + builtInTools: { + fileSearch: { + vectorStoreIds: '["vs_123"]', + filters: 'invalid json', + maxResults: 10, + }, + }, + tools: undefined, + }; + + await expect(createRequest.call(executeFunctions, 0, options)).rejects.toThrow( + 'Failed to parse filters', + ); + }); + + it('should handle invalid JSON in MCP server headers', async () => { + const executeFunctions = createExecuteFunctionsMock({}); + const options = { + model: 'gpt-4', + messages: [ + { + role: 'user', + type: 'text', + content: 'Hello', + }, + ], + options: {}, + builtInTools: { + mcpServers: { + mcpServerOptions: [ + { + serverLabel: 'Test Server', + serverUrl: 'https://example.com/mcp', + connectorId: 'conn_123', + authorization: 'Bearer token', + allowedTools: 'tool1', + headers: 'invalid json', + serverDescription: 'Test MCP server', + }, + ], + }, + }, + tools: undefined, + }; + + await expect(createRequest.call(executeFunctions, 0, options)).rejects.toThrow( + 'Failed to parse headers', + ); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/video/generate.operation.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/video/generate.operation.test.ts new file mode 100644 index 00000000000..f52b7dfdace --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/actions/video/generate.operation.test.ts @@ -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; + let mockNode: INode; + let mockFormDataInstance: jest.Mocked; + const apiRequestSpy = jest.spyOn(transport, 'apiRequest'); + const getBinaryDataFileSpy = jest.spyOn(binaryDataHelpers, 'getBinaryDataFile'); + const pollUntilAvailableSpy = jest.spyOn(pollingHelpers, 'pollUntilAvailable'); + + beforeEach(() => { + mockExecuteFunctions = mockDeep(); + mockNode = mock({ + 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; + 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(); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/conversation.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/conversation.test.ts new file mode 100644 index 00000000000..eb1ac3028fd --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/test/v2/conversation.test.ts @@ -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; + + 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', + ); + }); + }); +});