AFI parity: close all 35 gaps — every adapter wires every AFI-common capability
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>
This commit is contained in:
163
backends/mizan-ts/tests/upload.test.ts
Normal file
163
backends/mizan-ts/tests/upload.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user