The conformance board (tests/afi/test_capability_parity.py) is now fully green: 90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The gaps the prose table used to launder as "Django-only" / "out of scope" are wired, against the pinned-spec model (single-authored spec, byte-identical conformance across languages) — never per-language reimplementation. FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest), WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes (SQLAlchemy projection, same declaration surface as django-readers), Forms (Pydantic schema/validate/submit). Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth= enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT mint+verify and cache-key derivation byte-pinned to the Python reference (cache_keys_pin, token_pin, invalidate_header_pin). TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS backend can feed the codegen — the largest gap), multipart upload, session-init, WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms. Verified in the merged tree: core 25, fastapi 74, django 353/21-skip, mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
164 lines
6.4 KiB
TypeScript
164 lines
6.4 KiB
TypeScript
/**
|
|
* 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<string, any>, files: Record<string, Blob | Blob[]>): 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')
|
|
})
|
|
})
|