From 1a4da68f8dd591dd38bc236ce4e9845c8bfbed01 Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Thu, 2 Apr 2026 19:37:35 -0400 Subject: [PATCH] Add HTTP integration tests through full Django stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../mizan-django/src/mizan/tests/test_core.py | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) diff --git a/packages/mizan-django/src/mizan/tests/test_core.py b/packages/mizan-django/src/mizan/tests/test_core.py index 69c7e80..98c3151 100644 --- a/packages/mizan-django/src/mizan/tests/test_core.py +++ b/packages/mizan-django/src/mizan/tests/test_core.py @@ -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)