unknown1321's picture
Clean deploy to HF Space
e9ce6e9
Raw
History Blame Contribute Delete
8.71 kB
"""
tests/test_server.py
====================
Phase 10 β€” 10 tests covering the FastAPI server endpoints per CLAUDE.md.
Uses FastAPI TestClient (synchronous) so no live uvicorn process is needed.
The TestClient mounts the same `app` instance that production uses β€” this
exercises the real server code, not mocks.
"""
import pytest
from fastapi.testclient import TestClient
from server.app import app, env as server_env
from unified_gateway import AEPOObservation, UnifiedFintechEnv
# ---------------------------------------------------------------------------
# Single shared TestClient β€” re-used across all tests to keep server state
# consistent with sequential test execution.
# ---------------------------------------------------------------------------
client = TestClient(app)
def _valid_action_dict(**overrides) -> dict:
"""Build a valid action dict with safe defaults."""
base = dict(
risk_decision=1,
crypto_verify=0,
infra_routing=0,
db_retry_policy=0,
settlement_policy=0,
app_priority=2,
)
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# Test 1 β€” POST /reset with task=easy returns 200 and valid observation
# ---------------------------------------------------------------------------
def test_reset_easy_returns_200_and_valid_obs() -> None:
"""POST /reset {"task": "easy"} must return HTTP 200 and a valid observation."""
resp = client.post("/reset", json={"task": "easy"})
assert resp.status_code == 200
body = resp.json()
assert "observation" in body
# Reconstruct typed obs to validate all field ranges
obs = AEPOObservation(**body["observation"])
for key, val in obs.normalized().items():
assert 0.0 <= val <= 1.0, f"{key}={val} out of [0,1]"
# ---------------------------------------------------------------------------
# Test 2 β€” POST /reset with task=hard returns 200 and valid observation
# ---------------------------------------------------------------------------
def test_reset_hard_returns_200_and_valid_obs() -> None:
"""POST /reset {"task": "hard"} must return HTTP 200 and a valid observation."""
resp = client.post("/reset", json={"task": "hard"})
assert resp.status_code == 200
body = resp.json()
assert "observation" in body
obs = AEPOObservation(**body["observation"])
assert all(0.0 <= v <= 1.0 for v in obs.normalized().values())
# ---------------------------------------------------------------------------
# Test 3 β€” POST /reset with invalid task returns 422
# ---------------------------------------------------------------------------
def test_reset_invalid_task_returns_422() -> None:
"""POST /reset with an unrecognised task must return HTTP 422."""
resp = client.post("/reset", json={"task": "impossible"})
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# Test 4 β€” POST /step with valid action returns 200 with obs, reward, done, info
# ---------------------------------------------------------------------------
def test_step_valid_action_returns_200() -> None:
"""POST /step with a valid action must return HTTP 200 with required keys."""
client.post("/reset", json={"task": "easy"})
resp = client.post("/step", json={"action": _valid_action_dict()})
assert resp.status_code == 200
body = resp.json()
for key in ("observation", "reward", "done", "info"):
assert key in body, f"Missing key: {key}"
assert isinstance(body["reward"], float)
assert 0.0 <= body["reward"] <= 1.0
assert isinstance(body["done"], bool)
# ---------------------------------------------------------------------------
# Test 5 β€” POST /step with invalid action (risk_decision=9) returns 422
# ---------------------------------------------------------------------------
def test_step_invalid_action_returns_422() -> None:
"""POST /step with out-of-range action field must return HTTP 422."""
client.post("/reset", json={"task": "easy"})
resp = client.post("/step", json={"action": _valid_action_dict(risk_decision=9)})
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# Test 6 β€” GET /state returns current observation
# ---------------------------------------------------------------------------
def test_get_state_returns_observation() -> None:
"""GET /state must return HTTP 200 with an observation key."""
client.post("/reset", json={"task": "easy"})
resp = client.get("/state")
assert resp.status_code == 200
body = resp.json()
assert "observation" in body
obs = AEPOObservation(**body["observation"])
assert all(0.0 <= v <= 1.0 for v in obs.normalized().values())
# ---------------------------------------------------------------------------
# Test 7 β€” GET / (root health check) returns 200
# ---------------------------------------------------------------------------
def test_root_health_check() -> None:
"""GET / must return HTTP 200 β€” Hugging Face Spaces probe."""
resp = client.get("/")
assert resp.status_code == 200
assert "status" in resp.json()
# ---------------------------------------------------------------------------
# Test 8 β€” GET /reset (health probe) returns 200
# ---------------------------------------------------------------------------
def test_get_reset_health_check() -> None:
"""GET /reset must return 200 β€” some graders probe with GET before POST."""
resp = client.get("/reset")
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Test 9 β€” full episode: reset β†’ 100 steps β†’ done=True
# ---------------------------------------------------------------------------
def test_full_episode_completes_in_100_steps() -> None:
"""A full easy episode must reach done=True within 100 steps."""
client.post("/reset", json={"task": "easy"})
done = False
steps = 0
safe_action = _valid_action_dict(risk_decision=1, crypto_verify=0, infra_routing=0)
while not done and steps < 105:
resp = client.post("/step", json={"action": safe_action})
assert resp.status_code == 200
body = resp.json()
done = body["done"]
steps += 1
assert done is True, f"Episode not done after {steps} steps"
assert steps <= 100, f"Episode ran {steps} steps, expected ≀ 100"
# ---------------------------------------------------------------------------
# Test 10 β€” server uses same UnifiedFintechEnv class as standalone (no divergence)
# ---------------------------------------------------------------------------
def test_server_uses_same_env_class() -> None:
"""
The server's global env must be an instance of UnifiedFintechEnv β€”
the same class imported in standalone mode.
"""
assert isinstance(server_env, UnifiedFintechEnv), (
"server.app.env must be an instance of UnifiedFintechEnv "
"(dual-mode architecture contract)"
)
# ---------------------------------------------------------------------------
# Test 11 β€” POST /step before reset returns 400 (no active episode)
# ---------------------------------------------------------------------------
def test_step_before_reset_returns_400(monkeypatch) -> None:
"""
POST /step before calling POST /reset must return HTTP 400.
Covered by CLAUDE.md spec: 'POST /step before reset returns 400 (no active episode)'.
Uses monkeypatch to temporarily clear _episode_active so the test is
independent of other tests that may have already called /reset.
"""
import server.app as server_module
monkeypatch.setattr(server_module, "_episode_active", False)
resp = client.post("/step", json={"action": _valid_action_dict()})
assert resp.status_code == 400, (
f"Expected 400 before reset, got {resp.status_code}: {resp.text}"
)
# ---------------------------------------------------------------------------
# Test 12 β€” GET /state before reset returns 400
# ---------------------------------------------------------------------------
def test_state_before_reset_returns_400(monkeypatch) -> None:
"""
GET /state before calling POST /reset must return HTTP 400.
Covered by CLAUDE.md spec: 'GET /state before reset returns 400'.
"""
import server.app as server_module
monkeypatch.setattr(server_module, "_episode_active", False)
resp = client.get("/state")
assert resp.status_code == 400, (
f"Expected 400 before reset, got {resp.status_code}: {resp.text}"
)