AFI conformance test suite

Substrate-level gate: same @client fixture registered in both backends
emits equivalent schemas, therefore the codegen produces equivalent
TypeScript regardless of which backend the frontend is generated against.
Catches adapter symmetry problems (Pydantic→OpenAPI converter divergence,
metadata leakage, ordering non-determinism) without docker, browser, or
Playwright.

What ships:

backends/mizan-fastapi/src/mizan_fastapi/schema.py — build_schema():
- Builds OpenAPI 3.0 from registered Mizan functions, mirroring the
  shape mizan-django's export emits.
- Drives FastAPI's native OpenAPI generation by registering a stub POST
  endpoint per function with its Input/Output Pydantic models, then
  appends x-mizan-functions and x-mizan-contexts extensions.
- Param-elevation logic mirrors mizan-django/src/mizan/export/__init__.py
  exactly (sharedBy tracking, required iff every function in context has
  the param).
- snake_to_camel and metadata field shapes match Django for byte-equality
  on the AFI surface.

tests/afi/ — the conformance harness:
- fixture.py: 5 @client functions covering the protocol axes (plain,
  context, mutation+affects). No channels/forms — those aren't AFI-common.
- django_app/: minimal Django project (settings, urls, AppConfig.ready
  registers the fixture). manage.py adds tests/afi/ to sys.path so both
  backends import the same fixture module.
- fastapi_app.py: thin make_app() that registers fixture and mounts router.
- schema_normalizer.py: drops backend-specific framing — Ninja-vs-FastAPI
  envelope differences (info/servers/tags), Django-only function fields
  (form metadata), x-mizan-channels. Plus afi_subset() and
  function_io_schemas() helpers for narrower comparisons.
- test_codegen_parity.py: three gates
  1. x-mizan-functions match across backends
  2. x-mizan-contexts match across backends
  3. Per-function Input/Output OpenAPI schemas match (what codegen feeds
     to openapi-typescript for type generation)

The full normalized OpenAPI envelopes do diverge — FastAPI adds
HTTPValidationError, the two converters wrap things slightly differently
in non-AFI-essential ways. That's not in the test scope. The codegen
only consumes x-mizan-functions, x-mizan-contexts, and the per-function
type schemas; those are what the test gates.

Makefile: test-afi target added; rolls into the test aggregate.

Verified: 3/3 conformance tests pass. Other surfaces unaffected —
mizan-core 15/15, mizan-django 348 pass, mizan-fastapi 11/11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 18:59:01 -04:00
parent aaaf80cdbf
commit 0a95f3c860
16 changed files with 584 additions and 2 deletions

0
tests/afi/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1 @@
default_app_config = "afi_app.apps.AfiAppConfig"

View File

@@ -0,0 +1,13 @@
"""Django app config that registers the AFI fixture on Django's ready() hook."""
from django.apps import AppConfig
class AfiAppConfig(AppConfig):
name = "afi_app"
def ready(self) -> None:
# tests/afi/ is on sys.path (added by manage.py), so `fixture` resolves
# to tests/afi/fixture.py — the same module the FastAPI side imports.
from fixture import register_fixture
register_fixture()

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env python
"""Minimal Django manage.py for the AFI conformance fixture."""
import os
import sys
def main() -> None:
here = os.path.dirname(os.path.abspath(__file__))
# tests/afi/ must be on sys.path so apps.py can `from fixture import …`.
sys.path.insert(0, os.path.dirname(here))
sys.path.insert(0, here)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1,20 @@
"""Minimal Django settings for the AFI conformance fixture."""
SECRET_KEY = "afi-conformance-test-only"
DEBUG = True
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.auth",
"mizan",
"afi_app",
]
DATABASES = {
"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"},
}
ROOT_URLCONF = "project.urls"
USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

View File

@@ -0,0 +1,3 @@
"""Empty URLconf — the AFI fixture only exercises the schema export."""
urlpatterns: list = []

26
tests/afi/fastapi_app.py Normal file
View File

