Add HTTP integration tests through full Django stack

9 tests that use Django's test Client instead of RequestFactory.
These go through URL routing, middleware (sessions, CSRF, auth),
and real request parsing — proving the protocol works end-to-end:

- Mutation with auto-scoped invalidation (JSON body + header)
- Context fetch with bundled response + CDN headers
- String-to-int query param coercion
- Broad invalidation fallback (no matching args)
- Function-level affects targeting
- 404 for unknown functions and contexts
- Method enforcement (GET-only on /ctx/, POST-only on /call/)

282 Django tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 19:37:35 -04:00
parent a91ce78c3a
commit 1a4da68f8d

View File

@@ -1697,3 +1697,217 @@ class mizanFormMixinTests(TestCase):
# Should not be registered
self.assertIsNone(get_function("plain_form.schema"))
# =============================================================================
# HTTP Integration Tests — Full stack through Django's test Client
# =============================================================================
class HTTPIntegrationTests(TestCase):
"""
Tests that go through Django's test Client: URL routing, middleware,
CSRF, session, and real request parsing. No RequestFactory shortcuts.
"""
def setUp(self):
clear_registry()
def tearDown(self):
clear_registry()
def test_mutation_via_http_with_invalidation(self):
"""POST /api/mizan/call/ through real HTTP returns invalidation."""
UserCtx = ReactContext("user")
class ProfileOut(BaseModel):
name: str
@client(context=UserCtx)
def user_profile(request: HttpRequest, user_id: int) -> ProfileOut:
return ProfileOut(name=f"user_{user_id}")
@client(affects=UserCtx)
def update_profile(request: HttpRequest, user_id: int, name: str) -> ProfileOut:
return ProfileOut(name=name)
register(user_profile, "user_profile")
register(update_profile, "update_profile")
# Django test Client goes through full middleware stack
response = self.client.post(
"/api/mizan/call/",
data=json.dumps({"fn": "update_profile", "args": {"user_id": 5, "name": "Ryth"}}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
# Server-driven invalidation in JSON body
self.assertIn("result", data)
self.assertEqual(data["result"]["name"], "Ryth")
# Auto-scoped: user_id matched
self.assertEqual(len(data["invalidate"]), 1)
self.assertEqual(data["invalidate"][0]["context"], "user")
self.assertEqual(data["invalidate"][0]["params"]["user_id"], 5)
# Header transport
self.assertEqual(response["X-Mizan-Invalidate"], "user;user_id=5")
self.assertEqual(response["Cache-Control"], "no-store")
def test_context_fetch_via_http(self):
"""GET /api/mizan/ctx/user/?user_id=5 through real HTTP."""
UserCtx = ReactContext("user")
class ProfileOut(BaseModel):
name: str
class OrdersOut(BaseModel):
count: int
@client(context=UserCtx)
def user_profile(request: HttpRequest, user_id: int) -> ProfileOut:
return ProfileOut(name=f"user_{user_id}")
@client(context=UserCtx)
def user_orders(request: HttpRequest, user_id: int) -> OrdersOut:
return OrdersOut(count=user_id * 10)
register(user_profile, "user_profile")
register(user_orders, "user_orders")
response = self.client.get("/api/mizan/ctx/user/?user_id=5")
self.assertEqual(response.status_code, 200)
data = response.json()
# Raw bundled data
self.assertEqual(data["user_profile"]["name"], "user_5")
self.assertEqual(data["user_orders"]["count"], 50)
# CDN headers
self.assertIn("public", response["Cache-Control"])
self.assertIn("stale-while-revalidate", response["Cache-Control"])
self.assertIn("Authorization", response["Vary"])
def test_context_fetch_string_to_int_coercion(self):
"""Query params arrive as strings. Pydantic must coerce to int."""
UserCtx = ReactContext("user")
class Out(BaseModel):
doubled: int
@client(context=UserCtx)
def double_it(request: HttpRequest, value: int) -> Out:
return Out(doubled=value * 2)
register(double_it, "double_it")
# value=7 arrives as string "7" from query params
response = self.client.get("/api/mizan/ctx/user/?value=7")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["double_it"]["doubled"], 14)
def test_unknown_function_404(self):
"""POST to unknown function returns 404 via real HTTP."""
response = self.client.post(
"/api/mizan/call/",
data=json.dumps({"fn": "nonexistent", "args": {}}),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
data = response.json()
self.assertTrue(data["error"])
self.assertEqual(data["code"], "NOT_FOUND")
def test_unknown_context_404(self):
"""GET unknown context returns 404 via real HTTP."""
response = self.client.get("/api/mizan/ctx/nonexistent/")
self.assertEqual(response.status_code, 404)
data = response.json()
self.assertTrue(data["error"])
def test_broad_invalidation_no_param_match(self):
"""Mutation without matching args produces broad invalidation via HTTP."""
UserCtx = ReactContext("user")
class Out(BaseModel):
name: str
@client(context=UserCtx)
def user_profile(request: HttpRequest, user_id: int) -> Out:
return Out(name="test")
# url doesn't match user_id
@client(affects=UserCtx)
def update_avatar(request: HttpRequest, url: str) -> Out:
return Out(name="updated")
register(user_profile, "user_profile")
register(update_avatar, "update_avatar")
response = self.client.post(
"/api/mizan/call/",
data=json.dumps({"fn": "update_avatar", "args": {"url": "pic.jpg"}}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
# Broad — no scoping
self.assertEqual(data["invalidate"], ["user"])
self.assertEqual(response["X-Mizan-Invalidate"], "user")
def test_function_level_affects_via_http(self):
"""affects='user_profile' targets function, auto-scopes via HTTP."""
UserCtx = ReactContext("user")
class Out(BaseModel):
v: int
@client(context=UserCtx)
def user_profile(request: HttpRequest, user_id: int) -> Out:
return Out(v=1)
@client(context=UserCtx)
def user_orders(request: HttpRequest, user_id: int) -> Out:
return Out(v=2)
@client(affects="user_profile")
def update_name(request: HttpRequest, user_id: int, name: str) -> Out:
return Out(v=3)
register(user_profile, "user_profile")
register(user_orders, "user_orders")
register(update_name, "update_name")
response = self.client.post(
"/api/mizan/call/",
data=json.dumps({"fn": "update_name", "args": {"user_id": 7, "name": "Ryth"}}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
# Function-level + auto-scoped
self.assertEqual(data["invalidate"][0]["context"], "user_profile")
self.assertEqual(data["invalidate"][0]["params"]["user_id"], 7)
self.assertEqual(response["X-Mizan-Invalidate"], "user_profile;user_id=7")
def test_get_method_rejected_on_call_endpoint(self):
"""GET /api/mizan/call/ is rejected."""
response = self.client.get("/api/mizan/call/")
self.assertEqual(response.status_code, 405)
def test_post_method_rejected_on_context_endpoint(self):
"""POST /api/mizan/ctx/user/ is rejected."""
response = self.client.post("/api/mizan/ctx/user/")
self.assertEqual(response.status_code, 405)