Skip to content

Commit d1f0996

Browse files
authored
Add test_serve.py covering the prediction server (#7) (#12)
Tests the runtime/serve.py request handler over a live socket on an ephemeral port: /health, /predict, /predict_batch, plus the 400 (missing field), 500 (embedding-dim mismatch) and 404 (unknown path) error paths. Stdlib-only, clean shutdown in fixture teardown.
1 parent 26379fd commit d1f0996

1 file changed

Lines changed: 162 additions & 0 deletions

File tree

‎tests/test_serve.py‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Tests for the prediction server (runtime/serve.py).
2+
3+
Exercises the real request handler over a live socket: /health, /predict,
4+
/predict_batch and the error paths. The server is started on an ephemeral
5+
port (127.0.0.1:0) in a background thread and shut down cleanly after the
6+
module's tests run, so nothing leaks between test files.
7+
"""
8+
9+
import json
10+
import threading
11+
from http.server import HTTPServer
12+
from pathlib import Path
13+
from urllib.error import HTTPError
14+
from urllib.request import Request, urlopen
15+
16+
import numpy as np
17+
import pytest
18+
import sys
19+
20+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
21+
22+
import tracer
23+
from tracer.runtime import serve as serve_mod
24+
from tracer.policy.artifacts import load_manifest
25+
from tracer.runtime.router import Router
26+
27+
28+
# ── Fixtures ────────────────────────────────────────────────────────────────
29+
30+
def _make_traces(tmpdir, n=240, dim=16, n_classes=3, noise=0.05):
31+
"""Synthetic, well-separated traces so fit() deploys a surrogate."""
32+
rng = np.random.RandomState(0)
33+
centers = rng.randn(n_classes, dim) * 4
34+
labels_int = rng.randint(0, n_classes, size=n)
35+
X = centers[labels_int] + rng.randn(n, dim) * 0.6
36+
names = [f"cls_{i}" for i in range(n_classes)]
37+
teacher = [names[i] for i in labels_int]
38+
for i in range(n):
39+
if rng.random() < noise:
40+
teacher[i] = names[rng.randint(0, n_classes)]
41+
path = Path(tmpdir) / "traces.jsonl"
42+
with path.open("w") as f:
43+
for i in range(n):
44+
f.write(json.dumps({"input": f"text {i}", "teacher": teacher[i],
45+
"id": str(i)}) + "\n")
46+
return path, X.astype(np.float32)
47+
48+
49+
@pytest.fixture(scope="module")
50+
def fitted_artifact(tmp_path_factory):
51+
tmpdir = tmp_path_factory.mktemp("serve_artifact")
52+
traces_path, X = _make_traces(tmpdir)
53+
artifact_dir = Path(tmpdir) / ".tracer"
54+
result = tracer.fit(traces_path, artifact_dir, embeddings=X,
55+
config=tracer.FitConfig(verbose=False))
56+
assert result.manifest.selected_method is not None, \
57+
"test fixture expected a deployable policy"
58+
return artifact_dir, X
59+
60+
61+
@pytest.fixture(scope="module")
62+
def server(fitted_artifact):
63+
artifact_dir, _ = fitted_artifact
64+
# Wire the handler exactly as serve() does, but keep the server handle so we
65+
# can bind an ephemeral port and shut down cleanly.
66+
serve_mod._manifest = load_manifest(artifact_dir / "manifest.json")
67+
serve_mod._router = Router.load(artifact_dir)
68+
httpd = HTTPServer(("127.0.0.1", 0), serve_mod._Handler)
69+
port = httpd.server_address[1]
70+
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
71+
thread.start()
72+
yield f"http://127.0.0.1:{port}"
73+
httpd.shutdown()
74+
thread.join(timeout=5)
75+
httpd.server_close()
76+
77+
78+
# ── Helpers ─────────────────────────────────────────────────────────────────
79+
80+
def _get(base, path):
81+
with urlopen(f"{base}{path}", timeout=5) as resp:
82+
return resp.status, json.loads(resp.read())
83+
84+
85+
def _post(base, path, payload):
86+
req = Request(f"{base}{path}", data=json.dumps(payload).encode(),
87+
headers={"Content-Type": "application/json"}, method="POST")
88+
try:
89+
with urlopen(req, timeout=5) as resp:
90+
return resp.status, json.loads(resp.read())
91+
except HTTPError as e:
92+
return e.code, json.loads(e.read())
93+
94+
95+
# ── /health ───────────────────────────────────────────────────────────────────
96+
97+
def test_health(server, fitted_artifact):
98+
status, body = _get(server, "/health")
99+
assert status == 200
100+
assert body["status"] == "ok"
101+
assert body["method"] # a deployed method name
102+
assert body["n_labels"] == 3
103+
assert 0.0 <= body["coverage"] <= 1.0
104+
105+
106+
# ── /predict ──────────────────────────────────────────────────────────────────
107+
108+
def test_predict_returns_routing_decision(server, fitted_artifact):
109+
_, X = fitted_artifact
110+
status, body = _post(server, "/predict", {"embedding": X[0].tolist()})
111+
assert status == 200
112+
assert body["decision"] in ("handled", "deferred")
113+
assert "label" in body and "accept_score" in body and "stage" in body
114+
115+
116+
def test_predict_missing_field_is_400(server):
117+
status, body = _post(server, "/predict", {"not_embedding": [1, 2, 3]})
118+
assert status == 400
119+
assert "embedding" in body["error"]
120+
121+
122+
def test_predict_wrong_dim_is_500(server, fitted_artifact):
123+
# Router raises a dimension-mismatch ValueError, surfaced as a 500.
124+
status, body = _post(server, "/predict", {"embedding": [0.1, 0.2, 0.3]})
125+
assert status == 500
126+
assert "dimension" in body["error"].lower()
127+
128+
129+
# ── /predict_batch ────────────────────────────────────────────────────────────
130+
131+
def test_predict_batch(server, fitted_artifact):
132+
_, X = fitted_artifact
133+
batch = X[:5].tolist()
134+
status, body = _post(server, "/predict_batch", {"embeddings": batch})
135+
assert status == 200
136+
assert len(body["labels"]) == 5
137+
assert len(body["decisions"]) == 5
138+
assert len(body["handled"]) == 5
139+
assert all(d in ("handled", "deferred") for d in body["decisions"])
140+
141+
142+
def test_predict_batch_missing_field_is_400(server):
143+
status, body = _post(server, "/predict_batch", {"wrong": []})
144+
assert status == 400
145+
assert "embeddings" in body["error"]
146+
147+
148+
# ── Routing / unknown paths ───────────────────────────────────────────────────
149+
150+
def test_unknown_get_path_is_404(server):
151+
try:
152+
_get(server, "/nope")
153+
assert False, "expected 404"
154+
except HTTPError as e:
155+
assert e.code == 404
156+
body = json.loads(e.read())
157+
assert "endpoints" in body
158+
159+
160+
def test_unknown_post_path_is_404(server):
161+
status, body = _post(server, "/nope", {"x": 1})
162+
assert status == 404

0 commit comments

Comments
 (0)