@@ -0,0 +1,26 @@
"""FastAPI test app that registers the AFI fixture and exposes build_schema()."""
from __future__ import annotations
from fastapi import FastAPI
from mizan_fastapi import (
MizanError,
mizan_exception_handler,
mizan_validation_handler,
router as mizan_router,
)
from fixture import register_fixture
def make_app() -> FastAPI:
"""Build a fresh FastAPI app with the AFI fixture registered."""
register_fixture()
app = FastAPI()
app.include_router(mizan_router, prefix="/api/mizan")
app.add_exception_handler(MizanError, mizan_exception_handler)
from fastapi.exceptions import RequestValidationError
app.add_exception_handler(RequestValidationError, mizan_validation_handler)
return app

94
tests/afi/fixture.py Normal file
View File

@@ -0,0 +1,94 @@
"""
The AFI fixture — a small set of @client-decorated functions designed to
exercise the protocol axes both backends must agree on:
- plain function with typed input
- plain function with no input
- two context functions sharing a param (proves bundling + param elevation)
- a mutation declaring `affects` on the context
No channels, no forms, no shapes — those aren't AFI-common.
`register_fixture()` registers the functions with mizan_core.registry.
Backend test apps import this module and call register_fixture() during
their setup so each backend's schema export sees the same registrations.
"""
from __future__ import annotations
from pydantic import BaseModel
from mizan_core.client.function import client
from mizan_core.registry import register
# ─── Output shapes ──────────────────────────────────────────────────────────
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
# ─── Fixture functions ──────────────────────────────────────────────────────
@client
def echo(request, text: str) -> EchoOutput:
"""Echoes the input back."""
return EchoOutput(message=f"echo: {text}")
@client
def whoami(request) -> WhoamiOutput:
"""Returns the current user identity."""
return WhoamiOutput(email="anon@example.com", authenticated=False)
@client(context="user")
def user_profile(request, user_id: int) -> ProfileOutput:
"""One half of the user context."""
return ProfileOutput(user_id=user_id, name="placeholder")
@client(context="user")
def user_orders(request, user_id: int) -> list[OrderOutput]:
"""Other half of the user context — same param, proves param elevation."""
return []
@client(affects="user")
def update_profile(request, user_id: int, name: str) -> StatusOutput:
"""Mutation declaring affects on the user context."""
return StatusOutput(ok=True)
# ─── Registration ───────────────────────────────────────────────────────────
def register_fixture() -> None:
"""Register every fixture function with mizan_core.registry."""
register(echo, "echo")
register(whoami, "whoami")
register(user_profile, "user_profile")
register(user_orders, "user_orders")
register(update_profile, "update_profile")

22
tests/afi/pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[project]
name = "mizan-afi-tests"
version = "0.0.0"
description = "AFI conformance tests — verifies mizan-django and mizan-fastapi emit equivalent schemas for the same registered functions."
requires-python = ">=3.10"
dependencies = [
"mizan-core",
"mizan",
"mizan-fastapi",
"pytest>=8.0",
]
[tool.uv.sources]
mizan-core = { path = "../../cores/mizan-python", editable = true }
mizan = { path = "../../backends/mizan-django", editable = true }
mizan-fastapi = { path = "../../backends/mizan-fastapi", editable = true }
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["."]
python_classes = ["*Tests", "*Test", "Test*"]
python_functions = ["test_*"]

View File

