/** * Upload tests — multipart File-part binding + constraint enforcement. * * Mirrors mizan-fastapi/tests/test_upload.py: a multipart call binds file parts * into the function's Upload-typed inputs, and `File(...)` constraints * (max-size, content-type) reject at dispatch with a 400. */ import { describe, test, expect, beforeEach } from 'bun:test' import { client, clearRegistry, handleMultipartCall, parseSize, validateUpload, UploadedFile, type StructField, } from '../src' const uploadField = (name: string, opts: { maxSize?: number; contentTypes?: string[]; optional?: boolean; list?: boolean } = {}): StructField => { let shape: any = { kind: 'upload', maxSize: opts.maxSize, contentTypes: opts.contentTypes } if (opts.list) shape = { kind: 'list', inner: shape } if (opts.optional) shape = { kind: 'optional', inner: shape } return { name, required: !opts.optional, shape } } const intField = (name: string): StructField => ({ name, required: true, shape: { kind: 'primitive', primitive: 'integer' } }) function multipart(fn: string, args: Record, files: Record): FormData { const form = new FormData() form.set('fn', fn) form.set('args', JSON.stringify(args)) for (const [key, val] of Object.entries(files)) { for (const f of Array.isArray(val) ? val : [val]) form.append(key, f) } return form } describe('parseSize', () => { test('parses human sizes', () => { expect(parseSize('5MB')).toBe(5 * 1024 * 1024) expect(parseSize('1KB')).toBe(1024) expect(parseSize('2GB')).toBe(2 * 1024 ** 3) expect(parseSize(123)).toBe(123) expect(parseSize('500')).toBe(500) }) }) describe('validateUpload', () => { test('max-size rejection', () => { const f = new UploadedFile('a.bin', 'application/octet-stream', new Uint8Array(100)) expect(validateUpload(f, { maxSize: 50 })).toContain('exceeds max size') expect(validateUpload(f, { maxSize: 200 })).toBeNull() }) test('content-type allowlist + wildcard', () => { const png = new UploadedFile('a.png', 'image/png', new Uint8Array(1)) expect(validateUpload(png, { contentTypes: ['image/png'] })).toBeNull() expect(validateUpload(png, { contentTypes: ['image/*'] })).toBeNull() expect(validateUpload(png, { contentTypes: ['application/pdf'] })).toContain('not allowed') }) }) describe('multipart dispatch', () => { beforeEach(() => clearRegistry()) test('binds a file part into the Upload input', async () => { let received: UploadedFile | null = null client( { affects: 'avatars', ir: { input: [intField('user_id'), uploadField('avatar', { contentTypes: ['image/png'] })] }, }, async function set_avatar(user_id: number, avatar: UploadedFile) { received = avatar return { ok: true, name: avatar.filename, bytes: avatar.size } }, ) const form = multipart('set_avatar', { user_id: 5 }, { avatar: new File([new Uint8Array([1, 2, 3, 4])], 'face.png', { type: 'image/png' }), }) const r = await handleMultipartCall(form) expect(r.status).toBe(200) expect(r.body.result).toEqual({ ok: true, name: 'face.png', bytes: 4 }) expect(received).not.toBeNull() expect(received!.read()).toEqual(new Uint8Array([1, 2, 3, 4])) }) test('max-size violation rejects with 400', async () => { client( { affects: 'avatars', ir: { input: [uploadField('avatar', { maxSize: 3 })] } }, async function set_avatar(_avatar: UploadedFile) { return { ok: true } }, ) const form = multipart('set_avatar', {}, { avatar: new File([new Uint8Array([1, 2, 3, 4, 5])], 'big.bin', { type: 'application/octet-stream' }), }) const r = await handleMultipartCall(form) expect(r.status).toBe(400) expect(r.body.message).toContain('avatar:') expect(r.body.message).toContain('exceeds max size') }) test('content-type violation rejects with 400', async () => { client( { affects: 'avatars', ir: { input: [uploadField('avatar', { contentTypes: ['image/png'] })] } }, async function set_avatar(_avatar: UploadedFile) { return { ok: true } }, ) const form = multipart('set_avatar', {}, { avatar: new File([new Uint8Array([1])], 'doc.pdf', { type: 'application/pdf' }), }) const r = await handleMultipartCall(form) expect(r.status).toBe(400) expect(r.body.message).toContain('not allowed') }) test('list upload binds multiple parts', async () => { let count = 0 client( { affects: 'gallery', ir: { input: [uploadField('photos', { list: true })] } }, async function add_photos(photos: UploadedFile[]) { count = photos.length return { ok: true, count: photos.length } }, ) const form = multipart('add_photos', {}, { photos: [ new File([new Uint8Array([1])], 'a.png', { type: 'image/png' }), new File([new Uint8Array([2])], 'b.png', { type: 'image/png' }), ], }) const r = await handleMultipartCall(form) expect(r.status).toBe(200) expect(r.body.result.count).toBe(2) expect(count).toBe(2) }) test('missing fn → 400', async () => { const form = new FormData() form.set('args', '{}') const r = await handleMultipartCall(form) expect(r.status).toBe(400) expect(r.body.message).toContain("'fn'") }) test('invalidation still emitted on multipart mutation', async () => { client( { affects: 'avatars', ir: { input: [intField('user_id'), uploadField('avatar')] } }, async function set_avatar(_user_id: number, _avatar: UploadedFile) { return { ok: true } }, ) const form = multipart('set_avatar', { user_id: 9 }, { avatar: new File([new Uint8Array([1])], 'a.bin', { type: 'application/octet-stream' }), }) const r = await handleMultipartCall(form) expect(r.status).toBe(200) expect(r.headers['X-Mizan-Invalidate']).toContain('avatars') }) })