From 5a28512fe5530ae2f75ef77cdd7fd9b4fe3475c7 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:05:03 +1030 Subject: [PATCH] fix(client): upload a bare Blob under the item name The platform takes fileExtension from the multipart filename. A Blob passed to createFile or updateFile was appended without one, so it went up as "blob" and the item was stored with fileExtension "", which Automation extension conditions never match. --- .changeset/blob-upload-filename.md | 5 +++++ src/core/client.test.ts | 29 +++++++++++++++++++++++++++++ src/core/client.ts | 19 +++++++++++++++++-- src/core/examples/files.ts | 1 + 4 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 .changeset/blob-upload-filename.md diff --git a/.changeset/blob-upload-filename.md b/.changeset/blob-upload-filename.md new file mode 100644 index 0000000..fce728e --- /dev/null +++ b/.changeset/blob-upload-filename.md @@ -0,0 +1,5 @@ +--- +'@thatopen/services': patch +--- + +`createFile` and `updateFile` (when a name is given) upload a bare `Blob` under the item's name, so the item gets its `fileExtension` instead of an empty one. diff --git a/src/core/client.test.ts b/src/core/client.test.ts index 6f4ab92..62aed05 100644 --- a/src/core/client.test.ts +++ b/src/core/client.test.ts @@ -232,6 +232,35 @@ describe('EngineServicesClient — HTTP contract', () => { }); }); + describe('upload filename', () => { + // The platform takes fileExtension from the multipart filename; a bare + // Blob would be sent as "blob" and stored with fileExtension "". + it('createFile sends a bare Blob under the item name', async () => { + fetchMock.mockResolvedValue(okResponse({})); + const client = new EngineServicesClient(TOKEN, API); + await client.createFile({ file: new Blob(['{}']), name: 'result.json', versionTag: 'v1' }); + const file = (getCall(fetchMock).init.body as FormData).get('file') as File; + expect(file.name).toBe('result.json'); + expect(await file.text()).toBe('{}'); + }); + + it('createFile keeps a File\'s own name', async () => { + fetchMock.mockResolvedValue(okResponse({})); + const client = new EngineServicesClient(TOKEN, API); + await client.createFile({ file: new File(['x'], 'model.ifc'), name: 'Model', versionTag: 'v1' }); + const file = (getCall(fetchMock).init.body as FormData).get('file') as File; + expect(file.name).toBe('model.ifc'); + }); + + it('updateFile names a bare Blob when a name is given', async () => { + fetchMock.mockResolvedValue(okResponse({})); + const client = new EngineServicesClient(TOKEN, API); + await client.updateFile('item-1', { file: new Blob(['{}']), name: 'result.json', versionTag: 'v2' }); + const file = (getCall(fetchMock).init.body as FormData).get('file') as File; + expect(file.name).toBe('result.json'); + }); + }); + describe('error handling', () => { it('throws when the server responds with a non-2xx status', async () => { fetchMock.mockResolvedValue(errorResponse(403, 'Forbidden')); diff --git a/src/core/client.ts b/src/core/client.ts index f3c81cd..19ed2e9 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -229,6 +229,14 @@ export type EngineServicesClientProps = { * const files = await client.listFiles(); * ``` */ +/** + * The platform takes a file's extension from the upload's filename, and a bare + * Blob is sent as "blob". Give it the item's name so `fileExtension` is set. + */ +function namedFile(file: File | Blob, name: string): File | Blob { + return file instanceof File ? file : new File([file], name, { type: file.type }); +} + export class EngineServicesClient { private apiUrl: string; private accessToken: string; @@ -557,7 +565,10 @@ export class EngineServicesClient { * @returns The created item and its first version. */ async createFile(fileData: CreateItemProps) { - return await this.#createItem(fileData, ITEM_TYPE_FILE); + return await this.#createItem( + { ...fileData, file: namedFile(fileData.file, fileData.name) }, + ITEM_TYPE_FILE, + ); } /** @@ -571,7 +582,11 @@ export class EngineServicesClient { fileId: string, fileData: UpdateItemProps, ): Promise { - return await this.#updateItem(fileId, fileData); + const { file, name } = fileData; + return await this.#updateItem( + fileId, + file && name ? { ...fileData, file: namedFile(file, name) } : fileData, + ); } /** diff --git a/src/core/examples/files.ts b/src/core/examples/files.ts index d555fca..625c4ad 100644 --- a/src/core/examples/files.ts +++ b/src/core/examples/files.ts @@ -39,6 +39,7 @@ async function main() { // projectId is required — the backend rejects uploads without one. const fileName = `__example-test__-${Date.now()}.json`; const blob = new Blob([JSON.stringify({ test: true })], { type: 'application/json' }); + // A bare Blob is uploaded under `name`, so the item takes its fileExtension from it. const created = await client.createFile({ file: blob, name: fileName, versionTag: 'v1', projectId: PROJECT_ID }); console.log(`\nCreated file: [${created.item._id}] ${created.item.name}`);