@@ -0,0 +1,79 @@
"""
Normalize an OpenAPI schema dict so backend-specific framing doesn't make
two semantically-equivalent schemas compare unequal.
Drops:
- Top-level OpenAPI envelope fields that vary trivially (info, servers, tags)
- Django-only x-mizan-functions sub-fields (form metadata)
- Django-only top-level extensions (x-mizan-channels)
Sorts:
- x-mizan-functions by name
- components.schemas keys
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any
_DJANGO_ONLY_FUNCTION_FIELDS = {"isForm", "formName", "formRole", "formFields", "formFieldsError"}
_OPENAPI_ENVELOPE_FIELDS_TO_DROP = {"info", "servers", "tags", "openapi"}
def normalize(schema: dict[str, Any]) -> dict[str, Any]:
"""Return a canonicalized copy suitable for deep-equal comparison."""
out = deepcopy(schema)
for k in _OPENAPI_ENVELOPE_FIELDS_TO_DROP:
out.pop(k, None)
out.pop("x-mizan-channels", None)
fns = out.get("x-mizan-functions") or []
for fn in fns:
for field in _DJANGO_ONLY_FUNCTION_FIELDS:
fn.pop(field, None)
out["x-mizan-functions"] = sorted(fns, key=lambda f: f["name"])
components = out.get("components", {})
schemas = components.get("schemas")
if isinstance(schemas, dict):
components["schemas"] = {k: schemas[k] for k in sorted(schemas)}
return out
def afi_subset(schema: dict[str, Any]) -> dict[str, Any]:
"""Just the AFI-essential surface — what every adapter must agree on."""
fns = schema.get("x-mizan-functions") or []
fns_clean = [
{k: v for k, v in fn.items() if k not in _DJANGO_ONLY_FUNCTION_FIELDS}
for fn in fns
]
return {
"x-mizan-functions": sorted(fns_clean, key=lambda f: f["name"]),
"x-mizan-contexts": schema.get("x-mizan-contexts", {}),
}
def function_io_schemas(schema: dict[str, Any]) -> dict[str, Any]:
"""
Subset of components.schemas containing only the per-function Input/Output
models (what the codegen actually consumes for TypeScript type generation).
Drops backend-specific noise like HTTPValidationError, ValidationError,
that one backend emits and the other doesn't.
"""
fns = schema.get("x-mizan-functions") or []
expected_names: set[str] = set()
for fn in fns:
if fn.get("inputType"):
expected_names.add(fn["inputType"])
if fn.get("outputType"):
expected_names.add(fn["outputType"])
components = schema.get("components", {})
schemas = components.get("schemas", {})
return {name: schemas[name] for name in sorted(expected_names) if name in schemas}

View File

@@ -0,0 +1,88 @@
"""
AFI conformance — same @client fixture, same schema, both adapters.
Gates that mizan-django and mizan-fastapi emit equivalent schemas for the
same registered functions. If this passes, the codegen produces equivalent
TypeScript output regardless of which backend the frontend is generated
against (codegen is deterministic over schema input).
This is a substrate-level gate, not e2e. It catches adapter symmetry
problems — Pydantic→OpenAPI converter divergence, metadata leakage,
ordering non-determinism — without running a real frontend or backend.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).parent
DJANGO_MANAGE = HERE / "django_app" / "manage.py"
# ─── Schema fetchers ────────────────────────────────────────────────────────
def _fetch_django_schema() -> dict:
"""Spawn Django's management command and parse its stdout JSON."""
result = subprocess.run(
[sys.executable, str(DJANGO_MANAGE), "export_mizan_schema", "--indent", "0"],
capture_output=True,
text=True,
check=False,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
)
if result.returncode != 0:
pytest.fail(f"export_mizan_schema failed:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}")
return json.loads(result.stdout)
def _fetch_fastapi_schema() -> dict:
"""Build the FastAPI app inline (fresh registry) and call build_schema()."""
sys.path.insert(0, str(HERE))
try:
from mizan_core.registry import clear_registry
from mizan_fastapi import build_schema
from fastapi_app import make_app
clear_registry()
make_app()
return build_schema()
finally:
sys.path.remove(str(HERE))
# ─── Tests ──────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def schemas() -> tuple[dict, dict]:
return _fetch_django_schema(), _fetch_fastapi_schema()
class AFISubsetTests:
"""The AFI surface — x-mizan-functions and x-mizan-contexts — must match."""
def test_x_mizan_functions_match(self, schemas):
from schema_normalizer import afi_subset
django, fastapi = schemas
assert afi_subset(django)["x-mizan-functions"] == afi_subset(fastapi)["x-mizan-functions"]
def test_x_mizan_contexts_match(self, schemas):
from schema_normalizer import afi_subset
django, fastapi = schemas
assert afi_subset(django)["x-mizan-contexts"] == afi_subset(fastapi)["x-mizan-contexts"]
class TypeSchemasTests:
"""Per-function Input/Output Pydantic→OpenAPI schemas — codegen feeds these to openapi-typescript."""
def test_function_io_schemas_match(self, schemas):
from schema_normalizer import function_io_schemas
django, fastapi = schemas
assert function_io_schemas(django) == function_io_schemas(fastapi)