f693697
commit f693697
23 files changed
+1984 -0
Showing 23 changed files+1984-0
crates/justapi-py/python/justapi/test_background.pycrates/justapi-py/python/justapi/test_circuit_breaker.pycrates/justapi-py/python/justapi/test_dag.pycrates/justapi-py/python/justapi/test_db_helpers.pycrates/justapi-py/python/justapi/test_dependency_injection.pycrates/justapi-py/python/justapi/test_native.pycrates/justapi-py/python/justapi/test_observability.pycrates/justapi-py/python/justapi/test_plugin.pycrates/justapi-py/python/justapi/test_rate_limit.pycrates/justapi-py/python/justapi/test_request_coalescing.pycrates/justapi-py/python/justapi/test_schema_validation.pycrates/justapi-py/python/justapi/test_snapshot.pycrates/justapi-py/python/justapi/test_sse.pycrates/justapi-py/python/justapi/test_templating.pycrates/justapi-py/python/justapi/test_test_client.pycrates/justapi-py/python/justapi/test_testing.pycrates/justapi-py/python/justapi/test_validation.pycrates/justapi-py/test_graphql.pycrates/justapi-py/test_hot_reload.pycrates/justapi-py/test_layered_di.pycrates/justapi-py/tests/test_circuit_breaker_new.pycrates/justapi-py/tests/test_query.pycrates/justapi-py/tests/test_ws_sse.py
▼crates/justapi-py/python/justapi/test_background.py
+27-0
| @@ -0,0 +1,27 @@ | ||
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
| @@ -0,0 +1,60 @@ | ||
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
| @@ -0,0 +1,33 @@ | ||
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
| @@ -0,0 +1,159 @@ | ||
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
| @@ -0,0 +1,45 @@ | ||
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
| @@ -0,0 +1,97 @@ | ||
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
| @@ -0,0 +1,97 @@ | ||
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
| @@ -0,0 +1,66 @@ | ||
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
| @@ -0,0 +1,30 @@ | ||
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
| @@ -0,0 +1,59 @@ | ||
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
| @@ -0,0 +1,225 @@ | ||
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
| @@ -0,0 +1,152 @@ | ||
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
| @@ -0,0 +1,25 @@ | ||
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
| @@ -0,0 +1,24 @@ | ||
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
| @@ -0,0 +1,141 @@ | ||
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
| @@ -0,0 +1,220 @@ | ||
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
| @@ -0,0 +1,145 @@ | ||
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
| @@ -0,0 +1,38 @@ | ||
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
| @@ -0,0 +1,83 @@ | ||
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
| @@ -0,0 +1,36 @@ | ||
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
| @@ -0,0 +1,60 @@ | ||
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
| @@ -0,0 +1,76 @@ | ||
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
| @@ -0,0 +1,86 @@ | ||
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 | |