diff --git a/src/commands/file/upload.ts b/src/commands/file/upload.ts index 5108127b..986b4ee9 100644 --- a/src/commands/file/upload.ts +++ b/src/commands/file/upload.ts @@ -2,16 +2,13 @@ import { defineCommand } from '../../command'; import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { requestJson } from '../../client/http'; -import { fileUploadEndpoint } from '../../client/endpoints'; +import { resolveFileUploadPath, uploadFile } from '../../files/upload'; import { formatOutput, detectOutputFormat } from '../../output/formatter'; import { isInteractive } from '../../utils/env'; import { promptText, failIfMissing } from '../../utils/prompt'; import type { Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; import type { FileUploadResponse } from '../../types/api'; -import { existsSync } from 'fs'; -import { readFile } from 'fs/promises'; -import { resolve, basename } from 'path'; export default defineCommand({ name: 'file upload', @@ -40,10 +37,9 @@ export default defineCommand({ } } - const fullPath = resolve(filePath); - if (!existsSync(fullPath)) { - throw new CLIError(`File not found: ${fullPath}`, ExitCode.USAGE); - } + const createFileNotFoundError = (fullPath: string) => + new CLIError(`File not found: ${fullPath}`, ExitCode.USAGE); + const fullPath = resolveFileUploadPath(filePath, createFileNotFoundError); const purpose = (flags.purpose as string) || 'retrieval'; const format = detectOutputFormat(config.output); @@ -53,19 +49,12 @@ export default defineCommand({ return; } - const formData = new FormData(); - // Read file using Node.js fs/promises (compatible with both Node and Bun) - const fileData = await readFile(fullPath); - const fileName = basename(fullPath); - const fileBlob = new Blob([fileData]); - formData.append('file', fileBlob, fileName); - formData.append('purpose', purpose); - - const url = fileUploadEndpoint(config.baseUrl); - const response = await requestJson(config, { - url, - method: 'POST', - body: formData, + const response = await uploadFile({ + filePath: fullPath, + purpose, + baseUrl: config.baseUrl, + requestJson: (opts) => requestJson(config, opts), + createFileNotFoundError, }); if (config.quiet) { diff --git a/src/files/upload.ts b/src/files/upload.ts new file mode 100644 index 00000000..8db663c5 --- /dev/null +++ b/src/files/upload.ts @@ -0,0 +1,49 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import { fileUploadEndpoint } from '../client/endpoints'; +import type { RequestOpts } from '../client/http'; +import type { FileUploadResponse } from '../types/api'; + +type RequestFileUpload = (opts: RequestOpts) => Promise; +type CreateFileNotFoundError = (fullPath: string) => Error; + +interface UploadFileOptions { + filePath: string; + purpose: string; + baseUrl: string; + requestJson: RequestFileUpload; + createFileNotFoundError: CreateFileNotFoundError; +} + +export function resolveFileUploadPath( + filePath: string, + createFileNotFoundError: CreateFileNotFoundError, +): string { + const fullPath = resolve(filePath); + if (!existsSync(fullPath)) { + throw createFileNotFoundError(fullPath); + } + return fullPath; +} + +export async function uploadFile({ + filePath, + purpose, + baseUrl, + requestJson, + createFileNotFoundError, +}: UploadFileOptions): Promise { + const fullPath = resolveFileUploadPath(filePath, createFileNotFoundError); + const fileData = await readFile(fullPath); + + const formData = new FormData(); + formData.append('file', new Blob([fileData]), basename(fullPath)); + formData.append('purpose', purpose); + + return requestJson({ + url: fileUploadEndpoint(baseUrl), + method: 'POST', + body: formData, + }); +} diff --git a/src/sdk/file/index.ts b/src/sdk/file/index.ts index 211ebb59..33ac00de 100644 --- a/src/sdk/file/index.ts +++ b/src/sdk/file/index.ts @@ -1,13 +1,10 @@ -import { existsSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import { resolve, basename } from 'node:path'; import { Client } from '../client'; import { - fileUploadEndpoint, fileListEndpoint, fileDeleteEndpoint, fileRetrieveEndpoint, } from '../../client/endpoints'; +import { uploadFile } from '../../files/upload'; import type { FileUploadResponse, FileListResponse, @@ -25,23 +22,13 @@ export class FileSDK extends Client { * @param purpose - File purpose, defaults to `"retrieval"`. */ async upload(filePath: string, purpose = 'retrieval'): Promise { - const fullPath = resolve(filePath); - if (!existsSync(fullPath)) { - throw new SDKError(`File not found: ${fullPath}`, ExitCode.USAGE); - } - - const fileData = await readFile(fullPath); - const fileName = basename(fullPath); - - const formData = new FormData(); - formData.append('file', new Blob([fileData]), fileName); - formData.append('purpose', purpose); - - const url = fileUploadEndpoint(this.config.baseUrl); - return this.requestJson({ - url, - method: 'POST', - body: formData, + return uploadFile({ + filePath, + purpose, + baseUrl: this.config.baseUrl, + requestJson: (opts) => this.requestJson(opts), + createFileNotFoundError: (fullPath) => + new SDKError(`File not found: ${fullPath}`, ExitCode.USAGE), }); } diff --git a/test/commands/file/upload.test.ts b/test/commands/file/upload.test.ts index bd0601d1..eb5b3b22 100644 --- a/test/commands/file/upload.test.ts +++ b/test/commands/file/upload.test.ts @@ -58,9 +58,19 @@ describe('file upload command', () => { }); it('throws when file does not exist', async () => { - await expect( - uploadCommand.execute(baseConfig, { ...baseFlags, file: '/tmp/nonexistent-file-xxxxx.bin' }), - ).rejects.toThrow('File not found'); + try { + await uploadCommand.execute(baseConfig, { + ...baseFlags, + file: '/tmp/nonexistent-file-xxxxx.bin', + }); + throw new Error('Expected upload to reject'); + } catch (error) { + expect(error).toMatchObject({ + name: 'CLIError', + message: expect.stringContaining('File not found'), + exitCode: 2, + }); + } }); it('shows dry-run output with file info', async () => { @@ -82,4 +92,57 @@ describe('file upload command', () => { rmSync(tempDir, { recursive: true, force: true }); } }); + + it('uploads through the shared multipart operation and formats the response', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'mmx-upload-test-')); + const filePath = join(tempDir, 'fixture.txt'); + writeFileSync(filePath, 'shared upload contents'); + const originalFetch = globalThis.fetch; + let requestUrl = ''; + let requestInit: RequestInit | undefined; + + globalThis.fetch = (async (input, init) => { + requestUrl = String(input); + requestInit = init; + return new Response(JSON.stringify({ + base_resp: { status_code: 0, status_msg: 'success' }, + file: { + file_id: 'cli-file-id', + bytes: 22, + created_at: 1, + filename: 'fixture.txt', + purpose: 'vision', + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + try { + const captured = await captureStdout(async () => { + await uploadCommand.execute(baseConfig, { + ...baseFlags, + file: filePath, + purpose: 'vision', + }); + }); + + expect(requestUrl).toBe('https://api.mmx.io/v1/files/upload'); + expect(requestInit?.method).toBe('POST'); + expect(requestInit?.headers).toMatchObject({ Authorization: 'Bearer test-key' }); + expect(requestInit?.body).toBeInstanceOf(FormData); + + const body = requestInit?.body as FormData; + const uploadedFile = body.get('file'); + expect(uploadedFile).toBeInstanceOf(Blob); + expect((uploadedFile as File).name).toBe('fixture.txt'); + expect(await (uploadedFile as Blob).text()).toBe('shared upload contents'); + expect(body.get('purpose')).toBe('vision'); + expect(captured).toContain('cli-file-id'); + } finally { + globalThis.fetch = originalFetch; + rmSync(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/test/sdk/file.test.ts b/test/sdk/file.test.ts index 506ce3ec..52874990 100644 --- a/test/sdk/file.test.ts +++ b/test/sdk/file.test.ts @@ -7,23 +7,61 @@ import { tmpdir } from 'node:os'; describe('FileSDK', () => { it('throws SDKError when file does not exist', async () => { const sdk = new FileSDK({ apiKey: 'sk-test', region: 'global' }); - await expect(sdk.upload('/tmp/nonexistent-file-xxxxx.bin', 'retrieval')) - .rejects - .toThrow('File not found'); + try { + await sdk.upload('/tmp/nonexistent-file-xxxxx.bin', 'retrieval'); + throw new Error('Expected upload to reject'); + } catch (error) { + expect(error).toMatchObject({ + name: 'SDKError', + message: expect.stringContaining('File not found'), + exitCode: 2, + }); + } }); - it('gets past file existence check for a valid file', async () => { + it('uploads through the shared multipart operation and returns the response', async () => { const tmpFile = join(tmpdir(), 'mmx-sdk-test-upload.txt'); writeFileSync(tmpFile, 'hello world'); + const originalFetch = globalThis.fetch; + let requestUrl = ''; + let requestInit: RequestInit | undefined; + + globalThis.fetch = (async (input, init) => { + requestUrl = String(input); + requestInit = init; + return new Response(JSON.stringify({ + base_resp: { status_code: 0, status_msg: 'success' }, + file: { + file_id: 'sdk-file-id', + bytes: 11, + created_at: 1, + filename: 'mmx-sdk-test-upload.txt', + purpose: 'retrieval', + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; try { const sdk = new FileSDK({ apiKey: 'sk-test', region: 'global' }); - await sdk.upload(tmpFile, 'retrieval'); - // Should not reach here (no mock server), but if it does, fail informatively - } catch (err) { - // Must NOT be "File not found" — proves file existence check passed - expect((err as Error).message).not.toContain('File not found'); + const response = await sdk.upload(tmpFile, 'retrieval'); + + expect(response.file.file_id).toBe('sdk-file-id'); + expect(requestUrl).toBe('https://api.minimax.io/v1/files/upload'); + expect(requestInit?.method).toBe('POST'); + expect(requestInit?.headers).toMatchObject({ Authorization: 'Bearer sk-test' }); + expect(requestInit?.body).toBeInstanceOf(FormData); + + const body = requestInit?.body as FormData; + const uploadedFile = body.get('file'); + expect(uploadedFile).toBeInstanceOf(Blob); + expect((uploadedFile as File).name).toBe('mmx-sdk-test-upload.txt'); + expect(await (uploadedFile as Blob).text()).toBe('hello world'); + expect(body.get('purpose')).toBe('retrieval'); } finally { + globalThis.fetch = originalFetch; if (existsSync(tmpFile)) unlinkSync(tmpFile); } });