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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user