From c4c46b8ff93abab45426682f8b371997fb42d52d Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Tue, 5 Aug 2025 17:18:36 +0200 Subject: [PATCH] fix: Properly serialize metadata objects in Chat UI (#17963) Co-authored-by: Claude --- .../chat/src/__tests__/api/generic.spec.ts | 126 ++++++++++++++++++ .../frontend/@n8n/chat/src/api/generic.ts | 9 +- 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 packages/frontend/@n8n/chat/src/__tests__/api/generic.spec.ts diff --git a/packages/frontend/@n8n/chat/src/__tests__/api/generic.spec.ts b/packages/frontend/@n8n/chat/src/__tests__/api/generic.spec.ts new file mode 100644 index 0000000000..98e2a8015a --- /dev/null +++ b/packages/frontend/@n8n/chat/src/__tests__/api/generic.spec.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { postWithFiles } from '@n8n/chat/api/generic'; + +describe('postWithFiles', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should properly serialize object metadata to JSON string in FormData', async () => { + const mockResponse = { + ok: true, + status: 200, + json: async () => await Promise.resolve({ success: true }), + text: async () => await Promise.resolve('success'), + clone: () => mockResponse, + } as Response; + + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + const testFile = new File(['test content'], 'test.txt', { type: 'text/plain' }); + const metadata = { + userId: 'user-123', + token: 'abc-def-ghi', + nested: { + prop: 'value', + num: 42, + }, + }; + + await postWithFiles( + 'https://example.com/webhook', + { + action: 'sendMessage', + sessionId: 'test-session', + chatInput: 'test message', + metadata, + }, + [testFile], + ); + + expect(fetchSpy).toHaveBeenCalledWith('https://example.com/webhook', { + method: 'POST', + body: expect.any(FormData), + mode: 'cors', + cache: 'no-cache', + headers: {}, + }); + + // Get the FormData from the call + const formData = fetchSpy.mock.calls[0][1]?.body as FormData; + expect(formData).toBeInstanceOf(FormData); + + // Verify that metadata was properly serialized as JSON, not "[object Object]" + const metadataValue = formData.get('metadata'); + expect(metadataValue).toBe(JSON.stringify(metadata)); + + // Verify other fields are still strings + expect(formData.get('action')).toBe('sendMessage'); + expect(formData.get('sessionId')).toBe('test-session'); + expect(formData.get('chatInput')).toBe('test message'); + + // Verify file was included + expect(formData.get('files')).toBe(testFile); + }); + + it('should handle primitive values correctly', async () => { + const mockResponse = { + ok: true, + status: 200, + json: async () => await Promise.resolve({ success: true }), + text: async () => await Promise.resolve('success'), + clone: () => mockResponse, + } as Response; + + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + await postWithFiles('https://example.com/webhook', { + stringValue: 'test', + }); + + const formData = fetchSpy.mock.calls[0][1]?.body as FormData; + + expect(formData.get('stringValue')).toBe('test'); + }); + + it('should handle arrays as JSON strings', async () => { + const mockResponse = { + ok: true, + status: 200, + json: async () => await Promise.resolve({ success: true }), + text: async () => await Promise.resolve('success'), + clone: () => mockResponse, + } as Response; + + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + const arrayValue = ['item1', 'item2', { nested: 'object' }]; + + await postWithFiles('https://example.com/webhook', { + arrayValue, + }); + + const formData = fetchSpy.mock.calls[0][1]?.body as FormData; + expect(formData.get('arrayValue')).toBe(JSON.stringify(arrayValue)); + }); + + it('should handle empty objects correctly', async () => { + const mockResponse = { + ok: true, + status: 200, + json: async () => await Promise.resolve({ success: true }), + text: async () => await Promise.resolve('success'), + clone: () => mockResponse, + } as Response; + + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue(mockResponse); + + await postWithFiles('https://example.com/webhook', { + emptyObject: {}, + }); + + const formData = fetchSpy.mock.calls[0][1]?.body as FormData; + expect(formData.get('emptyObject')).toBe('{}'); + }); +}); diff --git a/packages/frontend/@n8n/chat/src/api/generic.ts b/packages/frontend/@n8n/chat/src/api/generic.ts index 11e18a86da..d84b27f526 100644 --- a/packages/frontend/@n8n/chat/src/api/generic.ts +++ b/packages/frontend/@n8n/chat/src/api/generic.ts @@ -55,14 +55,19 @@ export async function post(url: string, body: object = {}, options: RequestIn } export async function postWithFiles( url: string, - body: Record = {}, + body: Record = {}, files: File[] = [], options: RequestInit = {}, ) { const formData = new FormData(); for (const key in body) { - formData.append(key, body[key] as string); + const value = body[key]; + if (typeof value === 'object' && value !== null) { + formData.append(key, JSON.stringify(value)); + } else { + formData.append(key, value); + } } for (const file of files) {