"""End-to-end dispatch tests against a real FastAPI app + TestClient.""" from __future__ import annotations import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import BaseModel from mizan_core.client.function import client from mizan_core.registry import clear_registry, register from mizan_fastapi import router as mizan_router # ─── Fixtures ─────────────────────────────────────────────────────────────── class EchoOutput(BaseModel): message: str class SumOutput(BaseModel): total: int class UserOutput(BaseModel): email: str authenticated: bool @pytest.fixture def app(): """Build a fresh FastAPI app + Mizan router with a few @client functions.""" clear_registry() @client def echo(request, text: str) -> EchoOutput: return EchoOutput(message=f"echo: {text}") @client def add(request, a: int, b: int) -> SumOutput: return SumOutput(total=a + b) @client(context="user") def current_user(request) -> UserOutput: return UserOutput(email="anon@example.com", authenticated=False) @client(context="user") def user_count(request) -> SumOutput: return SumOutput(total=42) @client(affects="user") def update_email(request, email: str) -> EchoOutput: return EchoOutput(message=f"updated: {email}") register(echo, "echo") register(add, "add") register(current_user, "current_user") register(user_count, "user_count") register(update_email, "update_email") fastapi_app = FastAPI() fastapi_app.include_router(mizan_router, prefix="/api/mizan") yield fastapi_app clear_registry() @pytest.fixture def http(app): return TestClient(app) # ─── RPC dispatch ─────────────────────────────────────────────────────────── class FunctionCallTests: def test_simple_call_returns_result(self, http): r = http.post("/api/mizan/call/", json={"fn": "echo", "args": {"text": "hi"}}) assert r.status_code == 200 body = r.json() assert body["result"]["message"] == "echo: hi" assert body["invalidate"] == [] def test_call_with_typed_input(self, http): r = http.post("/api/mizan/call/", json={"fn": "add", "args": {"a": 2, "b": 3}}) assert r.status_code == 200 assert r.json()["result"]["total"] == 5 def test_unknown_function_returns_not_found(self, http): r = http.post("/api/mizan/call/", json={"fn": "ghost"}) assert r.status_code == 404 assert r.json()["error"]["code"] == "NOT_FOUND" def test_validation_error_returns_422(self, http): r = http.post("/api/mizan/call/", json={"fn": "add", "args": {"a": "not-int", "b": 3}}) assert r.status_code == 422 assert r.json()["error"]["code"] == "VALIDATION_ERROR" def test_missing_required_input_returns_validation_error(self, http): r = http.post("/api/mizan/call/", json={"fn": "add", "args": {}}) assert r.status_code == 422 assert r.json()["error"]["code"] == "VALIDATION_ERROR" def test_missing_fn_field_returns_400(self, http): r = http.post("/api/mizan/call/", json={}) assert r.status_code == 400 assert r.json()["error"]["code"] == "BAD_REQUEST" def test_invalid_json_returns_400(self, http): r = http.post("/api/mizan/call/", content=b"not json", headers={"content-type": "application/json"}) assert r.status_code == 400 def test_response_carries_no_store(self, http): r = http.post("/api/mizan/call/", json={"fn": "echo", "args": {"text": "x"}}) assert r.headers.get("cache-control") == "no-store" # ─── Context bundling ─────────────────────────────────────────────────────── class ContextFetchTests: def test_context_returns_bundled_results(self, http): r = http.get("/api/mizan/ctx/user/") assert r.status_code == 200 body = r.json() assert "current_user" in body assert "user_count" in body assert body["current_user"]["email"] == "anon@example.com" assert body["user_count"]["total"] == 42 def test_unknown_context_returns_not_found(self, http): r = http.get("/api/mizan/ctx/ghost/") assert r.status_code == 404 assert r.json()["error"]["code"] == "NOT_FOUND" # ─── Invalidation ─────────────────────────────────────────────────────────── class InvalidationTests: def test_mutation_emits_invalidate_list(self, http): r = http.post( "/api/mizan/call/", json={"fn": "update_email", "args": {"email": "new@example.com"}}, ) assert r.status_code == 200 body = r.json() # affects='user' is a context-name string → invalidate list contains 'user' assert "user" in body["invalidate"]