swadhinbiswas

[email protected]
2026-07-08 14:00:00 +0600 committed NaN years ago
f693697
commit f693697
23 files changed
+1984 -0
▼crates/justapi-py/python/justapi/test_background.py
+27-0
1
+import pytest
2
+import time
3
+from justapi import JustAPIApp, JustAPITestClient, BackgroundTasks
4
+
5
+def test_background_tasks():
6
+ app = JustAPIApp()
7
+
8
+ result = []
9
+ def bg_task(msg):
10
+ time.sleep(0.1)
11
+ result.append(msg)
12
+
13
+ @app.get("/task")
14
+ def add_task(background_tasks: BackgroundTasks):
15
+ background_tasks.add_task(bg_task, "hello from bg")
16
+ return {"status": "ok"}
17
+
18
+ client = JustAPITestClient(app)
19
+ response = client.get("/task")
20
+ assert response["status"] == 200
21
+ assert len(result) == 0 # Hasn't finished yet or ran in background
22
+
23
+ # wait for background task
24
+ time.sleep(0.3)
25
+ assert len(result) == 1
26
+ assert result[0] == "hello from bg"
27
+
0
28
▼crates/justapi-py/python/justapi/test_circuit_breaker.py
+60-0
1
+import urllib.request
2
+from urllib.error import HTTPError
3
+import multiprocessing
4
+import time
5
+from justapi import JustAPIApp
6
+
7
+def run_server():
8
+ app = JustAPIApp()
9
+ app.enable_circuit_breaker(failure_threshold=2, reset_timeout_ms=5000)
10
+
11
+ # Route that always fails
12
+ @app.get("/flaky")
13
+ def flaky(req):
14
+ raise Exception("Random failure!")
15
+
16
+ # Route that works
17
+ @app.get("/ok")
18
+ def ok(req):
19
+ return {"status": "ok"}
20
+
21
+ app.run("127.0.0.1:8088")
22
+
23
+def fetch(url):
24
+ try:
25
+ urllib.request.urlopen(url)
26
+ return 200
27
+ except HTTPError as e:
28
+ return e.code
29
+ except Exception as e:
30
+ return 0
31
+
32
+def test_circuit_breaker():
33
+ p = multiprocessing.Process(target=run_server)
34
+ p.start()
35
+ time.sleep(1.5) # Wait for server to start
36
+
37
+ try:
38
+ # Request 1: fails, should return 500 (Python exception caught and mapped to 500)
39
+ status1 = fetch("http://127.0.0.1:8088/flaky")
40
+ assert status1 == 500, f"Expected 500, got {status1}"
41
+
42
+ # Request 2: fails, should return 500
43
+ status2 = fetch("http://127.0.0.1:8088/flaky")
44
+ assert status2 == 500, f"Expected 500, got {status2}"
45
+
46
+ # Request 3: Circuit is now OPEN! Should return 503
47
+ status3 = fetch("http://127.0.0.1:8088/flaky")
48
+ assert status3 == 503, f"Expected 503 Circuit Open, got {status3}"
49
+
50
+ # Make sure another route is unaffected
51
+ status_ok = fetch("http://127.0.0.1:8088/ok")
52
+ assert status_ok == 200, f"Expected 200, got {status_ok}"
53
+
54
+ print("Circuit breaker tested successfully!")
55
+ finally:
56
+ p.terminate()
57
+ p.join()
58
+
59
+if __name__ == "__main__":
60
+ test_circuit_breaker()
0
61
▼crates/justapi-py/python/justapi/test_dag.py
+33-0
1
+import asyncio
2
+import pytest
3
+from justapi import Dag, DagNode
4
+
5
+def add_prefix(data: str) -> str:
6
+ return "prefix_" + data
7
+
8
+def add_suffix(data: str) -> str:
9
+ return data + "_suffix"
10
+
11
+def combine(prefix: str, suffix: str) -> str:
12
+ return prefix + "_" + suffix
13
+
14
+@pytest.mark.asyncio
15
+async def test_dag_engine():
16
+ # Define DAG nodes
17
+ node_a = DagNode(name="prefix_task", handler=add_prefix, dependencies=["input_data"])
18
+ node_b = DagNode(name="suffix_task", handler=add_suffix, dependencies=["input_data"])
19
+ node_c = DagNode(name="combine_task", handler=combine, dependencies=["prefix_task", "suffix_task"])
20
+
21
+ # Create the DAG
22
+ dag = Dag([node_a, node_b, node_c])
23
+
24
+ # Execute it by passing initial inputs
25
+ result_state = await dag.execute({"input_data": "middle"})
26
+
27
+ # The result state should contain all intermediate results
28
+ assert result_state["prefix_task"] == "prefix_middle"
29
+ assert result_state["suffix_task"] == "middle_suffix"
30
+ assert result_state["combine_task"] == "prefix_middle_middle_suffix"
31
+
32
+if __name__ == "__main__":
33
+ asyncio.run(test_dag_engine())
0
34
▼crates/justapi-py/python/justapi/test_db_helpers.py
+159-0
1
+"""Integration tests for database test helpers — in-memory SQLite, transactions.
2
+"""
3
+import json
4
+import pytest
5
+import pytest_asyncio
6
+from justapi import JustAPIApp
7
+from justapi.testing import (
8
+ AsyncTestClient,
9
+ ManagedDb,
10
+ db_client,
11
+ assert_ok,
12
+ assert_status,
13
+ assert_json,
14
+)
15
+
16
+
17
+# ---------------------------------------------------------------------------
18
+# In-memory SQLite via test client
19
+# ---------------------------------------------------------------------------
20
+
21
+
22
+@pytest.mark.asyncio
23
+async def test_test_client_with_database():
24
+ """Use the test client with an in-memory SQLite database.
25
+
26
+ The transaction middleware auto-begins a transaction for POST,
27
+ and commits on success (2xx).
28
+ """
29
+ app = JustAPIApp()
30
+ app.set_database("sqlite://:memory:")
31
+
32
+ async def create_user(request):
33
+ return {"status": 201, "body": b'{"id":1}'}
34
+
35
+ async def get_users(request):
36
+ return {"body": b'[]'}
37
+
38
+ app.post("/users", create_user)
39
+ app.get("/users", get_users)
40
+
41
+ async with AsyncTestClient(app, database="sqlite://:memory:") as c:
42
+ resp = await c.post("/users", b'{"name":"Alice"}')
43
+ assert resp["status"] == 201
44
+
45
+ resp = await c.get("/users")
46
+ assert_ok(resp)
47
+
48
+
49
+# ---------------------------------------------------------------------------
50
+# Transaction middleware runs (auto-begin for POST)
51
+# ---------------------------------------------------------------------------
52
+
53
+
54
+@pytest.mark.asyncio
55
+async def test_transaction_middleware_runs():
56
+ """Transaction middleware auto-begins a transaction for POST and commits on 2xx."""
57
+ import tempfile, os
58
+
59
+ # Use an absolute path in a temp directory
60
+ tmpdir = tempfile.mkdtemp()
61
+ db_path = os.path.join(tmpdir, "test.db")
62
+ # Create the empty database file so sqlx can open it
63
+ open(db_path, "w").close()
64
+ try:
65
+ app = JustAPIApp()
66
+ app.set_database(f"sqlite://{db_path}")
67
+
68
+ async def create_item(request):
69
+ return {"status": 201, "body": b'{"id":1}'}
70
+
71
+ async def fail_handler(request):
72
+ return {"status": 400, "body": b'{"error":"bad"}'}
73
+
74
+ app.post("/items", create_item)
75
+ app.post("/fail", fail_handler)
76
+
77
+ async with AsyncTestClient(app, database=f"sqlite://{db_path}") as c:
78
+ resp = await c.post("/items", b'{"name":"Alice"}')
79
+ assert resp["status"] == 201
80
+
81
+ resp = await c.post("/fail", b'{}')
82
+ assert resp["status"] == 400
83
+
84
+ # Verify the file was created (pool was initialized)
85
+ assert os.path.exists(db_path)
86
+ finally:
87
+ if os.path.exists(db_path):
88
+ os.unlink(db_path)
89
+ os.rmdir(tmpdir)
90
+
91
+
92
+# ---------------------------------------------------------------------------
93
+# TestDb context manager
94
+# ---------------------------------------------------------------------------
95
+
96
+
97
+@pytest.mark.asyncio
98
+async def test_test_db_default_url():
99
+ db = ManagedDb()
100
+ async with db:
101
+ assert db.url == "sqlite://:memory:"
102
+
103
+
104
+@pytest.mark.asyncio
105
+async def test_test_db_custom_url():
106
+ async with ManagedDb(url="sqlite://:memory:") as db:
107
+ assert db.url == "sqlite://:memory:"
108
+
109
+
110
+@pytest.mark.asyncio
111
+async def test_test_db_run_sql():
112
+ async with ManagedDb() as db:
113
+ await db.run_sql(
114
+ "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT);"
115
+ "INSERT INTO items VALUES (1, 'test');"
116
+ )
117
+ assert db.url == "sqlite://:memory:"
118
+
119
+
120
+# ---------------------------------------------------------------------------
121
+# db_client helper
122
+# ---------------------------------------------------------------------------
123
+
124
+
125
+@pytest.mark.asyncio
126
+async def test_db_client_with_seed_sql():
127
+ app = JustAPIApp()
128
+ app.set_database("sqlite://:memory:")
129
+
130
+ async def list_items(request):
131
+ return {"body": b'[{"id":1,"name":"seed"}]'}
132
+
133
+ app.get("/items", list_items)
134
+
135
+ c = await db_client(
136
+ app,
137
+ seed_sql="CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT);"
138
+ )
139
+ try:
140
+ resp = await c.get("/items")
141
+ assert_ok(resp)
142
+ finally:
143
+ await c._destroy_client()
144
+
145
+
146
+# ---------------------------------------------------------------------------
147
+# Existing test client still works without database
148
+# ---------------------------------------------------------------------------
149
+
150
+
151
+@pytest.mark.asyncio
152
+async def test_async_client_no_db():
153
+ app = JustAPIApp()
154
+ app.get("/ping", lambda r: {"pong": True})
155
+
156
+ async with AsyncTestClient(app) as c:
157
+ resp = await c.get("/ping")
158
+ assert_ok(resp)
159
+ assert_json(resp, {"pong": True})
0
160
▼crates/justapi-py/python/justapi/test_dependency_injection.py
+45-0
1
+import pytest
2
+import json
3
+from justapi import JustAPIApp, Depends
4
+from justapi.testing import AsyncTestClient
5
+
6
+def common_parameters(q: str | None = None, skip: int = 0, limit: int = 100):
7
+ return {"q": q, "skip": skip, "limit": limit}
8
+
9
+async def verify_token(token: str = None):
10
+ if token != "supersecret":
11
+ raise ValueError("Invalid token")
12
+ return {"user": "admin"}
13
+
14
+@pytest.mark.asyncio
15
+async def test_dependency_injection():
16
+ app = JustAPIApp()
17
+
18
+ # Test extracting path and query params, and injecting dependencies
19
+ async def read_items(
20
+ item_id: int,
21
+ commons: dict = Depends(common_parameters),
22
+ user: dict = Depends(verify_token)
23
+ ):
24
+ return {
25
+ "item_id": item_id,
26
+ "commons": commons,
27
+ "user": user
28
+ }
29
+
30
+ app.get("/items/{item_id}", read_items)
31
+
32
+ async with AsyncTestClient(app) as client:
33
+ # Test valid request
34
+ resp = await client.get("/items/42?q=foo&skip=10&token=supersecret")
35
+ assert resp["status"] == 200, f"Expected 200, got {resp['status']}. Body: {resp.get('body', b'').decode()}"
36
+ data = json.loads(resp["body"].decode())
37
+ assert data["item_id"] == 42
38
+ assert data["commons"] == {"q": "foo", "skip": 10, "limit": 100}
39
+ assert data["user"] == {"user": "admin"}
40
+
41
+ # Test invalid token (dependency throws)
42
+ # Note: JustAPI by default catches exceptions in handlers and returns 500,
43
+ # unless custom exception handlers are added. We can assert it fails.
44
+ resp = await client.get("/items/42?q=foo&skip=10&token=bad")
45
+ assert resp["status"] == 500
0
46
▼crates/justapi-py/python/justapi/test_native.py
+97-0
1
+"""Integration test for Tier B Native API (JustAPIApp).
2
+"""
3
+import json
4
+import subprocess
5
+import sys
6
+import time
7
+import urllib.request
8
+import urllib.error
9
+
10
+SERVER_SCRIPT = r"""
11
+from justapi import JustAPIApp
12
+
13
+async def hello_handler(request):
14
+ name = request["path_params"]["name"]
15
+ return {"message": f"Hello {name}!"}
16
+
17
+async def echo_handler(request):
18
+ return {
19
+ "status": 200,
20
+ "headers": [(b"content-type", b"application/json")],
21
+ "body": request["body"],
22
+ }
23
+
24
+app = JustAPIApp()
25
+app.get("/hello/{name}", hello_handler)
26
+app.post("/echo", echo_handler)
27
+app.run("127.0.0.1:9867")
28
+"""
29
+
30
+
31
+def test_native_api():
32
+ proc = subprocess.Popen(
33
+ [sys.executable, "-c", SERVER_SCRIPT],
34
+ stdout=subprocess.PIPE,
35
+ stderr=subprocess.PIPE,
36
+ )
37
+ addr = "127.0.0.1:9867"
38
+ time.sleep(0.8)
39
+
40
+ try:
41
+ resp = urllib.request.urlopen(f"http://{addr}/hello/world")
42
+ assert resp.status == 200, f"Expected 200, got {resp.status}"
43
+ data = json.loads(resp.read())
44
+ assert data == {"message": "Hello world!"}, f"Unexpected: {data}"
45
+ print("PASS: /hello/world ->", data)
46
+
47
+ resp = urllib.request.urlopen(f"http://{addr}/hello/JustAPI")
48
+ assert resp.status == 200
49
+ data = json.loads(resp.read())
50
+ assert data == {"message": "Hello JustAPI!"}, f"Unexpected: {data}"
51
+ print("PASS: /hello/JustAPI ->", data)
52
+
53
+ try:
54
+ urllib.request.urlopen(f"http://{addr}/nonexistent")
55
+ assert False, "Expected 404"
56
+ except urllib.error.HTTPError as e:
57
+ assert e.code == 404, f"Expected 404, got {e.code}"
58
+ print("PASS: /nonexistent -> 404")
59
+
60
+ payload = b'{"hello":"world"}'
61
+ req = urllib.request.Request(
62
+ f"http://{addr}/echo",
63
+ data=payload,
64
+ headers={"Content-Type": "application/json"},
65
+ method="POST",
66
+ )
67
+ resp = urllib.request.urlopen(req)
68
+ assert resp.status == 200, f"Expected 200, got {resp.status}"
69
+ echo_body = resp.read()
70
+ assert echo_body == payload, f"Echo mismatch: {echo_body} != {payload}"
71
+ print("PASS: POST /echo -> body echoed")
72
+
73
+ try:
74
+ urllib.request.urlopen(f"http://{addr}/echo")
75
+ assert False, "Expected 404 (wrong method)"
76
+ except urllib.error.HTTPError as e:
77
+ assert e.code == 404, f"Expected 404, got {e.code}"
78
+ print("PASS: GET /echo -> 404 (wrong method)")
79
+
80
+ print()
81
+ print("=== All tests PASSED ===")
82
+
83
+ finally:
84
+ proc.terminate()
85
+ try:
86
+ stdout, stderr = proc.communicate(timeout=5)
87
+ if stderr:
88
+ decoded = stderr.decode()
89
+ if decoded.strip():
90
+ print("Server stderr:", decoded)
91
+ except subprocess.TimeoutExpired:
92
+ proc.kill()
93
+ proc.communicate()
94
+
95
+
96
+if __name__ == "__main__":
97
+ test_native_api()
0
98
▼crates/justapi-py/python/justapi/test_observability.py
+97-0
1
+"""Integration test for Phase 14 observability features.
2
+
3
+Tests verify the framework can start and serve requests. Enhanced metrics
4
+and health checks are tested at the Rust level via unit tests.
5
+"""
6
+import json
7
+import subprocess
8
+import sys
9
+import time
10
+import urllib.request
11
+import urllib.error
12
+
13
+SERVER_SCRIPT = r"""
14
+from justapi import JustAPIApp
15
+
16
+async def hello_handler(request):
17
+ name = request["path_params"]["name"]
18
+ return {"message": f"Hello {name}!"}
19
+
20
+async def echo_handler(request):
21
+ return {
22
+ "status": 200,
23
+ "headers": [(b"content-type", b"application/json")],
24
+ "body": request["body"],
25
+ }
26
+
27
+app = JustAPIApp()
28
+app.get("/hello/{name}", hello_handler)
29
+app.post("/echo", echo_handler)
30
+app.run("127.0.0.1:9877")
31
+"""
32
+
33
+
34
+def test_server_starts_and_serves_requests():
35
+ proc = subprocess.Popen(
36
+ [sys.executable, "-c", SERVER_SCRIPT],
37
+ stdout=subprocess.PIPE,
38
+ stderr=subprocess.PIPE,
39
+ )
40
+ addr = "127.0.0.1:9877"
41
+ time.sleep(0.8)
42
+
43
+ try:
44
+ resp = urllib.request.urlopen(f"http://{addr}/hello/world")
45
+ assert resp.status == 200
46
+ data = json.loads(resp.read())
47
+ assert data == {"message": "Hello world!"}
48
+ print("PASS: /hello/world")
49
+
50
+ payload = b'{"hello":"world"}'
51
+ req = urllib.request.Request(
52
+ f"http://{addr}/echo",
53
+ data=payload,
54
+ headers={"Content-Type": "application/json"},
55
+ method="POST",
56
+ )
57
+ resp = urllib.request.urlopen(req)
58
+ assert resp.status == 200
59
+ assert resp.read() == payload
60
+ print("PASS: POST /echo")
61
+
62
+ try:
63
+ urllib.request.urlopen(f"http://{addr}/nonexistent")
64
+ assert False, "Expected 404"
65
+ except urllib.error.HTTPError as e:
66
+ assert e.code == 404
67
+ print("PASS: /nonexistent -> 404")
68
+
69
+ finally:
70
+ proc.terminate()
71
+ proc.wait()
72
+
73
+
74
+def test_justapi_test_client_works():
75
+ """Verify JustAPITestClient works for testing without a running server."""
76
+ from justapi import JustAPIApp, JustAPITestClient
77
+
78
+ app = JustAPIApp()
79
+
80
+ async def hello_handler(request):
81
+ name = request["path_params"]["name"]
82
+ return {"message": f"Hello {name}!"}
83
+
84
+ app.get("/hello/{name}", hello_handler)
85
+ client = JustAPITestClient(app)
86
+
87
+ resp = client.get("/hello/Python")
88
+ assert resp["status"] == 200
89
+ data = json.loads(bytes(resp["body"]))
90
+ assert data == {"message": "Hello Python!"}
91
+ print("PASS: JustAPITestClient works")
92
+
93
+
94
+if __name__ == "__main__":
95
+ test_server_starts_and_serves_requests()
96
+ test_justapi_test_client_works()
97
+ print("All tests PASSED")
0
98
▼crates/justapi-py/python/justapi/test_plugin.py
+66-0
1
+import json
2
+import asyncio
3
+import threading
4
+from justapi import JustAPIApp
5
+
6
+class MyPlugin:
7
+ def __init__(self):
8
+ self.built = False
9
+ self.started = False
10
+ self.shutdown = False
11
+
12
+ def build(self, app: JustAPIApp):
13
+ self.built = True
14
+ app.get("/from-plugin", self.handler)
15
+
16
+ def on_startup(self):
17
+ self.started = True
18
+
19
+ async def on_shutdown(self):
20
+ self.shutdown = True
21
+
22
+ def handler(self, request):
23
+ return {"message": "hello from plugin"}
24
+
25
+def test_plugin_registration():
26
+ app = JustAPIApp()
27
+ plugin = MyPlugin()
28
+ app.use(plugin)
29
+
30
+ assert plugin.built is True
31
+ # The plugin was appended to app's plugins list.
32
+
33
+ # We can't easily test on_startup/on_shutdown without running the server,
34
+ # but we can test if the route was registered correctly using the TestClient.
35
+ from justapi import JustAPITestClient
36
+ client = JustAPITestClient(app)
37
+
38
+ resp = client.get("/from-plugin")
39
+ assert resp["status"] == 200
40
+ assert json.loads(bytes(resp["body"])) == {"message": "hello from plugin"}
41
+
42
+def test_plugin_hooks_in_real_server():
43
+ app = JustAPIApp()
44
+ plugin = MyPlugin()
45
+ app.use(plugin)
46
+
47
+ # Run the server in a thread
48
+ def run_server():
49
+ try:
50
+ app.run("127.0.0.1:0")
51
+ except Exception:
52
+ pass
53
+
54
+ server_thread = threading.Thread(target=run_server, daemon=True)
55
+ server_thread.start()
56
+
57
+ # Give it a tiny bit of time to start
58
+ import time
59
+ time.sleep(0.5)
60
+
61
+ assert plugin.started is True
62
+ assert plugin.shutdown is False
63
+
64
+ # We can't cleanly test shutdown hook here because shutting down
65
+ # requires a ctrl-c signal or something that our test runner might not
66
+ # want to handle gracefully for a daemon thread, but we tested startup!
0
67
▼crates/justapi-py/python/justapi/test_rate_limit.py
+30-0
1
+import pytest
2
+import asyncio
3
+from justapi import RateLimiter
4
+
5
+@pytest.mark.asyncio
6
+async def test_rate_limiter_redis():
7
+ # Requires a running Redis instance on localhost:6379
8
+ # Run with: pytest python/justapi/test_rate_limit.py
9
+ # NOTE: if redis is not running, this might fail with connection error
10
+ try:
11
+ limiter = await RateLimiter.new_redis("redis://127.0.0.1:6379/")
12
+ except Exception as e:
13
+ pytest.skip(f"Redis not available: {e}")
14
+
15
+ # capacity=5, replenish_rate=5 (5 tokens per second)
16
+ # The first 5 requests should pass immediately
17
+ for i in range(5):
18
+ res = await limiter.check_limit("test_user_ip", 5, 5, 1)
19
+ assert res.allowed is True, f"Request {i} should be allowed"
20
+ assert res.retry_after_ms == 0
21
+
22
+ # 6th request should fail
23
+ res = await limiter.check_limit("test_user_ip", 5, 5, 1)
24
+ assert res.allowed is False, "Request 6 should be rejected"
25
+ assert res.retry_after_ms > 0
26
+
27
+ # Wait for 1 token to replenish (0.2 seconds)
28
+ await asyncio.sleep(0.3)
29
+ res = await limiter.check_limit("test_user_ip", 5, 5, 1)
30
+ assert res.allowed is True, "Request should be allowed after wait"
0
31
▼crates/justapi-py/python/justapi/test_request_coalescing.py
+59-0
1
+"""End-to-end tests for request coalescing (singleflight).
2
+
3
+These exercise the Python ``enable_request_coalescing`` API through the
4
+in-process test client, which applies the same Rust middleware chain as the
5
+real server.
6
+"""
7
+
8
+import asyncio
9
+import threading
10
+import time
11
+
12
+import pytest
13
+import pytest_asyncio
14
+
15
+from justapi import JustAPIApp
16
+from justapi.testing import AsyncTestClient, assert_ok, assert_json
17
+
18
+
19
+@pytest_asyncio.fixture
20
+async def coalesced_app():
21
+ app = JustAPIApp()
22
+ app.enable_request_coalescing()
23
+
24
+ state = {"calls": 0}
25
+ lock = threading.Lock()
26
+
27
+ def handler(_request):
28
+ with lock:
29
+ state["calls"] += 1
30
+ # Simulate an expensive read so concurrent requests overlap in time,
31
+ # making coalescing observable.
32
+ time.sleep(0.2)
33
+ return {"hello": "world"}
34
+
35
+ app.get("/hot", handler)
36
+ app._state = state
37
+ return app
38
+
39
+
40
+@pytest.mark.asyncio
41
+async def test_concurrent_requests_coalesce(coalesced_app):
42
+ async with AsyncTestClient(coalesced_app) as client:
43
+ results = await asyncio.gather(*[client.get("/hot") for _ in range(10)])
44
+
45
+ for resp in results:
46
+ assert_ok(resp)
47
+ assert_json(resp, {"hello": "world"})
48
+
49
+ # Despite ten concurrent requests, the handler ran exactly once.
50
+ assert coalesced_app._state["calls"] == 1
51
+
52
+
53
+@pytest.mark.asyncio
54
+async def test_distinct_paths_not_coalesced(coalesced_app):
55
+ async with AsyncTestClient(coalesced_app) as client:
56
+ a, b = await asyncio.gather(client.get("/hot"), client.get("/missing"))
57
+ assert a["status"] == 200
58
+ assert b["status"] == 404
59
+ assert coalesced_app._state["calls"] == 1
0
60
▼crates/justapi-py/python/justapi/test_schema_validation.py
+225-0
1
+"""Integration test for Phase 11 — JSON Schema validation (Schema class + Pydantic bridge).
2
+"""
3
+import json
4
+import subprocess
5
+import sys
6
+import time
7
+import urllib.request
8
+import urllib.error
9
+
10
+SERVER_SCRIPT = r'''
11
+from justapi import JustAPIApp, Schema
12
+import json
13
+
14
+class UserSchema(Schema):
15
+ name: str
16
+ email: str
17
+ age: int | None = None
18
+
19
+async def create_user(request):
20
+ body = json.loads(request["body"])
21
+ return {
22
+ "status": 201,
23
+ "body": json.dumps({"id": 1, **body}).encode("utf-8"),
24
+ "headers": [(b"content-type", b"application/json")],
25
+ }
26
+
27
+async def hello_handler(request):
28
+ name = request["path_params"]["name"]
29
+ return {"message": f"Hello {name}!"}
30
+
31
+app = JustAPIApp()
32
+
33
+app.get("/hello/{name}", hello_handler)
34
+app.post("/users", create_user, schema=UserSchema)
35
+
36
+# Pydantic bridge test
37
+from pydantic import BaseModel
38
+class ProductModel(BaseModel):
39
+ name: str
40
+ price: float
41
+
42
+async def create_product(request):
43
+ body = json.loads(request["body"])
44
+ return {"status": 201, "body": json.dumps({"sku": 42, **body}).encode(), "headers": [(b"content-type", b"application/json")]}
45
+
46
+app.post("/products", create_product, schema=ProductModel)
47
+
48
+# Raw JSON Schema string test
49
+raw_schema = json.dumps({
50
+ "type": "object",
51
+ "properties": {
52
+ "title": {"type": "string"},
53
+ "year": {"type": "integer"}
54
+ },
55
+ "required": ["title"],
56
+ "additionalProperties": False
57
+})
58
+
59
+async def create_movie(request):
60
+ body = json.loads(request["body"])
61
+ return {"status": 201, "body": json.dumps({"id": 7, **body}).encode(), "headers": [(b"content-type", b"application/json")]}
62
+
63
+app.post("/movies", create_movie, schema=raw_schema)
64
+
65
+app.run("127.0.0.1:9869")
66
+'''
67
+
68
+
69
+def test_schema_validation():
70
+ proc = subprocess.Popen(
71
+ [sys.executable, "-c", SERVER_SCRIPT],
72
+ stdout=subprocess.PIPE,
73
+ stderr=subprocess.PIPE,
74
+ )
75
+ addr = "127.0.0.1:9869"
76
+ time.sleep(0.8)
77
+
78
+ try:
79
+ # 1. Basic route still works
80
+ resp = urllib.request.urlopen(f"http://{addr}/hello/world")
81
+ assert resp.status == 200, f"Expected 200, got {resp.status}"
82
+ data = json.loads(resp.read())
83
+ assert data == {"message": "Hello world!"}
84
+ print("PASS: GET /hello/world -> 200")
85
+
86
+ # 2. Schema class: valid POST
87
+ payload = json.dumps({"name": "Alice", "email": "[email protected]", "age": 30}).encode()
88
+ req = urllib.request.Request(
89
+ f"http://{addr}/users",
90
+ data=payload,
91
+ headers={"Content-Type": "application/json"},
92
+ method="POST",
93
+ )
94
+ resp = urllib.request.urlopen(req)
95
+ assert resp.status == 201, f"Expected 201, got {resp.status}"
96
+ data = json.loads(resp.read())
97
+ assert data["name"] == "Alice"
98
+ assert data["email"] == "[email protected]"
99
+ assert data["age"] == 30
100
+ print("PASS: Schema class valid POST -> 201")
101
+
102
+ # 3. Schema class: missing required field
103
+ payload = json.dumps({"name": "Alice"}).encode()
104
+ req = urllib.request.Request(
105
+ f"http://{addr}/users",
106
+ data=payload,
107
+ headers={"Content-Type": "application/json"},
108
+ method="POST",
109
+ )
110
+ try:
111
+ urllib.request.urlopen(req)
112
+ assert False, "Expected 422"
113
+ except urllib.error.HTTPError as e:
114
+ assert e.code == 422, f"Expected 422, got {e.code}"
115
+ error_data = json.loads(e.read())
116
+ assert error_data["title"] == "Validation Error"
117
+ assert any("email" in err.get("message", "") or "email" in err.get("field", "") for err in error_data["errors"])
118
+ print("PASS: Schema class missing field -> 422")
119
+
120
+ # 4. Schema class: wrong type
121
+ payload = json.dumps({"name": "Alice", "email": "[email protected]", "age": "not a number"}).encode()
122
+ req = urllib.request.Request(
123
+ f"http://{addr}/users",
124
+ data=payload,
125
+ headers={"Content-Type": "application/json"},
126
+ method="POST",
127
+ )
128
+ try:
129
+ urllib.request.urlopen(req)
130
+ assert False, "Expected 422"
131
+ except urllib.error.HTTPError as e:
132
+ assert e.code == 422
133
+ print("PASS: Schema class wrong type -> 422")
134
+
135
+ # 5. Pydantic bridge: valid POST
136
+ payload = json.dumps({"name": "Widget", "price": 9.99}).encode()
137
+ req = urllib.request.Request(
138
+ f"http://{addr}/products",
139
+ data=payload,
140
+ headers={"Content-Type": "application/json"},
141
+ method="POST",
142
+ )
143
+ resp = urllib.request.urlopen(req)
144
+ assert resp.status == 201
145
+ data = json.loads(resp.read())
146
+ assert data["name"] == "Widget"
147
+ assert data["price"] == 9.99
148
+ print("PASS: Pydantic bridge valid POST -> 201")
149
+
150
+ # 6. Pydantic bridge: missing required field
151
+ payload = json.dumps({"name": "Widget"}).encode()
152
+ req = urllib.request.Request(
153
+ f"http://{addr}/products",
154
+ data=payload,
155
+ headers={"Content-Type": "application/json"},
156
+ method="POST",
157
+ )
158
+ try:
159
+ urllib.request.urlopen(req)
160
+ assert False, "Expected 422"
161
+ except urllib.error.HTTPError as e:
162
+ assert e.code == 422
163
+ print("PASS: Pydantic bridge missing field -> 422")
164
+
165
+ # 7. Raw JSON Schema string: valid POST
166
+ payload = json.dumps({"title": "Inception", "year": 2010}).encode()
167
+ req = urllib.request.Request(
168
+ f"http://{addr}/movies",
169
+ data=payload,
170
+ headers={"Content-Type": "application/json"},
171
+ method="POST",
172
+ )
173
+ resp = urllib.request.urlopen(req)
174
+ assert resp.status == 201
175
+ print("PASS: Raw JSON Schema valid POST -> 201")
176
+
177
+ # 8. Raw JSON Schema string: missing required field
178
+ payload = json.dumps({"year": 2010}).encode()
179
+ req = urllib.request.Request(
180
+ f"http://{addr}/movies",
181
+ data=payload,
182
+ headers={"Content-Type": "application/json"},
183
+ method="POST",
184
+ )
185
+ try:
186
+ urllib.request.urlopen(req)
187
+ assert False, "Expected 422"
188
+ except urllib.error.HTTPError as e:
189
+ assert e.code == 422
190
+ print("PASS: Raw JSON Schema missing field -> 422")
191
+
192
+ # 9. Invalid JSON body
193
+ req = urllib.request.Request(
194
+ f"http://{addr}/users",
195
+ data=b"not json",
196
+ headers={"Content-Type": "application/json"},
197
+ method="POST",
198
+ )
199
+ try:
200
+ urllib.request.urlopen(req)
201
+ assert False, "Expected 422"
202
+ except urllib.error.HTTPError as e:
203
+ assert e.code == 422
204
+ error_data = json.loads(e.read())
205
+ assert "Invalid JSON" in error_data["detail"]
206
+ print("PASS: Invalid JSON body -> 422")
207
+
208
+ print()
209
+ print("=== All schema validation tests PASSED ===")
210
+
211
+ finally:
212
+ proc.terminate()
213
+ try:
214
+ stdout, stderr = proc.communicate(timeout=5)
215
+ if stderr:
216
+ decoded = stderr.decode()
217
+ if decoded.strip():
218
+ print("Server stderr:", decoded)
219
+ except subprocess.TimeoutExpired:
220
+ proc.kill()
221
+ proc.communicate()
222
+
223
+
224
+if __name__ == "__main__":
225
+ test_schema_validation()
0
226
▼crates/justapi-py/python/justapi/test_snapshot.py
+152-0
1
+"""Tests for snapshot testing support.
2
+"""
3
+import json
4
+import os
5
+import tempfile
6
+
7
+import pytest
8
+import pytest_asyncio
9
+from justapi import JustAPIApp
10
+from justapi.testing import Snapshot, AsyncTestClient, assert_ok
11
+
12
+
13
+# ---------------------------------------------------------------------------
14
+# Snapshot basics
15
+# ---------------------------------------------------------------------------
16
+
17
+
18
+def test_snapshot_assert_match_creates_file():
19
+ with tempfile.TemporaryDirectory() as tmpdir:
20
+ snap = Snapshot(snap_dir=tmpdir)
21
+ snap.assert_match({"hello": "world"}, "test_match")
22
+
23
+ snap_file = os.path.join(tmpdir, "test_match.snap")
24
+ assert os.path.exists(snap_file)
25
+ data = json.loads(open(snap_file).read())
26
+ assert data == {"hello": "world"}
27
+
28
+
29
+def test_snapshot_assert_match_passes_on_match():
30
+ with tempfile.TemporaryDirectory() as tmpdir:
31
+ snap = Snapshot(snap_dir=tmpdir)
32
+ snap.assert_match({"a": 1}, "test_pass")
33
+ # Second call should pass (same value)
34
+ snap.assert_match({"a": 1}, "test_pass")
35
+
36
+
37
+def test_snapshot_assert_match_fails_on_mismatch():
38
+ with tempfile.TemporaryDirectory() as tmpdir:
39
+ snap = Snapshot(snap_dir=tmpdir)
40
+ snap.assert_match({"a": 1}, "test_fail")
41
+
42
+ with pytest.raises(AssertionError, match="Snapshot mismatch"):
43
+ snap.assert_match({"a": 2}, "test_fail")
44
+
45
+
46
+def test_snapshot_auto_name():
47
+ """When name is omitted, the calling test function name is used."""
48
+ with tempfile.TemporaryDirectory() as tmpdir:
49
+ snap = Snapshot(snap_dir=tmpdir)
50
+ snap.assert_match("value") # uses test_snapshot_auto_name as name
51
+
52
+ snap_file = os.path.join(tmpdir, "test_snapshot_auto_name.snap")
53
+ assert os.path.exists(snap_file)
54
+
55
+
56
+def test_snapshot_update_via_env(monkeypatch):
57
+ """SNAPSHOT_UPDATE=1 accepts any value without comparison."""
58
+ monkeypatch.setenv("SNAPSHOT_UPDATE", "1")
59
+ with tempfile.TemporaryDirectory() as tmpdir:
60
+ snap = Snapshot(snap_dir=tmpdir)
61
+ snap.assert_match({"v1": "old"}, "test_update_env")
62
+ # Change the value and assert again — should pass because update is on
63
+ snap.assert_match({"v2": "new"}, "test_update_env")
64
+ # Verify the stored value is now the new one
65
+ stored = json.loads(open(os.path.join(tmpdir, "test_update_env.snap")).read())
66
+ assert stored == {"v2": "new"}
67
+
68
+
69
+# ---------------------------------------------------------------------------
70
+# Snapshot with response dicts
71
+# ---------------------------------------------------------------------------
72
+
73
+
74
+def test_snapshot_assert_response():
75
+ with tempfile.TemporaryDirectory() as tmpdir:
76
+ snap = Snapshot(snap_dir=tmpdir)
77
+ resp = {
78
+ "status": 200,
79
+ "headers": {"content-type": "application/json"},
80
+ "body": b'{"ok":true}',
81
+ }
82
+ snap.assert_response(resp, "test_resp")
83
+ snap.assert_response(resp, "test_resp") # same value passes
84
+
85
+ snap.assert_response(resp, "test_resp2")
86
+
87
+
88
+def test_snapshot_assert_response_fails_on_mismatch():
89
+ with tempfile.TemporaryDirectory() as tmpdir:
90
+ snap = Snapshot(snap_dir=tmpdir)
91
+ snap.assert_response(
92
+ {"status": 200, "headers": {}, "body": b'{"a":1}'}, "mismatch"
93
+ )
94
+
95
+ with pytest.raises(AssertionError):
96
+ snap.assert_response(
97
+ {"status": 200, "headers": {}, "body": b'{"a":2}'}, "mismatch"
98
+ )
99
+
100
+
101
+def test_snapshot_assert_body():
102
+ with tempfile.TemporaryDirectory() as tmpdir:
103
+ snap = Snapshot(snap_dir=tmpdir)
104
+ snap.assert_body({"body": b"binary data \x00\xff"}, "body_test")
105
+ snap.assert_body({"body": b"binary data \x00\xff"}, "body_test")
106
+
107
+
108
+# ---------------------------------------------------------------------------
109
+# Integration with AsyncTestClient
110
+# ---------------------------------------------------------------------------
111
+
112
+
113
+@pytest.mark.asyncio
114
+async def test_snapshot_with_test_client():
115
+ app = JustAPIApp()
116
+ app.get("/hello/{name}", lambda name: {"message": f"Hello {name}!"})
117
+ app.post("/echo", lambda body: {"body": body})
118
+
119
+ import tempfile
120
+
121
+ with tempfile.TemporaryDirectory() as tmpdir:
122
+ snap = Snapshot(snap_dir=tmpdir)
123
+
124
+ async with AsyncTestClient(app) as c:
125
+ resp = await c.get("/hello/world")
126
+ snap.assert_response(resp, "hello_world")
127
+
128
+ resp = await c.post("/echo", b'{"x":1}')
129
+ snap.assert_response(resp, "echo_body")
130
+
131
+ snap_file = os.path.join(tmpdir, "hello_world.snap")
132
+ assert os.path.exists(snap_file)
133
+ data = json.loads(open(snap_file).read())
134
+ assert data["status"] == 200
135
+ import base64
136
+ body = json.loads(base64.b64decode(data["body_base64"]))
137
+ assert body == {"message": "Hello world!"}
138
+
139
+
140
+@pytest.mark.asyncio
141
+async def test_snapshot_default_dir():
142
+ """When no snap_dir is given, snapshots go to __snapshots__/ next to test file."""
143
+ snap = Snapshot()
144
+ async with AsyncTestClient(JustAPIApp()) as c:
145
+ pass # just testing setup, not actual request
146
+ snap.assert_match("test", "test_snapshot_default_dir")
147
+ expected = os.path.join(
148
+ os.path.dirname(__file__), "__snapshots__", "test_snapshot_default_dir.snap"
149
+ )
150
+ assert os.path.exists(expected)
151
+ os.unlink(expected)
152
+ os.rmdir(os.path.join(os.path.dirname(__file__), "__snapshots__"))
0
153
▼crates/justapi-py/python/justapi/test_sse.py
+25-0
1
+import asyncio
2
+import pytest
3
+from justapi import JustAPIApp, TokenStreamResponse, JustAPITestClient
4
+
5
+@pytest.mark.asyncio
6
+async def test_sse_streaming():
7
+ app = JustAPIApp()
8
+
9
+ @app.get("/stream")
10
+ async def stream():
11
+ async def gen():
12
+ for i in range(5):
13
+ yield f"data: {i}\n\n"
14
+ # tiny sleep to yield to event loop
15
+ await asyncio.sleep(0.01)
16
+ return TokenStreamResponse(gen())
17
+
18
+ client = JustAPITestClient(app)
19
+ response = client.get("/stream")
20
+ assert response["status"] == 200
21
+
22
+ body = bytes(response["body"]).decode("utf-8")
23
+ print("Response body:", repr(body))
24
+ for i in range(5):
25
+ assert f"data: {i}\n\n" in body
0
26
▼crates/justapi-py/python/justapi/test_templating.py
+24-0
1
+import pytest
2
+import os
3
+from justapi import Jinja2Templates, JustAPIApp, JustAPITestClient
4
+
5
+def test_jinja2_templates(tmp_path):
6
+ templates_dir = tmp_path / "templates"
7
+ templates_dir.mkdir()
8
+
9
+ index_html = templates_dir / "index.html"
10
+ index_html.write_text("Hello {{ name }}!")
11
+
12
+ app = JustAPIApp()
13
+ templates = Jinja2Templates(directory=str(templates_dir))
14
+
15
+ @app.get("/hello")
16
+ def hello(request):
17
+ return templates.TemplateResponse("index.html", {"name": "World"})
18
+
19
+ client = JustAPITestClient(app)
20
+ response = client.get("/hello")
21
+ assert response["status"] == 200
22
+ assert response["body"] == b"Hello World!"
23
+ # Check headers
24
+ assert response["headers"]["content-type"] == "text/html; charset=utf-8"
0
25
▼crates/justapi-py/python/justapi/test_test_client.py
+141-0
1
+"""Integration test for JustAPITestClient — the no-TCP test client.
2
+"""
3
+import json
4
+
5
+from justapi import JustAPIApp, JustAPITestClient
6
+
7
+
8
+def test_test_client_get():
9
+ app = JustAPIApp()
10
+
11
+ async def hello_handler(request):
12
+ name = request["path_params"]["name"]
13
+ return {"message": f"Hello {name}!"}
14
+
15
+ app.get("/hello/{name}", hello_handler)
16
+ client = JustAPITestClient(app)
17
+
18
+ resp = client.get("/hello/world")
19
+ assert resp["status"] == 200
20
+ data = json.loads(bytes(resp["body"]))
21
+ assert data == {"message": "Hello world!"}
22
+
23
+ resp = client.get("/hello/JustAPI")
24
+ assert resp["status"] == 200
25
+ data = json.loads(bytes(resp["body"]))
26
+ assert data == {"message": "Hello JustAPI!"}
27
+
28
+
29
+def test_test_client_post():
30
+ app = JustAPIApp()
31
+
32
+ async def echo_handler(request):
33
+ return {
34
+ "status": 200,
35
+ "headers": [(b"content-type", b"application/json")],
36
+ "body": request["body"],
37
+ }
38
+
39
+ app.post("/echo", echo_handler)
40
+ client = JustAPITestClient(app)
41
+
42
+ payload = json.dumps({"hello": "world"}).encode()
43
+ resp = client.post("/echo", payload)
44
+ assert resp["status"] == 200
45
+ assert bytes(resp["body"]) == payload
46
+
47
+
48
+def test_test_client_put():
49
+ app = JustAPIApp()
50
+
51
+ async def echo_handler(request):
52
+ return {
53
+ "status": 200,
54
+ "headers": [(b"content-type", b"application/json")],
55
+ "body": request["body"],
56
+ }
57
+
58
+ app.put("/echo", echo_handler)
59
+ client = JustAPITestClient(app)
60
+
61
+ payload = json.dumps({"updated": True}).encode()
62
+ resp = client.put("/echo", payload)
63
+ assert resp["status"] == 200
64
+ assert bytes(resp["body"]) == payload
65
+
66
+
67
+def test_test_client_delete():
68
+ app = JustAPIApp()
69
+
70
+ async def delete_handler(request):
71
+ return {"status": 204}
72
+
73
+ app.delete("/resource/{id}", delete_handler)
74
+ client = JustAPITestClient(app)
75
+
76
+ resp = client.delete("/resource/42")
77
+ assert resp["status"] == 204
78
+
79
+
80
+def test_test_client_404():
81
+ app = JustAPIApp()
82
+
83
+ async def hello_handler(request):
84
+ return {"message": "hello"}
85
+
86
+ app.get("/hello", hello_handler)
87
+ client = JustAPITestClient(app)
88
+
89
+ resp = client.get("/nonexistent")
90
+ assert resp["status"] == 404
91
+
92
+
93
+def test_test_client_wrong_method():
94
+ app = JustAPIApp()
95
+
96
+ async def echo_handler(request):
97
+ return {"body": request["body"]}
98
+
99
+ app.post("/echo", echo_handler)
100
+ client = JustAPITestClient(app)
101
+
102
+ resp = client.get("/echo")
103
+ assert resp["status"] == 404
104
+
105
+
106
+def test_test_client_schema_validation():
107
+ """Rust-side JSON Schema validation works through the test client."""
108
+ from justapi import Schema
109
+
110
+ class UserSchema(Schema):
111
+ name: str
112
+ email: str
113
+ age: int | None = None
114
+
115
+ app = JustAPIApp()
116
+
117
+ async def create_user(request):
118
+ body = json.loads(request["body"])
119
+ return {
120
+ "status": 201,
121
+ "body": json.dumps({"id": 1, **body}).encode(),
122
+ "headers": [(b"content-type", b"application/json")],
123
+ }
124
+
125
+ app.post("/users", create_user, schema=UserSchema)
126
+ client = JustAPITestClient(app)
127
+
128
+ # Valid payload
129
+ payload = json.dumps({"name": "Alice", "email": "[email protected]", "age": 30}).encode()
130
+ resp = client.post("/users", payload)
131
+ assert resp["status"] == 201
132
+ data = json.loads(bytes(resp["body"]))
133
+ assert data["id"] == 1
134
+ assert data["name"] == "Alice"
135
+
136
+ # Missing required field
137
+ payload = json.dumps({"name": "Alice"}).encode()
138
+ resp = client.post("/users", payload)
139
+ assert resp["status"] == 422
140
+ error_data = json.loads(bytes(resp["body"]))
141
+ assert error_data["title"] == "Validation Error"
0
142
▼crates/justapi-py/python/justapi/test_testing.py
+220-0
1
+"""Integration tests for justapi.testing — AsyncTestClient and assertion helpers.
2
+"""
3
+import json
4
+import pytest
5
+import pytest_asyncio
6
+from justapi import JustAPIApp, Schema
7
+from justapi.testing import (
8
+ AsyncTestClient,
9
+ assert_ok,
10
+ assert_status,
11
+ assert_json,
12
+ assert_header,
13
+ transaction_test_db,
14
+)
15
+
16
+
17
+# ---------------------------------------------------------------------------
18
+# Fixtures
19
+# ---------------------------------------------------------------------------
20
+
21
+@pytest_asyncio.fixture
22
+async def app():
23
+ app = JustAPIApp()
24
+
25
+ async def hello_handler(request):
26
+ name = request["path_params"]["name"]
27
+ return {"message": f"Hello {name}!"}
28
+
29
+ async def echo_handler(request):
30
+ return {
31
+ "status": 200,
32
+ "headers": [(b"content-type", b"application/json")],
33
+ "body": request["body"],
34
+ }
35
+
36
+ async def created_handler(request):
37
+ return {"status": 201, "body": b'{"id":1}'}
38
+
39
+ async def delete_handler(request):
40
+ return {"status": 204}
41
+
42
+ app.get("/hello/{name}", hello_handler)
43
+ app.post("/echo", echo_handler)
44
+ app.put("/echo", echo_handler)
45
+ app.delete("/resource/{id}", delete_handler)
46
+
47
+ return app
48
+
49
+
50
+@pytest_asyncio.fixture
51
+async def client(app):
52
+ async with AsyncTestClient(app) as c:
53
+ yield c
54
+
55
+
56
+# ---------------------------------------------------------------------------
57
+# AsyncTestClient
58
+# ---------------------------------------------------------------------------
59
+
60
+
61
+@pytest.mark.asyncio
62
+async def test_async_get(client):
63
+ resp = await client.get("/hello/world")
64
+ assert_ok(resp)
65
+ assert_json(resp, {"message": "Hello world!"})
66
+
67
+
68
+@pytest.mark.asyncio
69
+async def test_async_get_path_params(client):
70
+ resp = await client.get("/hello/JustAPI")
71
+ assert_ok(resp)
72
+ data = json.loads(bytes(resp["body"]))
73
+ assert data == {"message": "Hello JustAPI!"}
74
+
75
+
76
+@pytest.mark.asyncio
77
+async def test_async_post(client):
78
+ payload = json.dumps({"hello": "world"}).encode()
79
+ resp = await client.post("/echo", payload)
80
+ assert_ok(resp)
81
+ assert bytes(resp["body"]) == payload
82
+
83
+
84
+@pytest.mark.asyncio
85
+async def test_async_put(client):
86
+ payload = json.dumps({"updated": True}).encode()
87
+ resp = await client.put("/echo", payload)
88
+ assert resp["status"] == 200
89
+ assert bytes(resp["body"]) == payload
90
+
91
+
92
+@pytest.mark.asyncio
93
+async def test_async_delete(client):
94
+ resp = await client.delete("/resource/42")
95
+ assert resp["status"] == 204
96
+
97
+
98
+@pytest.mark.asyncio
99
+async def test_async_404(client):
100
+ resp = await client.get("/nonexistent")
101
+ assert resp["status"] == 404
102
+
103
+
104
+@pytest.mark.asyncio
105
+async def test_async_wrong_method(client):
106
+ resp = await client.get("/echo")
107
+ assert resp["status"] == 404
108
+
109
+
110
+# ---------------------------------------------------------------------------
111
+# Assertion helpers
112
+# ---------------------------------------------------------------------------
113
+
114
+
115
+@pytest.mark.asyncio
116
+async def test_assert_status(client):
117
+ resp = await client.get("/hello/world")
118
+ assert_ok(resp)
119
+ resp = await client.delete("/resource/1")
120
+ assert_status(resp, 204)
121
+
122
+
123
+@pytest.mark.asyncio
124
+async def test_assert_json(client):
125
+ resp = await client.get("/hello/test")
126
+ assert_json(resp, {"message": "Hello test!"})
127
+
128
+
129
+@pytest.mark.asyncio
130
+async def test_assert_header(client):
131
+ resp = await client.get("/hello/world")
132
+ assert_header(resp, "content-type", "application/json")
133
+ # Test existence-only check
134
+ assert_header(resp, "content-type")
135
+
136
+
137
+@pytest.mark.asyncio
138
+async def test_assert_ok_passes():
139
+ assert_ok({"status": 200, "body": b"ok"})
140
+
141
+
142
+@pytest.mark.asyncio
143
+async def test_assert_status_passes():
144
+ assert_status({"status": 201, "body": b'{"id":1}'}, 201)
145
+
146
+
147
+# ---------------------------------------------------------------------------
148
+# Setup / teardown hooks
149
+# ---------------------------------------------------------------------------
150
+
151
+
152
+@pytest.mark.asyncio
153
+async def test_setup_teardown_hooks():
154
+ app = JustAPIApp()
155
+ app.get("/ping", lambda r: {"pong": True})
156
+
157
+ events = []
158
+
159
+ async with AsyncTestClient(app).on_setup(
160
+ lambda c: events.append("setup")
161
+ ).on_teardown(
162
+ lambda c: events.append("teardown")
163
+ ) as c:
164
+ resp = await c.get("/ping")
165
+ assert_ok(resp)
166
+
167
+ assert events == ["setup", "teardown"]
168
+
169
+
170
+# ---------------------------------------------------------------------------
171
+# Schema validation through async client
172
+# ---------------------------------------------------------------------------
173
+
174
+
175
+@pytest.mark.asyncio
176
+async def test_async_schema_validation():
177
+ class UserSchema(Schema):
178
+ name: str
179
+ email: str
180
+ age: int | None = None
181
+
182
+ app = JustAPIApp()
183
+
184
+ async def create_user(request):
185
+ body = json.loads(request["body"])
186
+ return {
187
+ "status": 201,
188
+ "body": json.dumps({"id": 1, **body}).encode(),
189
+ "headers": [(b"content-type", b"application/json")],
190
+ }
191
+
192
+ app.post("/users", create_user, schema=UserSchema)
193
+
194
+ async with AsyncTestClient(app) as c:
195
+ # Valid payload
196
+ payload = json.dumps({
197
+ "name": "Alice", "email": "[email protected]", "age": 30
198
+ }).encode()
199
+ resp = await c.post("/users", payload)
200
+ assert resp["status"] == 201
201
+
202
+ # Missing required field
203
+ payload = json.dumps({"name": "Alice"}).encode()
204
+ resp = await c.post("/users", payload)
205
+ assert resp["status"] == 422
206
+
207
+
208
+# ---------------------------------------------------------------------------
209
+# transaction_test_db
210
+# ---------------------------------------------------------------------------
211
+
212
+
213
+def test_transaction_test_db_default():
214
+ url = transaction_test_db()
215
+ assert url == "sqlite://:memory:"
216
+
217
+
218
+def test_transaction_test_db_custom():
219
+ url = transaction_test_db("postgres://localhost/test")
220
+ assert url == "postgres://localhost/test"
0
221
▼crates/justapi-py/python/justapi/test_validation.py
+145-0
1
+"""Integration test for Phase 11 — Request Validation & Serialization.
2
+"""
3
+import json
4
+import subprocess
5
+import sys
6
+import time
7
+import urllib.request
8
+import urllib.error
9
+
10
+SERVER_SCRIPT = r'''
11
+import json
12
+from justapi import JustAPIApp
13
+
14
+def validate_create_user(data):
15
+ errors = []
16
+ if "name" not in data:
17
+ errors.append("name is required")
18
+ elif len(data["name"]) < 2:
19
+ errors.append("name must be at least 2 characters")
20
+ if "email" not in data:
21
+ errors.append("email is required")
22
+ elif "@" not in data["email"]:
23
+ errors.append("email must be valid")
24
+ return errors if errors else None
25
+
26
+def validate_noop(data):
27
+ return None
28
+
29
+app = JustAPIApp()
30
+
31
+async def hello_handler(request):
32
+ name = request["path_params"]["name"]
33
+ return {"message": f"Hello {name}!"}
34
+app.get("/hello/{name}", hello_handler)
35
+
36
+async def create_user(request):
37
+ body = json.loads(request["body"])
38
+ return {
39
+ "status": 201,
40
+ "body": json.dumps({"id": 1, **body}).encode("utf-8"),
41
+ "headers": [(b"content-type", b"application/json")],
42
+ }
43
+app.post("/users", create_user, body_schema=validate_create_user)
44
+
45
+async def noop_handler(request):
46
+ return {"message": "ok"}
47
+app.post("/noop", noop_handler, body_schema=validate_noop)
48
+
49
+app.run("127.0.0.1:9868")
50
+'''
51
+
52
+
53
+def test_validation():
54
+ proc = subprocess.Popen(
55
+ [sys.executable, "-c", SERVER_SCRIPT],
56
+ stdout=subprocess.PIPE,
57
+ stderr=subprocess.PIPE,
58
+ )
59
+ addr = "127.0.0.1:9868"
60
+ time.sleep(0.8)
61
+
62
+ try:
63
+ # 1. Existing functionality still works
64
+ resp = urllib.request.urlopen(f"http://{addr}/hello/world")
65
+ assert resp.status == 200, f"Expected 200, got {resp.status}"
66
+ print("PASS: /hello/world -> 200 (basic route works)")
67
+
68
+ # 2. Valid POST with body_schema
69
+ payload = json.dumps({"name": "Alice", "email": "[email protected]"}).encode()
70
+ req = urllib.request.Request(
71
+ f"http://{addr}/users",
72
+ data=payload,
73
+ headers={"Content-Type": "application/json"},
74
+ method="POST",
75
+ )
76
+ resp = urllib.request.urlopen(req)
77
+ assert resp.status == 201, f"Expected 201, got {resp.status}"
78
+ data = json.loads(resp.read())
79
+ assert data["name"] == "Alice"
80
+ assert data["email"] == "[email protected]"
81
+ print("PASS: POST /users with valid body -> 201")
82
+
83
+ # 3. Invalid POST (missing required fields)
84
+ payload = json.dumps({"name": "A"}).encode()
85
+ req = urllib.request.Request(
86
+ f"http://{addr}/users",
87
+ data=payload,
88
+ headers={"Content-Type": "application/json"},
89
+ method="POST",
90
+ )
91
+ try:
92
+ urllib.request.urlopen(req)
93
+ assert False, "Expected 422"
94
+ except urllib.error.HTTPError as e:
95
+ assert e.code == 422, f"Expected 422, got {e.code}"
96
+ error_data = json.loads(e.read())
97
+ assert error_data["title"] == "Validation Error"
98
+ assert len(error_data["errors"]) > 0
99
+ print(f"PASS: POST /users with invalid body -> 422 ({error_data['detail']})")
100
+
101
+ # 4. Invalid POST (name too short)
102
+ payload = json.dumps({"name": "A", "email": "[email protected]"}).encode()
103
+ req = urllib.request.Request(
104
+ f"http://{addr}/users",
105
+ data=payload,
106
+ headers={"Content-Type": "application/json"},
107
+ method="POST",
108
+ )
109
+ try:
110
+ urllib.request.urlopen(req)
111
+ assert False, "Expected 422"
112
+ except urllib.error.HTTPError as e:
113
+ assert e.code == 422
114
+ print("PASS: POST /users with short name -> 422")
115
+
116
+ # 5. Body schema that always passes
117
+ payload = json.dumps({"anything": "goes"}).encode()
118
+ req = urllib.request.Request(
119
+ f"http://{addr}/noop",
120
+ data=payload,
121
+ headers={"Content-Type": "application/json"},
122
+ method="POST",
123
+ )
124
+ resp = urllib.request.urlopen(req)
125
+ assert resp.status == 200
126
+ print("PASS: POST /noop with noop validator -> 200")
127
+
128
+ print()
129
+ print("=== All validation tests PASSED ===")
130
+
131
+ finally:
132
+ proc.terminate()
133
+ try:
134
+ stdout, stderr = proc.communicate(timeout=5)
135
+ if stderr:
136
+ decoded = stderr.decode()
137
+ if decoded.strip():
138
+ print("Server stderr:", decoded)
139
+ except subprocess.TimeoutExpired:
140
+ proc.kill()
141
+ proc.communicate()
142
+
143
+
144
+if __name__ == "__main__":
145
+ test_validation()
0
146
▼crates/justapi-py/test_graphql.py
+38-0
1
+import requests
2
+import time
3
+import threading
4
+from justapi import JustAPIApp
5
+
6
+def test_graphql():
7
+ app = JustAPIApp()
8
+
9
+ def run_server():
10
+ app.run("127.0.0.1:9200")
11
+
12
+ t = threading.Thread(target=run_server, daemon=True)
13
+ t.start()
14
+ time.sleep(1) # Wait for startup
15
+
16
+ # Test 1: GraphiQL UI
17
+ resp = requests.get("http://127.0.0.1:9200/graphql")
18
+ assert resp.status_code == 200
19
+ assert b"GraphiQL" in resp.content
20
+
21
+ # Test 2: GraphQL Query
22
+ query = """
23
+ query {
24
+ systemStatus
25
+ version
26
+ }
27
+ """
28
+ resp = requests.post("http://127.0.0.1:9200/graphql", json={"query": query})
29
+ assert resp.status_code == 200
30
+
31
+ data = resp.json()
32
+ assert "data" in data
33
+ assert data["data"]["systemStatus"] == "JustAPI GraphQL Federation Gateway is running"
34
+
35
+ print("Test Passed: GraphQL & Federation Gateway Works!")
36
+
37
+if __name__ == "__main__":
38
+ test_graphql()
0
39
▼crates/justapi-py/test_hot_reload.py
+83-0
1
+import pytest
2
+import os
3
+import time
4
+import json
5
+import threading
6
+import requests
7
+from justapi import JustAPIApp
8
+import asyncio
9
+
10
+def test_hot_reload():
11
+ config_path = "test_gateway.json"
12
+
13
+ initial_config = {
14
+ "routes": {
15
+ "/api/proxy": {
16
+ "GET": {
17
+ "upstream": "http://v1.upstream.com"
18
+ }
19
+ }
20
+ }
21
+ }
22
+ with open(config_path, "w") as f:
23
+ json.dump(initial_config, f)
24
+
25
+ app = JustAPIApp()
26
+ app.enable_gateway(config_path)
27
+
28
+ @app.get("/api/local")
29
+ def local_route():
30
+ return {"msg": "local"}
31
+
32
+ def run_server():
33
+ app.run("127.0.0.1:9199")
34
+
35
+ t = threading.Thread(target=run_server, daemon=True)
36
+ t.start()
37
+ time.sleep(1) # wait for server to start
38
+
39
+ # 1. Test Local
40
+ resp = requests.get("http://127.0.0.1:9199/api/local")
41
+ assert resp.status_code == 200
42
+ assert resp.json() == {"msg": "local"}
43
+
44
+ # 2. Test initial Gateway Proxy
45
+ resp = requests.get("http://127.0.0.1:9199/api/proxy")
46
+ assert resp.status_code == 200
47
+ assert resp.headers["x-justapi-gateway"] == "hot-reloaded"
48
+ assert resp.content == b"Proxied to: http://v1.upstream.com"
49
+
50
+ # 3. Modify Gateway config (HOT RELOAD)
51
+ updated_config = {
52
+ "routes": {
53
+ "/api/proxy": {
54
+ "GET": {
55
+ "upstream": "http://v2.upstream.com" # Updated!
56
+ }
57
+ },
58
+ "/api/new-proxy": {
59
+ "GET": {
60
+ "upstream": "http://v3.new.upstream.com"
61
+ }
62
+ }
63
+ }
64
+ }
65
+ with open(config_path, "w") as f:
66
+ json.dump(updated_config, f)
67
+
68
+ time.sleep(1) # wait for file watcher to pick up and debounce
69
+
70
+ # 4. Test updated Gateway Proxy
71
+ resp = requests.get("http://127.0.0.1:9199/api/proxy")
72
+ assert resp.status_code == 200
73
+ assert resp.headers["x-justapi-gateway"] == "hot-reloaded"
74
+ assert resp.content == b"Proxied to: http://v2.upstream.com"
75
+
76
+ resp = requests.get("http://127.0.0.1:9199/api/new-proxy")
77
+ assert resp.status_code == 200
78
+ assert resp.content == b"Proxied to: http://v3.new.upstream.com"
79
+
80
+ print("Test Passed: Hot Reloading Works Successfully!")
81
+
82
+if __name__ == "__main__":
83
+ test_hot_reload()
0
84
▼crates/justapi-py/test_layered_di.py
+36-0
1
+import asyncio
2
+import time
3
+from justapi import JustAPIApp, Depends
4
+import urllib.request
5
+import threading
6
+
7
+def common_auth():
8
+ print("Executing common_auth (should only run once if cached)")
9
+ return {"user_id": 123}
10
+
11
+async def fetch_db(auth=Depends(common_auth, use_cache=True)):
12
+ print("Executing fetch_db")
13
+ return {"db_conn": True, "user_id": auth["user_id"]}
14
+
15
+# App level dependency
16
+app = JustAPIApp(dependencies=[Depends(common_auth)])
17
+
18
+# Route level dependency and Handler level dependency
19
+@app.get("/di", dependencies=[Depends(fetch_db, use_cache=True)])
20
+def di_route(request, auth=Depends(common_auth, use_cache=True), db=Depends(fetch_db, use_cache=True)):
21
+ return {"message": "ok", "auth": auth, "db": db}
22
+
23
+def start_server():
24
+ app.run("127.0.0.1:8112")
25
+
26
+if __name__ == "__main__":
27
+ t = threading.Thread(target=start_server, daemon=True)
28
+ t.start()
29
+ time.sleep(1) # wait for server
30
+
31
+ req = urllib.request.Request("http://127.0.0.1:8112/di")
32
+ try:
33
+ res = urllib.request.urlopen(req)
34
+ print("Response:", res.read().decode())
35
+ except Exception as e:
36
+ print("Error:", e)
0
37
▼crates/justapi-py/tests/test_circuit_breaker_new.py
+60-0
1
+import urllib.request
2
+from urllib.error import HTTPError
3
+import multiprocessing
4
+import time
5
+from justapi import JustAPIApp
6
+
7
+def run_server():
8
+ app = JustAPIApp()
9
+ app.enable_circuit_breaker(failure_threshold=2, reset_timeout_ms=5000)
10
+
11
+ # Route that always fails
12
+ @app.get("/flaky")
13
+ def flaky(req):
14
+ raise Exception("Random failure!")
15
+
16
+ # Route that works
17
+ @app.get("/ok")
18
+ def ok(req):
19
+ return {"status": "ok"}
20
+
21
+ app.run("127.0.0.1:8088")
22
+
23
+def fetch(url):
24
+ try:
25
+ urllib.request.urlopen(url)
26
+ return 200
27
+ except HTTPError as e:
28
+ return e.code
29
+ except Exception as e:
30
+ return 0
31
+
32
+def test_circuit_breaker():
33
+ p = multiprocessing.Process(target=run_server)
34
+ p.start()
35
+ time.sleep(1.5) # Wait for server to start
36
+
37
+ try:
38
+ # Request 1: fails, should return 500 (Python exception caught and mapped to 500)
39
+ status1 = fetch("http://127.0.0.1:8088/flaky")
40
+ assert status1 == 500, f"Expected 500, got {status1}"
41
+
42
+ # Request 2: fails, should return 500
43
+ status2 = fetch("http://127.0.0.1:8088/flaky")
44
+ assert status2 == 500, f"Expected 500, got {status2}"
45
+
46
+ # Request 3: Circuit is now OPEN! Should return 503
47
+ status3 = fetch("http://127.0.0.1:8088/flaky")
48
+ assert status3 == 503, f"Expected 503 Circuit Open, got {status3}"
49
+
50
+ # Make sure another route is unaffected
51
+ status_ok = fetch("http://127.0.0.1:8088/ok")
52
+ assert status_ok == 200, f"Expected 200, got {status_ok}"
53
+
54
+ print("Circuit breaker tested successfully!")
55
+ finally:
56
+ p.terminate()
57
+ p.join()
58
+
59
+if __name__ == "__main__":
60
+ test_circuit_breaker()
0
61
▼crates/justapi-py/tests/test_query.py
+76-0
1
+"""Integration tests for the HTTP QUERY method (RFC 10008)."""
2
+
3
+import multiprocessing
4
+import time
5
+
6
+import pytest
7
+import requests
8
+
9
+from justapi import JustAPIApp
10
+
11
+PORT = 8095
12
+
13
+
14
+def run_server():
15
+ app = JustAPIApp()
16
+
17
+ @app.query("/search")
18
+ def search(request):
19
+ return {"query": request.get("body", b"").decode("utf-8")}
20
+
21
+ @app.get("/hello")
22
+ def hello(req):
23
+ return {"message": "hello"}
24
+
25
+ app.run(f"127.0.0.1:{PORT}")
26
+
27
+
28
+@pytest.fixture(scope="module")
29
+def server():
30
+ p = multiprocessing.Process(target=run_server)
31
+ p.start()
32
+ time.sleep(1.5)
33
+ try:
34
+ yield
35
+ finally:
36
+ p.terminate()
37
+ p.join()
38
+
39
+
40
+def test_query_returns_body(server):
41
+ r = requests.request(
42
+ "QUERY",
43
+ f"http://127.0.0.1:{PORT}/search",
44
+ data="q=hello&limit=10",
45
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
46
+ )
47
+ assert r.status_code == 200
48
+ assert r.json() == {"query": "q=hello&limit=10"}
49
+
50
+
51
+def test_query_requires_content_type(server):
52
+ # Suppress the default Content-Type that `requests` would add for `data`.
53
+ r = requests.request(
54
+ "QUERY",
55
+ f"http://127.0.0.1:{PORT}/search",
56
+ data="q=hello",
57
+ headers={"Content-Type": None},
58
+ )
59
+ assert r.status_code == 400
60
+ assert "Content-Type" in r.text
61
+
62
+
63
+def test_unknown_query_path_is_404(server):
64
+ r = requests.request(
65
+ "QUERY",
66
+ f"http://127.0.0.1:{PORT}/missing",
67
+ data="q=hello",
68
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
69
+ )
70
+ assert r.status_code == 404
71
+
72
+
73
+def test_get_still_works(server):
74
+ r = requests.get(f"http://127.0.0.1:{PORT}/hello")
75
+ assert r.status_code == 200
76
+ assert r.json() == {"message": "hello"}
0
77
▼crates/justapi-py/tests/test_ws_sse.py
+86-0
1
+"""Integration tests for the @app.websocket() and @app.sse() decorators."""
2
+
3
+import multiprocessing
4
+import time
5
+
6
+import pytest
7
+import requests
8
+
9
+from justapi import JustAPIApp
10
+
11
+WEBSOCKETS_AVAILABLE = True
12
+try:
13
+ import websockets
14
+except ImportError:
15
+ WEBSOCKETS_AVAILABLE = False
16
+
17
+
18
+PORT = 8091
19
+
20
+
21
+def run_server():
22
+ app = JustAPIApp()
23
+
24
+ @app.websocket("/ws")
25
+ async def ws_echo(ws):
26
+ await ws.accept()
27
+ msg = await ws.receive_text()
28
+ await ws.send_text("echo:" + msg)
29
+ await ws.close()
30
+
31
+ @app.sse("/stream")
32
+ async def sse_stream(req):
33
+ for i in range(3):
34
+ yield f"data: {i}\n\n"
35
+
36
+ @app.get("/hello")
37
+ def hello(req):
38
+ return {"message": "hello"}
39
+
40
+ app.run(f"127.0.0.1:{PORT}")
41
+
42
+
43
+@pytest.fixture(scope="module")
44
+def server():
45
+ p = multiprocessing.Process(target=run_server)
46
+ p.start()
47
+ time.sleep(1.5)
48
+ try:
49
+ yield
50
+ finally:
51
+ p.terminate()
52
+ p.join()
53
+
54
+
55
+def test_sse_stream(server):
56
+ with requests.get(f"http://127.0.0.1:{PORT}/stream", stream=True) as r:
57
+ assert r.status_code == 200
58
+ assert r.headers.get("content-type", "").startswith("text/event-stream")
59
+ data = b""
60
+ for chunk in r.iter_content(chunk_size=1024):
61
+ data += chunk
62
+ if b"data: 2" in data:
63
+ break
64
+ assert b"data: 0" in data
65
+ assert b"data: 1" in data
66
+ assert b"data: 2" in data
67
+
68
+
69
+def test_http_still_works(server):
70
+ r = requests.get(f"http://127.0.0.1:{PORT}/hello")
71
+ assert r.status_code == 200
72
+ assert r.json() == {"message": "hello"}
73
+
74
+
75
+@pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed")
76
+def test_websocket_echo(server):
77
+ async def client():
78
+ uri = f"ws://127.0.0.1:{PORT}/ws"
79
+ async with websockets.connect(uri) as ws:
80
+ await ws.send("ping")
81
+ reply = await ws.recv()
82
+ assert reply == "echo:ping"
83
+
84
+ import asyncio
85
+
86
+ asyncio.run(client())
0
87