diff --git a/__tests__/file-uploads.test.ts b/__tests__/file-uploads.test.ts index ac8b12f..e5f6425 100644 --- a/__tests__/file-uploads.test.ts +++ b/__tests__/file-uploads.test.ts @@ -1,6 +1,8 @@ import { beforeAll, describe, it, expect } from 'vitest'; +import { randomUUID } from 'crypto'; import { createTestClient } from './create-test-client'; import { StreamClient } from '../src/StreamClient'; +import { StreamChannel } from '../src/StreamChannel'; import fs from 'fs'; import path from 'path'; import { File } from 'buffer'; @@ -69,3 +71,70 @@ describe.skip('global file uploads', () => { expect(deleteResponse).toBeDefined(); }); }); + +// Don't want to upload files and image every time we run the tests +describe.skip('channel file uploads', () => { + let client: StreamClient; + let channel: StreamChannel; + const user = { + id: 'stream-node-test-user', + role: 'admin', + }; + + beforeAll(async () => { + client = createTestClient(); + await client.upsertUsers([user]); + + channel = client.chat.channel('messaging', 'streamnodetest' + randomUUID()); + await channel.getOrCreate({ data: { created_by_id: user.id } }); + }); + + it('upload and delete file', async () => { + const filePath = path.join(__dirname, 'assets', 'test-file.pdf'); + const fileBuffer = fs.readFileSync(filePath); + + const response = await channel.uploadChannelFile({ + // @ts-expect-error API spec says file should be a string + file: new File([fileBuffer], 'test-file.pdf'), + user: { id: user.id }, + }); + + expect(response.file).toBeDefined(); + + const deleteResponse = await channel.deleteChannelFile({ + url: response.file, + }); + + expect(deleteResponse).toBeDefined(); + }); + + it('upload image', async () => { + const filePath = path.join(__dirname, 'assets', 'test-image.jpg'); + const fileBuffer = fs.readFileSync(filePath); + + const uploadSizes = [ + { + width: 100, + height: 100, + resize: 'scale', + crop: 'center', + }, + ]; + + const response = await channel.uploadChannelImage({ + // @ts-expect-error API spec says file should be a string + file: new File([fileBuffer], 'test-image.jpg'), + user: { id: user.id }, + upload_sizes: uploadSizes, + }); + + expect(response.upload_sizes?.length).toBe(1); + expect(response.upload_sizes?.[0]).toMatchObject(uploadSizes[0]); + + const deleteResponse = await channel.deleteChannelImage({ + url: response.file, + }); + + expect(deleteResponse).toBeDefined(); + }); +}); diff --git a/__tests__/multipart.test.ts b/__tests__/multipart.test.ts new file mode 100644 index 0000000..9012714 --- /dev/null +++ b/__tests__/multipart.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApiClient } from '../src/ApiClient'; +import { StreamChatClient } from '../src/StreamChatClient'; +import { StreamClient } from '../src/StreamClient'; + +const createApiClient = () => + new ApiClient({ + apiKey: 'test-api-key', + token: 'test-token', + baseUrl: 'https://example.com', + timeout: 3000, + }); + +const mockFetch = () => + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + +/** + * Sends a multipart request with the given body and returns the form data + * that was actually put on the wire. + */ +const formDataFor = async (body: Record) => { + const fetchSpy = mockFetch(); + + await createApiClient().sendRequest( + 'POST', + '/test', + undefined, + undefined, + body, + 'multipart/form-data', + ); + + return fetchSpy.mock.calls[0][1]!.body as FormData; +}; + +describe('multipart form data serialization', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('serializes an object field as JSON', async () => { + const formData = await formDataFor({ user: { id: 'user-id' } }); + + expect(formData.get('user')).toBe('{"id":"user-id"}'); + }); + + it('serializes an array field as JSON', async () => { + const formData = await formDataFor({ + upload_sizes: [{ width: 100, height: 100, resize: 'scale' }], + }); + + expect(formData.get('upload_sizes')).toBe( + '[{"width":100,"height":100,"resize":"scale"}]', + ); + }); + + it('keeps a file field as a file', async () => { + const file = new File(['file-contents'], 'test-file.pdf'); + const formData = await formDataFor({ file }); + + const sent = formData.get('file'); + expect(sent).toBeInstanceOf(File); + expect((sent as File).name).toBe('test-file.pdf'); + expect(await (sent as File).text()).toBe('file-contents'); + }); + + it('omits fields with no value', async () => { + const formData = await formDataFor({ user: undefined, custom: null }); + + expect([...formData.keys()]).toEqual([]); + }); + + it('keeps scalar fields as-is', async () => { + const formData = await formDataFor({ name: 'a-name', limit: 10 }); + + expect(formData.get('name')).toBe('a-name'); + expect(formData.get('limit')).toBe('10'); + }); + + it('does not double-encode an already JSON-encoded field', async () => { + const formData = await formDataFor({ + user: JSON.stringify({ id: 'user-id' }), + }); + + expect(formData.get('user')).toBe('{"id":"user-id"}'); + }); + + it('encodes the user of a global image upload', async () => { + const fetchSpy = mockFetch(); + const client = new StreamClient('test-api-key', 'test-secret'); + + await client.uploadImage({ + file: new File(['image-contents'], 'test-image.jpg'), + upload_sizes: [{ width: 100, height: 100 }], + user: { id: 'user-id' }, + }); + + const formData = fetchSpy.mock.calls[0][1]!.body as FormData; + expect(formData.get('user')).toBe('{"id":"user-id"}'); + expect(formData.get('upload_sizes')).toBe('[{"width":100,"height":100}]'); + expect(formData.get('file')).toBeInstanceOf(File); + }); + + it('omits the user of a global file upload when it has no value', async () => { + const fetchSpy = mockFetch(); + const client = new StreamClient('test-api-key', 'test-secret'); + + await client.uploadFile({ + file: new File(['file-contents'], 'test-file.pdf'), + }); + + const formData = fetchSpy.mock.calls[0][1]!.body as FormData; + expect([...formData.keys()]).toEqual(['file']); + }); + + it('encodes the user of a channel image upload', async () => { + const fetchSpy = mockFetch(); + const chat = new StreamChatClient(createApiClient()); + + await chat.uploadChannelImage({ + type: 'messaging', + id: 'channel-id', + // @ts-expect-error API spec says file should be a string + file: new File(['image-contents'], 'test-image.jpg'), + upload_sizes: [{ width: 100, height: 100 }], + user: { id: 'user-id' }, + }); + + const formData = fetchSpy.mock.calls[0][1]!.body as FormData; + expect(formData.get('user')).toBe('{"id":"user-id"}'); + expect(formData.get('upload_sizes')).toBe('[{"width":100,"height":100}]'); + expect(formData.get('file')).toBeInstanceOf(File); + }); + + it('encodes the user of a channel file upload', async () => { + const fetchSpy = mockFetch(); + const chat = new StreamChatClient(createApiClient()); + + await chat.uploadChannelFile({ + type: 'messaging', + id: 'channel-id', + // @ts-expect-error API spec says file should be a string + file: new File(['file-contents'], 'test-file.pdf'), + user: { id: 'user-id' }, + }); + + const formData = fetchSpy.mock.calls[0][1]!.body as FormData; + expect(formData.get('user')).toBe('{"id":"user-id"}'); + expect(formData.get('file')).toBeInstanceOf(File); + }); +}); diff --git a/src/ApiClient.ts b/src/ApiClient.ts index adb98be..be83548 100644 --- a/src/ApiClient.ts +++ b/src/ApiClient.ts @@ -51,13 +51,8 @@ export class ApiClient { const encodedBody = requestContentType === 'multipart/form-data' - ? new FormData() + ? this.multipartBodyStringify(body as Record) : JSON.stringify(body); - if (requestContentType === 'multipart/form-data') { - Object.keys(body as Record).forEach((key) => { - (encodedBody as FormData).append(key, body[key]); - }); - } try { const response = await fetch(`${this.apiConfig.baseUrl}${url}`, { @@ -125,6 +120,23 @@ export class ApiClient { } }; + protected multipartBodyStringify = (body: Record): FormData => { + const formData = new FormData(); + for (const key in body) { + const value = body[key]; + if (value === null || value === undefined) continue; + if (value instanceof Blob) { + formData.append(key, value); + } else if (isScalar(value)) { + formData.append(key, String(value)); + } else { + formData.append(key, JSON.stringify(value)); + } + } + + return formData; + }; + protected queryParamsStringify = (params: Record) => { const newParams = []; for (const k in params) { diff --git a/src/StreamClient.ts b/src/StreamClient.ts index 977f82d..1cd8efb 100644 --- a/src/StreamClient.ts +++ b/src/StreamClient.ts @@ -105,26 +105,16 @@ export class StreamClient extends CommonApi { // @ts-expect-error API spec says file should be a string uploadFile = (request: Omit & { file: File }) => { - return super.uploadFile({ - // @ts-expect-error API spec says file should be a string - file: request.file, - // @ts-expect-error form data will only work if this is a string - user: JSON.stringify(request.user), - }); + // @ts-expect-error API spec says file should be a string + return super.uploadFile(request); }; // @ts-expect-error API spec says file should be a string uploadImage = ( request: Omit & { file: File }, ) => { - return super.uploadImage({ - // @ts-expect-error API spec says file should be a string - file: request.file, - // @ts-expect-error form data will only work if this is a string - user: JSON.stringify(request.user), - // @ts-expect-error form data will only work if this is a string - upload_sizes: JSON.stringify(request.upload_sizes), - }); + // @ts-expect-error API spec says file should be a string + return super.uploadImage(request); }; /**