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:
6
backends/mizan-ts/tests/fixtures/Hello.tsx
vendored
Normal file
6
backends/mizan-ts/tests/fixtures/Hello.tsx
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
|
||||
/** SSR fixture component — rendered by the Bun worker in the bridge test. */
|
||||
export default function Hello({ name }: { name: string }) {
|
||||
return createElement('div', { className: 'greeting' }, `Hello, ${name}!`)
|
||||
}
|
||||
53
backends/mizan-ts/tests/fixtures/stub-worker.mjs
vendored
Normal file
53
backends/mizan-ts/tests/fixtures/stub-worker.mjs
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Protocol-conformant stub SSR worker — speaks the EXACT same newline-delimited
|
||||
* JSON-RPC the real `workers/mizan-ssr/src/worker.tsx` speaks, but with no React
|
||||
* dependency. It lets `tests/ssr.test.ts` exercise the full SSRBridge subprocess
|
||||
* machinery (ready handshake, id correlation, render reply, ping, error frame)
|
||||
* under plain Node, independent of the real worker's install state.
|
||||
*
|
||||
* `render` echoes the props into a deterministic HTML string so the bridge's
|
||||
* request/response correlation is observable; a file named "*boom*" yields an
|
||||
* error frame to prove the failure path.
|
||||
*/
|
||||
|
||||
function respond(msg) {
|
||||
process.stdout.write(JSON.stringify(msg) + '\n')
|
||||
}
|
||||
|
||||
function handle(msg) {
|
||||
if (msg.method === 'ping') {
|
||||
respond({ id: msg.id, pong: true })
|
||||
return
|
||||
}
|
||||
if (msg.method === 'render') {
|
||||
const { file, props } = msg.params ?? {}
|
||||
if (typeof file === 'string' && file.includes('boom')) {
|
||||
respond({ id: msg.id, error: `cannot render ${file}` })
|
||||
return
|
||||
}
|
||||
respond({ id: msg.id, html: `<div data-file="${file}">${JSON.stringify(props ?? {})}</div>` })
|
||||
return
|
||||
}
|
||||
respond({ id: msg.id, error: `Unknown method: ${msg.method}` })
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
process.stdin.setEncoding('utf-8')
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buffer += chunk
|
||||
let nl
|
||||
while ((nl = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, nl).trim()
|
||||
buffer = buffer.slice(nl + 1)
|
||||
if (line) {
|
||||
try {
|
||||
handle(JSON.parse(line))
|
||||
} catch (e) {
|
||||
respond({ id: -1, error: e.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Ready handshake — identical to the real worker.
|
||||
respond({ id: 0, ready: true })
|
||||
149
backends/mizan-ts/tests/ir-fixture.ts
Normal file
149
backends/mizan-ts/tests/ir-fixture.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* The AFI fixture, TypeScript side — mirrors `tests/afi/fixture.py` 1:1.
|
||||
*
|
||||
* Each function declares the same IR type schema the Python fixture's Pydantic
|
||||
* Input/Output models imply, so `buildIr()` here emits the same KDL the Python
|
||||
* `build_ir()` emits from `fixture.py`. The byte-parity test (`ir.test.ts`)
|
||||
* subprocesses the live Python emitter and asserts equality.
|
||||
*
|
||||
* Output structs are declared under their model name (`ProfileOutput`,
|
||||
* `OrderOutput`, …) and referenced via `{ kind: 'ref' }`; the emitter renames
|
||||
* them to the canonical `<camel>Output`, exactly as `_collect_named_types`
|
||||
* renames the Pydantic models.
|
||||
*/
|
||||
|
||||
import { client, ReactContext } from '../src'
|
||||
import type { NamedType, StructField } from '../src'
|
||||
|
||||
const intField = (name: string): StructField => ({
|
||||
name,
|
||||
required: true,
|
||||
shape: { kind: 'primitive', primitive: 'integer' },
|
||||
})
|
||||
const strField = (name: string): StructField => ({
|
||||
name,
|
||||
required: true,
|
||||
shape: { kind: 'primitive', primitive: 'string' },
|
||||
})
|
||||
const boolField = (name: string): StructField => ({
|
||||
name,
|
||||
required: true,
|
||||
shape: { kind: 'primitive', primitive: 'boolean' },
|
||||
})
|
||||
|
||||
const ProfileOutput: NamedType = { kind: 'struct', fields: [intField('user_id'), strField('name')] }
|
||||
const OrderOutput: NamedType = {
|
||||
kind: 'struct',
|
||||
fields: [intField('id'), intField('user_id'), intField('total')],
|
||||
}
|
||||
|
||||
const UserCtx = new ReactContext('user')
|
||||
|
||||
/** Register the AFI fixture functions with the mizan-ts registry. */
|
||||
export function registerFixture(): void {
|
||||
// echo — plain function, typed input + struct output.
|
||||
client(
|
||||
{
|
||||
ir: {
|
||||
input: [strField('text')],
|
||||
output: { kind: 'ref', name: 'EchoOutput' },
|
||||
types: { EchoOutput: { kind: 'struct', fields: [strField('message')] } },
|
||||
},
|
||||
},
|
||||
async function echo(text: string) {
|
||||
return { message: `echo: ${text}` }
|
||||
},
|
||||
)
|
||||
|
||||
// whoami — no input.
|
||||
client(
|
||||
{
|
||||
ir: {
|
||||
output: { kind: 'ref', name: 'WhoamiOutput' },
|
||||
types: {
|
||||
WhoamiOutput: {
|
||||
kind: 'struct',
|
||||
fields: [strField('email'), boolField('authenticated')],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async function whoami() {
|
||||
return { email: 'anon@example.com', authenticated: false }
|
||||
},
|
||||
)
|
||||
|
||||
// user_profile — context member.
|
||||
client(
|
||||
{
|
||||
context: UserCtx,
|
||||
ir: {
|
||||
input: [intField('user_id')],
|
||||
output: { kind: 'ref', name: 'ProfileOutput' },
|
||||
types: { ProfileOutput },
|
||||
},
|
||||
},
|
||||
async function user_profile(user_id: number) {
|
||||
return { user_id, name: 'placeholder' }
|
||||
},
|
||||
)
|
||||
|
||||
// user_orders — context member, list output, same param (param elevation).
|
||||
client(
|
||||
{
|
||||
context: UserCtx,
|
||||
ir: {
|
||||
input: [intField('user_id')],
|
||||
output: { kind: 'list', inner: { kind: 'ref', name: 'OrderOutput' } },
|
||||
types: { OrderOutput },
|
||||
},
|
||||
},
|
||||
async function user_orders(_user_id: number) {
|
||||
return []
|
||||
},
|
||||
)
|
||||
|
||||
// update_profile — mutation affecting the user context.
|
||||
client(
|
||||
{
|
||||
affects: UserCtx,
|
||||
ir: {
|
||||
input: [intField('user_id'), strField('name')],
|
||||
output: { kind: 'ref', name: 'StatusOutput' },
|
||||
types: { StatusOutput: { kind: 'struct', fields: [boolField('ok')] } },
|
||||
},
|
||||
},
|
||||
async function update_profile(_user_id: number, _name: string) {
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
// find_user — optional return.
|
||||
client(
|
||||
{
|
||||
ir: {
|
||||
input: [intField('user_id')],
|
||||
output: { kind: 'optional', inner: { kind: 'ref', name: 'ProfileOutput' } },
|
||||
types: { ProfileOutput },
|
||||
},
|
||||
},
|
||||
async function find_user(_user_id: number) {
|
||||
return null
|
||||
},
|
||||
)
|
||||
|
||||
// rename_user — merge target.
|
||||
client(
|
||||
{
|
||||
merge: UserCtx,
|
||||
ir: {
|
||||
input: [intField('user_id'), strField('name')],
|
||||
output: { kind: 'ref', name: 'ProfileOutput' },
|
||||
types: { ProfileOutput },
|
||||
},
|
||||
},
|
||||
async function rename_user(user_id: number, name: string) {
|
||||
return { user_id, name }
|
||||
},
|
||||
)
|
||||
}
|
||||
159
backends/mizan-ts/tests/ir.test.ts
Normal file
159
backends/mizan-ts/tests/ir.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* KDL IR byte-parity — the mizan-ts `buildIr()` against the canonical Python
|
||||
* `build_ir()` (`cores/mizan-python/src/mizan_core/ir.py`).
|
||||
*
|
||||
* The IR is the codegen contract. A TypeScript backend can only feed
|
||||
* `protocol/mizan-codegen` if it emits the same KDL the Python/Rust backends
|
||||
* emit for the same registry. This test reconstructs the AFI fixture in both
|
||||
* languages, subprocesses the live Python emitter, and asserts byte-equality —
|
||||
* the same discipline `protocol/mizan-codegen/tests/python_parity.rs` applies.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { buildIr, clearRegistry } from '../src'
|
||||
import { registerFixture } from './ir-fixture'
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, '../../..')
|
||||
const MIZAN_PYTHON = resolve(REPO_ROOT, 'cores/mizan-python')
|
||||
|
||||
/**
|
||||
* Reconstruct the AFI fixture in Python via `mizan_core` only (no backend
|
||||
* adapter dependency) and emit `build_ir()`. This is the cross-language oracle:
|
||||
* the same registrations the TS fixture makes, run through the reference
|
||||
* emitter.
|
||||
*/
|
||||
const PY_FIXTURE = String.raw`
|
||||
import sys
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from mizan_core.client.function import client
|
||||
from mizan_core import registry as reg
|
||||
from mizan_core.ir import build_ir
|
||||
|
||||
reg.clear_registry()
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
|
||||
class WhoamiOutput(BaseModel):
|
||||
email: str
|
||||
authenticated: bool
|
||||
|
||||
class ProfileOutput(BaseModel):
|
||||
user_id: int
|
||||
name: str
|
||||
|
||||
class OrderOutput(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
total: int
|
||||
|
||||
class StatusOutput(BaseModel):
|
||||
ok: bool
|
||||
|
||||
@client
|
||||
def echo(request, text: str) -> EchoOutput: ...
|
||||
|
||||
@client
|
||||
def whoami(request) -> WhoamiOutput: ...
|
||||
|
||||
@client(context="user")
|
||||
def user_profile(request, user_id: int) -> ProfileOutput: ...
|
||||
|
||||
@client(context="user")
|
||||
def user_orders(request, user_id: int) -> list[OrderOutput]: ...
|
||||
|
||||
@client(affects="user")
|
||||
def update_profile(request, user_id: int, name: str) -> StatusOutput: ...
|
||||
|
||||
@client
|
||||
def find_user(request, user_id: int) -> Optional[ProfileOutput]: ...
|
||||
|
||||
@client(merge="user")
|
||||
def rename_user(request, user_id: int, name: str) -> ProfileOutput: ...
|
||||
|
||||
for f in [echo, whoami, user_profile, user_orders, update_profile, find_user, rename_user]:
|
||||
reg.register(f, f.__name__)
|
||||
|
||||
sys.stdout.write(build_ir())
|
||||
`
|
||||
|
||||
function pythonBuildIr(): string {
|
||||
return execFileSync(
|
||||
'uv',
|
||||
['run', '--project', MIZAN_PYTHON, 'python', '-c', PY_FIXTURE],
|
||||
{ encoding: 'utf-8' },
|
||||
)
|
||||
}
|
||||
|
||||
const UV_AVAILABLE = (() => {
|
||||
try {
|
||||
execFileSync('uv', ['--version'], { stdio: 'ignore' })
|
||||
return existsSync(resolve(MIZAN_PYTHON, 'pyproject.toml'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
describe('KDL IR — buildIr()', () => {
|
||||
beforeEach(() => clearRegistry())
|
||||
|
||||
test('emits the canonical type / function / context sections', () => {
|
||||
registerFixture()
|
||||
const kdl = buildIr()
|
||||
|
||||
// Types are alphabetical; output structs renamed to <camel>Output.
|
||||
expect(kdl).toContain('type "OrderOutput" {')
|
||||
expect(kdl).toContain('type "echoInput" {')
|
||||
expect(kdl).toContain('type "findUserOutput" {')
|
||||
expect(kdl).toContain('type "userOrdersOutput" {')
|
||||
|
||||
// Functions alphabetical, with transport + context/affects/merge leaves.
|
||||
expect(kdl).toContain('function "echo" {')
|
||||
expect(kdl).toContain(' camel "echo"')
|
||||
expect(kdl).toContain(' has-input #true')
|
||||
expect(kdl).toContain(' output-nullable #true') // find_user
|
||||
expect(kdl).toContain(' affects "user"') // update_profile
|
||||
expect(kdl).toContain(' merge "user"') // rename_user
|
||||
|
||||
// Context section with shared param elevation.
|
||||
expect(kdl).toContain('context "user" {')
|
||||
expect(kdl).toContain(' shared-by "user_orders"')
|
||||
expect(kdl).toContain(' shared-by "user_profile"')
|
||||
})
|
||||
|
||||
test('has-input #false for a no-arg function', () => {
|
||||
registerFixture()
|
||||
const kdl = buildIr()
|
||||
const whoami = kdl.slice(kdl.indexOf('function "whoami" {'))
|
||||
expect(whoami).toContain('has-input #false')
|
||||
expect(whoami).not.toContain('input "whoamiInput"')
|
||||
})
|
||||
|
||||
test.skipIf(!UV_AVAILABLE)(
|
||||
'byte-identical to the Python build_ir() (cores/mizan-python)',
|
||||
() => {
|
||||
registerFixture()
|
||||
const tsKdl = buildIr()
|
||||
const pyKdl = pythonBuildIr()
|
||||
|
||||
// Line-by-line first so a divergence names the offending line.
|
||||
const tsLines = tsKdl.split('\n')
|
||||
const pyLines = pyKdl.split('\n')
|
||||
const n = Math.max(tsLines.length, pyLines.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (tsLines[i] !== pyLines[i]) {
|
||||
throw new Error(
|
||||
`KDL diverges at line ${i + 1}:\n` +
|
||||
` python: ${JSON.stringify(pyLines[i])}\n` +
|
||||
` ts: ${JSON.stringify(tsLines[i])}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
expect(tsKdl).toBe(pyKdl)
|
||||
},
|
||||
)
|
||||
})
|
||||
167
backends/mizan-ts/tests/shapes-forms.test.ts
Normal file
167
backends/mizan-ts/tests/shapes-forms.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Shapes (typed query projection) + Forms (schema / validate / submit) tests.
|
||||
*
|
||||
* Shapes prove over-fetch elimination: the projected record carries only the
|
||||
* declared fields + nested relations, nothing else. Forms prove the three
|
||||
* roles register as dispatchable `@client` functions carrying the IR's
|
||||
* `form`/`form-name`/`form-role` meta, and that validate/submit enforce the
|
||||
* declared field rules.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
clearRegistry,
|
||||
getFunction,
|
||||
handleMutationCall,
|
||||
Shape,
|
||||
project,
|
||||
registerForm,
|
||||
formSchema,
|
||||
validateForm,
|
||||
type QueryProjection,
|
||||
} from '../src'
|
||||
|
||||
describe('Shapes — typed query projection', () => {
|
||||
test('keeps only declared scalar fields', () => {
|
||||
const projection: QueryProjection = { fields: ['id', 'name'] }
|
||||
const out = project([{ id: 1, name: 'A', secret: 'x', internal: 42 }], projection)
|
||||
expect(out).toEqual([{ id: 1, name: 'A' }])
|
||||
expect(out[0]).not.toHaveProperty('secret')
|
||||
expect(out[0]).not.toHaveProperty('internal')
|
||||
})
|
||||
|
||||
test('prunes nested relations recursively', () => {
|
||||
const projection: QueryProjection = {
|
||||
fields: ['id'],
|
||||
relations: { orders: { fields: ['total'] } },
|
||||
}
|
||||
const out = project(
|
||||
[{ id: 1, name: 'drop', orders: [{ id: 9, total: 100, hidden: true }] }],
|
||||
projection,
|
||||
)
|
||||
expect(out).toEqual([{ id: 1, orders: [{ total: 100 }] }])
|
||||
expect(out[0].orders[0]).not.toHaveProperty('hidden')
|
||||
})
|
||||
|
||||
test('handles single-object relation + null', () => {
|
||||
const projection: QueryProjection = {
|
||||
fields: ['id'],
|
||||
relations: { profile: { fields: ['bio'] } },
|
||||
}
|
||||
const out = project(
|
||||
[
|
||||
{ id: 1, profile: { bio: 'hi', age: 30 } },
|
||||
{ id: 2, profile: null },
|
||||
],
|
||||
projection,
|
||||
)
|
||||
expect(out[0]).toEqual({ id: 1, profile: { bio: 'hi' } })
|
||||
expect(out[1]).toEqual({ id: 2, profile: null })
|
||||
})
|
||||
|
||||
test('Shape.query binds a projection to a source', () => {
|
||||
const UserShape = new Shape('user', { fields: ['id', 'email'] })
|
||||
const out = UserShape.query([{ id: 1, email: 'a@b.c', password: 'nope' }])
|
||||
expect(out).toEqual([{ id: 1, email: 'a@b.c' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Forms — schema / validate / submit', () => {
|
||||
beforeEach(() => clearRegistry())
|
||||
|
||||
const contactForm = {
|
||||
fields: [
|
||||
{ name: 'email', type: 'email', required: true, label: 'Email' },
|
||||
{
|
||||
name: 'age',
|
||||
type: 'number',
|
||||
required: false,
|
||||
validate: (v: unknown) => (Number(v) < 0 ? 'must be non-negative' : null),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
test('formSchema produces field definitions', () => {
|
||||
const schema = formSchema(contactForm)
|
||||
expect(schema.fields).toHaveLength(2)
|
||||
expect(schema.fields[0]).toEqual({
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
label: 'Email',
|
||||
helpText: '',
|
||||
choices: null,
|
||||
initial: null,
|
||||
})
|
||||
// Default label derived from name when omitted.
|
||||
expect(schema.fields[1].label).toBe('Age')
|
||||
})
|
||||
|
||||
test('validateForm: required + custom validator', () => {
|
||||
expect(validateForm(contactForm, { email: 'a@b.c' }).valid).toBe(true)
|
||||
expect(validateForm(contactForm, {}).errors.email).toEqual(['This field is required.'])
|
||||
expect(validateForm(contactForm, { email: 'a@b.c', age: -1 }).errors.age).toEqual([
|
||||
'must be non-negative',
|
||||
])
|
||||
})
|
||||
|
||||
test('registerForm registers schema + validate + submit with form meta', () => {
|
||||
const reg = registerForm(contactForm, 'contact', {
|
||||
submit: async (data) => ({ saved: data.email }),
|
||||
})
|
||||
expect(reg).toEqual({ schema: 'contact-schema', validate: 'contact-validate', submit: 'contact-submit' })
|
||||
|
||||
for (const [wire, role] of [
|
||||
['contact-schema', 'schema'],
|
||||
['contact-validate', 'validate'],
|
||||
['contact-submit', 'submit'],
|
||||
] as const) {
|
||||
const entry = getFunction(wire)
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.form).toBe(true)
|
||||
expect(entry!.formName).toBe('contact')
|
||||
expect(entry!.formRole).toBe(role)
|
||||
}
|
||||
})
|
||||
|
||||
test('schema function dispatches to the field defs', async () => {
|
||||
registerForm(contactForm, 'contact')
|
||||
const r = await handleMutationCall('contact-schema', {})
|
||||
expect(r.status).toBe(200)
|
||||
expect(r.body.result.fields).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('validate function dispatches and rejects bad data', async () => {
|
||||
registerForm(contactForm, 'contact')
|
||||
const ok = await handleMutationCall('contact-validate', { data: { email: 'a@b.c' } })
|
||||
expect(ok.body.result.valid).toBe(true)
|
||||
|
||||
const bad = await handleMutationCall('contact-validate', { data: {} })
|
||||
expect(bad.body.result.valid).toBe(false)
|
||||
expect(bad.body.result.errors.email).toBeDefined()
|
||||
})
|
||||
|
||||
test('submit validates then runs the handler', async () => {
|
||||
let handled: any = null
|
||||
registerForm(contactForm, 'contact', {
|
||||
submit: async (data) => {
|
||||
handled = data
|
||||
return { id: 7 }
|
||||
},
|
||||
})
|
||||
|
||||
const ok = await handleMutationCall('contact-submit', { data: { email: 'a@b.c' } })
|
||||
expect(ok.body.result).toEqual({ ok: true, result: { id: 7 } })
|
||||
expect(handled).toEqual({ email: 'a@b.c' })
|
||||
|
||||
const bad = await handleMutationCall('contact-submit', { data: {} })
|
||||
expect(bad.body.result.ok).toBe(false)
|
||||
expect(bad.body.result.errors.email).toBeDefined()
|
||||
})
|
||||
|
||||
test('submit not registered without a handler', () => {
|
||||
const reg = registerForm(contactForm, 'noSubmit')
|
||||
expect(reg.submit).toBeUndefined()
|
||||
expect(getFunction('noSubmit-submit')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
101
backends/mizan-ts/tests/ssr.test.ts
Normal file
101
backends/mizan-ts/tests/ssr.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* SSR bridge tests — spawn + drive a JSON-RPC worker subprocess.
|
||||
*
|
||||
* The bridge's contract is the newline-delimited JSON-RPC protocol over a
|
||||
* spawned worker (ready handshake, id-correlated render/ping, error frames,
|
||||
* timeout, restart). Two peers exercise it:
|
||||
*
|
||||
* - a self-contained protocol stub (`stub-worker.mjs`, plain Node) — always
|
||||
* runs, proving the full subprocess machinery independent of any install;
|
||||
* - the REAL Bun worker (`workers/mizan-ssr/src/worker.tsx`) rendering an
|
||||
* actual React component — runs when `bun` + the worker's deps are present.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterEach } from 'bun:test'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { SSRBridge } from '../src'
|
||||
|
||||
const HERE = import.meta.dir
|
||||
const REPO_ROOT = resolve(HERE, '../../..')
|
||||
const STUB_WORKER = resolve(HERE, 'fixtures/stub-worker.mjs')
|
||||
const HELLO_TSX = resolve(HERE, 'fixtures/Hello.tsx')
|
||||
const REAL_WORKER = resolve(REPO_ROOT, 'workers/mizan-ssr/src/worker.tsx')
|
||||
|
||||
// The real worker renders an actual React component. bun resolves `react`
|
||||
// from the COMPONENT file's tree, so the fixture resolves it via mizan-ts's
|
||||
// own react devDependency (installed alongside this package).
|
||||
const BUN_OK = (() => {
|
||||
try {
|
||||
execFileSync('bun', ['--version'], { stdio: 'ignore' })
|
||||
return existsSync(resolve(HERE, '../node_modules/react/package.json'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
let bridge: SSRBridge | null = null
|
||||
afterEach(() => {
|
||||
bridge?.shutdown()
|
||||
bridge = null
|
||||
})
|
||||
|
||||
describe('SSRBridge — stub worker (Node, no React)', () => {
|
||||
test('waits for ready, then renders with id correlation', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
const r = await bridge.render('/abs/Card.tsx', { title: 'Hi', n: 3 })
|
||||
expect(r.html).toBe('<div data-file="/abs/Card.tsx">{"title":"Hi","n":3}</div>')
|
||||
})
|
||||
|
||||
test('ping health check', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
expect(await bridge.ping()).toBe(true)
|
||||
})
|
||||
|
||||
test('concurrent renders stay correlated', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
const [a, b, c] = await Promise.all([
|
||||
bridge.render('/a.tsx', { k: 'a' }),
|
||||
bridge.render('/b.tsx', { k: 'b' }),
|
||||
bridge.render('/c.tsx', { k: 'c' }),
|
||||
])
|
||||
expect(a.html).toContain('"k":"a"')
|
||||
expect(b.html).toContain('"k":"b"')
|
||||
expect(c.html).toContain('"k":"c"')
|
||||
})
|
||||
|
||||
test('worker error frame surfaces as a thrown error', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
await expect(bridge.render('/boom.tsx', {})).rejects.toThrow('SSR render failed')
|
||||
})
|
||||
|
||||
test('restarts after the worker exits', async () => {
|
||||
bridge = new SSRBridge({ worker: STUB_WORKER, runtime: 'node', runtimeArgs: [] })
|
||||
const first = await bridge.render('/one.tsx', { k: 1 })
|
||||
expect(first.html).toContain('"k":1')
|
||||
bridge.shutdown() // simulate a crashed/stopped worker
|
||||
const second = await bridge.render('/two.tsx', { k: 2 })
|
||||
expect(second.html).toContain('"k":2')
|
||||
})
|
||||
|
||||
test('startup timeout when the worker never signals ready', async () => {
|
||||
// `true` exits immediately without a ready frame → start times out.
|
||||
bridge = new SSRBridge({ worker: '/dev/null', runtime: 'true', runtimeArgs: [], timeout: 0.3 })
|
||||
await expect(bridge.render('/x.tsx', {})).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSRBridge — real Bun worker (renderToString)', () => {
|
||||
test.skipIf(!BUN_OK)('renders a React component to HTML', async () => {
|
||||
bridge = new SSRBridge({ worker: REAL_WORKER, runtime: 'bun' })
|
||||
const r = await bridge.render(HELLO_TSX, { name: 'Ryth' })
|
||||
expect(r.html).toContain('Hello, Ryth!')
|
||||
expect(r.html).toContain('class="greeting"')
|
||||
})
|
||||
|
||||
test.skipIf(!BUN_OK)('ping on the real worker', async () => {
|
||||
bridge = new SSRBridge({ worker: REAL_WORKER, runtime: 'bun' })
|
||||
expect(await bridge.ping()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,23 @@
|
||||
/**
|
||||
* MWT decode tests — round-trip + cross-language pin against Python create_mwt.
|
||||
* MWT / JWT token tests — decode round-trip + cross-language byte-parity pins
|
||||
* against the live Python mint (`cores/mizan-python`).
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { createHmac } from 'crypto'
|
||||
import { decodeMwt, decodeJwtBearer, identityFromMwt } from '../src'
|
||||
import { createHmac, createHash } from 'crypto'
|
||||
import { execFileSync } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import {
|
||||
decodeMwt,
|
||||
decodeJwtBearer,
|
||||
identityFromMwt,
|
||||
signMwt,
|
||||
computePermissionKey,
|
||||
createAccessToken,
|
||||
createRefreshToken,
|
||||
type MintUser,
|
||||
} from '../src'
|
||||
|
||||
function b64url(buf: Buffer | string): string {
|
||||
return Buffer.from(buf).toString('base64url')
|
||||
@@ -124,3 +137,117 @@ describe('MWT cross-language pin (Python create_mwt)', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Mint: round-trip + cross-language byte-parity ────────────────────────────
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, '../../..')
|
||||
const MIZAN_PYTHON = resolve(REPO_ROOT, 'cores/mizan-python')
|
||||
|
||||
const UV_AVAILABLE = (() => {
|
||||
try {
|
||||
execFileSync('uv', ['--version'], { stdio: 'ignore' })
|
||||
return existsSync(resolve(MIZAN_PYTHON, 'pyproject.toml'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
/**
|
||||
* Run a Python snippet against cores/mizan-python and return stdout (trimmed).
|
||||
* `time.time` is pinned so the production mint functions are deterministic.
|
||||
*/
|
||||
function py(snippet: string): string {
|
||||
return execFileSync('uv', ['run', '--project', MIZAN_PYTHON, 'python', '-c', snippet], {
|
||||
encoding: 'utf-8',
|
||||
}).trim()
|
||||
}
|
||||
|
||||
describe('MWT mint — round-trip', () => {
|
||||
const SECRET = 'mint-roundtrip-secret'
|
||||
|
||||
test('signMwt produces a token decodeMwt accepts', () => {
|
||||
const user: MintUser = { pk: 7, isStaff: true, isSuperuser: false, permissions: ['a.view', 'a.edit'] }
|
||||
const token = signMwt(user, SECRET, { now: Math.floor(Date.now() / 1000) })
|
||||
const p = decodeMwt(token, SECRET)
|
||||
expect(p).not.toBeNull()
|
||||
expect(p!.sub).toBe('7')
|
||||
expect(p!.staff).toBe(true)
|
||||
expect(p!.super).toBe(false)
|
||||
expect(p!.kid).toBe('v1')
|
||||
expect(p!.aud).toBe('mizan')
|
||||
// pkey is the permission hash, surviving the round-trip.
|
||||
expect(p!.pkey).toBe(computePermissionKey(user))
|
||||
})
|
||||
|
||||
test('computePermissionKey matches the documented blob hash', () => {
|
||||
const user: MintUser = { pk: 1, isStaff: true, isSuperuser: false, permissions: ['z', 'a'] }
|
||||
// "1:0:a,z" — staff:super:sorted-perms.
|
||||
const expected = createHash('sha256').update('1:0:a,z', 'utf-8').digest('hex')
|
||||
expect(computePermissionKey(user)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MWT mint — cross-language pin (Python create_mwt)', () => {
|
||||
const SECRET = 'pin-mint-secret-mwt'
|
||||
const NOW = 1700000000
|
||||
|
||||
test.skipIf(!UV_AVAILABLE)('TS signMwt byte-identical to Python create_mwt', () => {
|
||||
const user: MintUser = {
|
||||
pk: 42,
|
||||
isStaff: true,
|
||||
isSuperuser: false,
|
||||
permissions: ['app.view_thing', 'app.change_thing'],
|
||||
}
|
||||
const tsToken = signMwt(user, SECRET, { ttl: 300, now: NOW })
|
||||
|
||||
// Drive the REAL create_mwt with time.time pinned to NOW and a
|
||||
// user stub whose get_all_permissions returns the same perms.
|
||||
const pyToken = py(String.raw`
|
||||
import time, sys
|
||||
time.time = lambda: ${NOW}
|
||||
from mizan_core.mwt import create_mwt
|
||||
|
||||
class U:
|
||||
pk = 42
|
||||
is_staff = True
|
||||
is_superuser = False
|
||||
def get_all_permissions(self):
|
||||
return {"app.view_thing", "app.change_thing"}
|
||||
|
||||
sys.stdout.write(create_mwt(U(), ${JSON.stringify(SECRET)}, ttl=300))
|
||||
`)
|
||||
expect(tsToken).toBe(pyToken)
|
||||
})
|
||||
})
|
||||
|
||||
describe('JWT mint — cross-language pin (Python create_access/refresh_token)', () => {
|
||||
const SECRET = 'pin-mint-secret-jwt'
|
||||
const NOW = 1700000000
|
||||
|
||||
const config = { privateKey: SECRET, accessTokenExpiresIn: 300, refreshTokenExpiresIn: 604800 }
|
||||
const claims = { userId: 42, sessionKey: 'sess-abc', isStaff: true, isSuperuser: false }
|
||||
|
||||
test.skipIf(!UV_AVAILABLE)('TS createAccessToken byte-identical to Python', () => {
|
||||
const tsToken = createAccessToken(claims, config, NOW)
|
||||
const pyToken = py(String.raw`
|
||||
import time, sys
|
||||
time.time = lambda: ${NOW}
|
||||
from mizan_core.auth.jwt import JWTConfig, create_access_token
|
||||
cfg = JWTConfig(private_key=${JSON.stringify(SECRET)}, public_key=${JSON.stringify(SECRET)})
|
||||
sys.stdout.write(create_access_token(42, "sess-abc", cfg, is_staff=True, is_superuser=False))
|
||||
`)
|
||||
expect(tsToken).toBe(pyToken)
|
||||
})
|
||||
|
||||
test.skipIf(!UV_AVAILABLE)('TS createRefreshToken byte-identical to Python', () => {
|
||||
const tsToken = createRefreshToken(claims, config, NOW)
|
||||
const pyToken = py(String.raw`
|
||||
import time, sys
|
||||
time.time = lambda: ${NOW}
|
||||
from mizan_core.auth.jwt import JWTConfig, create_refresh_token
|
||||
cfg = JWTConfig(private_key=${JSON.stringify(SECRET)}, public_key=${JSON.stringify(SECRET)})
|
||||
sys.stdout.write(create_refresh_token(42, "sess-abc", cfg, is_staff=True, is_superuser=False))
|
||||
`)
|
||||
expect(tsToken).toBe(pyToken)
|
||||
})
|
||||
})
|
||||
|
||||
131
backends/mizan-ts/tests/transport.test.ts
Normal file
131
backends/mizan-ts/tests/transport.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Session-init + WebSocket transport tests.
|
||||
*
|
||||
* session-init returns the `{ csrfToken }` no-store shape at parity with the
|
||||
* Django/FastAPI/Axum session endpoint. The WebSocket transport drives the
|
||||
* SAME dispatch core the HTTP path uses, so a function exposed over WS behaves
|
||||
* identically — invalidation, auth, and not-found all carry through.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
ReactContext,
|
||||
client,
|
||||
clearRegistry,
|
||||
handleSessionInit,
|
||||
sessionInitRoute,
|
||||
SESSION_INIT_PATH,
|
||||
handleWebSocketMessage,
|
||||
serveWebSocket,
|
||||
type Identity,
|
||||
type WebSocketLike,
|
||||
} from '../src'
|
||||
|
||||
describe('session-init', () => {
|
||||
test('returns { csrfToken: null } with no-store', () => {
|
||||
const r = handleSessionInit()
|
||||
expect(r.status).toBe(200)
|
||||
expect(r.body).toEqual({ csrfToken: null })
|
||||
expect(r.headers['Cache-Control']).toBe('no-store')
|
||||
expect(r.headers['Content-Type']).toBe('application/json')
|
||||
})
|
||||
|
||||
test('embeds a host-provided CSRF token', () => {
|
||||
const r = handleSessionInit('tok-123')
|
||||
expect(r.body).toEqual({ csrfToken: 'tok-123' })
|
||||
})
|
||||
|
||||
test('route descriptor mounts GET /session/ (parity with Django/FastAPI/Axum)', () => {
|
||||
expect(SESSION_INIT_PATH).toBe('/session/')
|
||||
expect(sessionInitRoute.path).toBe('/session/')
|
||||
expect(sessionInitRoute.method).toBe('GET')
|
||||
// The wired handler returns the session shape.
|
||||
expect(sessionInitRoute.handler().body).toEqual({ csrfToken: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSocket transport', () => {
|
||||
beforeEach(() => clearRegistry())
|
||||
|
||||
const UserCtx = new ReactContext('user')
|
||||
|
||||
function setup() {
|
||||
client({ context: UserCtx, websocket: true }, async function user_profile(user_id: number) {
|
||||
return { user_id, name: `user_${user_id}` }
|
||||
})
|
||||
client({ affects: UserCtx, websocket: true }, async function update_profile(user_id: number, name: string) {
|
||||
return { ok: true, user_id, name }
|
||||
})
|
||||
}
|
||||
|
||||
test('call frame routes through mutation dispatch + carries invalidation', async () => {
|
||||
setup()
|
||||
const reply = await handleWebSocketMessage({
|
||||
id: 1,
|
||||
type: 'call',
|
||||
fn: 'update_profile',
|
||||
args: { user_id: 5, name: 'X' },
|
||||
})
|
||||
expect(reply.id).toBe(1)
|
||||
expect(reply.result).toEqual({ ok: true, user_id: 5, name: 'X' })
|
||||
expect(reply.invalidate).toBeDefined()
|
||||
expect(reply.invalidate[0].context).toBe('user')
|
||||
expect(reply.invalidate[0].params.user_id).toBe(5)
|
||||
})
|
||||
|
||||
test('fetch frame routes through context bundle', async () => {
|
||||
setup()
|
||||
const reply = await handleWebSocketMessage({
|
||||
id: 2,
|
||||
type: 'fetch',
|
||||
context: 'user',
|
||||
params: { user_id: '7' },
|
||||
})
|
||||
expect(reply.id).toBe(2)
|
||||
expect(reply.result.user_profile).toEqual({ user_id: '7', name: 'user_7' })
|
||||
})
|
||||
|
||||
test('unknown function returns an error frame, not a throw', async () => {
|
||||
const reply = await handleWebSocketMessage({ id: 3, type: 'call', fn: 'nope' })
|
||||
expect(reply.error).toBeDefined()
|
||||
expect(reply.error!.code).toBe('NOT_FOUND')
|
||||
expect(reply.id).toBe(3)
|
||||
})
|
||||
|
||||
test('auth enforcement carries over the WS transport', async () => {
|
||||
client({ auth: true, websocket: true }, async function secret() {
|
||||
return { ok: true }
|
||||
})
|
||||
const anon: Identity = { isAuthenticated: false, isStaff: false, isSuperuser: false, id: null }
|
||||
const reply = await handleWebSocketMessage({ id: 4, type: 'call', fn: 'secret' }, anon)
|
||||
expect(reply.error!.code).toBe('UNAUTHORIZED')
|
||||
})
|
||||
|
||||
test('malformed JSON frame → error', async () => {
|
||||
const reply = await handleWebSocketMessage('{not json')
|
||||
expect(reply.error!.code).toBe('BAD_REQUEST')
|
||||
})
|
||||
|
||||
test('serveWebSocket wires a connection and replies as JSON', async () => {
|
||||
setup()
|
||||
const sent: string[] = []
|
||||
let listener: ((e: { data: any }) => void) | null = null
|
||||
const ws: WebSocketLike = {
|
||||
send: (d) => sent.push(d),
|
||||
addEventListener: (_t, l) => {
|
||||
listener = l
|
||||
},
|
||||
}
|
||||
serveWebSocket(ws)
|
||||
expect(listener).not.toBeNull()
|
||||
|
||||
// Drive a message through the wired listener.
|
||||
await listener!({ data: JSON.stringify({ id: 9, type: 'fetch', context: 'user', params: { user_id: '3' } }) })
|
||||
// Give the async handler a tick to resolve + send.
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(sent.length).toBe(1)
|
||||
const reply = JSON.parse(sent[0])
|
||||
expect(reply.id).toBe(9)
|
||||
expect(reply.result.user_profile.name).toBe('user_3')
|
||||
})
|
||||
})
|
||||
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