Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions __tests__/file-uploads.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
});
156 changes: 156 additions & 0 deletions __tests__/multipart.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>) => {
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);
});
});
24 changes: 18 additions & 6 deletions src/ApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,8 @@ export class ApiClient {

const encodedBody =
requestContentType === 'multipart/form-data'
? new FormData()
? this.multipartBodyStringify(body as Record<string, any>)
: JSON.stringify(body);
if (requestContentType === 'multipart/form-data') {
Object.keys(body as Record<string, any>).forEach((key) => {
(encodedBody as FormData).append(key, body[key]);
});
}

try {
const response = await fetch(`${this.apiConfig.baseUrl}${url}`, {
Expand Down Expand Up @@ -125,6 +120,23 @@ export class ApiClient {
}
};

protected multipartBodyStringify = (body: Record<string, any>): 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<string, any>) => {
const newParams = [];
for (const k in params) {
Expand Down
18 changes: 4 additions & 14 deletions src/StreamClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,26 +105,16 @@ export class StreamClient extends CommonApi {

// @ts-expect-error API spec says file should be a string
uploadFile = (request: Omit<FileUploadRequest, 'file'> & { 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<ImageUploadRequest, 'file'> & { 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);
};

/**
Expand Down