"""Tests for the FastAPI surface in ``cyclone.api``. All tests use ``fastapi.testclient.TestClient`` — no real network or uvicorn process is started. The fixture file is the same one used by the parser's own end-to-end test (``co_medicaid_837p.txt`` → 2 claims, both pass). """ from __future__ import annotations import json from pathlib import Path import pytest from fastapi.testclient import TestClient from cyclone.api import app from cyclone import __version__ FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt" @pytest.fixture def client() -> TestClient: return TestClient(app) # --------------------------------------------------------------------------- # # Health # --------------------------------------------------------------------------- # def test_health_endpoint(client: TestClient): """SP19: health endpoint now returns a subsystem snapshot.""" resp = client.get("/api/health") assert resp.status_code == 200 body = resp.json() # Old contract (status + version) is preserved. assert body["status"] == "ok" assert body["version"] == __version__ # SP19 additions. assert "db" in body and body["db"].get("ok") is True assert "scheduler" in body assert "pubsub" in body assert "batch" in body # --------------------------------------------------------------------------- # # JSON response path # --------------------------------------------------------------------------- # def test_parse_837_endpoint_returns_json(client: TestClient): text = FIXTURE.read_text() resp = client.post( "/api/parse-837", files={"file": ("co_medicaid_837p.txt", text, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert "envelope" in body assert body["envelope"] is not None assert "claims" in body and len(body["claims"]) == 2 assert "summary" in body assert body["summary"]["total_claims"] == 2 assert body["summary"]["passed"] == 2 # --------------------------------------------------------------------------- # # NDJSON streaming path # --------------------------------------------------------------------------- # def test_parse_837_endpoint_streams_ndjson(client: TestClient): text = FIXTURE.read_text() resp = client.post( "/api/parse-837", files={"file": ("co_medicaid_837p.txt", text, "text/plain")}, ) assert resp.status_code == 200, resp.text assert resp.headers["content-type"].startswith("application/x-ndjson") # Consume line-by-line (this is exactly what the React frontend will do). lines = list(resp.iter_lines()) # 1 envelope + 2 claims + 1 summary assert len(lines) == 4 parsed = [json.loads(line) for line in lines] assert parsed[0]["type"] == "envelope" assert parsed[0]["data"] is not None assert parsed[1]["type"] == "claim" assert parsed[2]["type"] == "claim" assert parsed[3]["type"] == "summary" # When include_raw_segments defaults to True, each claim carries raw segments. for obj in parsed[1:3]: assert "raw_segments" in obj["data"] assert isinstance(obj["data"]["raw_segments"], list) # Summary numbers match the JSON path. assert parsed[3]["data"]["total_claims"] == 2 assert parsed[3]["data"]["passed"] == 2 # The streaming summary carries the server-side batch id so the # frontend can call /api/batches/{id}/export-837 without a separate # GET /api/batches round-trip (regression: it used to be missing # on the stream path, only present on the JSON path, which made the # Upload page's Export button say "no batch to export"). assert parsed[3]["type"] == "summary" assert isinstance(parsed[3]["data"]["batch_id"], str) assert len(parsed[3]["data"]["batch_id"]) == 32 # uuid4().hex def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient): text = FIXTURE.read_text() resp = client.post( "/api/parse-837?include_raw_segments=false", files={"file": ("co_medicaid_837p.txt", text, "text/plain")}, ) assert resp.status_code == 200 assert resp.headers["content-type"].startswith("application/x-ndjson") claims = [json.loads(line) for line in resp.iter_lines() if json.loads(line)["type"] == "claim"] assert len(claims) == 2 for c in claims: assert c["data"]["raw_segments"] == [] # --------------------------------------------------------------------------- # # Validation / error paths # --------------------------------------------------------------------------- # def test_parse_837_endpoint_rejects_missing_file(client: TestClient): # FastAPI's `File(...)` (no default) → 422 Unprocessable Entity. resp = client.post("/api/parse-837") assert resp.status_code == 422 def test_parse_837_endpoint_handles_payer_query_param(client: TestClient): text = FIXTURE.read_text() for payer in ("co_medicaid", "generic_837p"): resp = client.post( f"/api/parse-837?payer={payer}", files={"file": ("co_medicaid_837p.txt", text, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 200, (payer, resp.text) body = resp.json() assert body["summary"]["passed"] == 2 assert body["summary"]["total_claims"] == 2 # --------------------------------------------------------------------------- # # CORS # --------------------------------------------------------------------------- # def test_cors_headers_present(client: TestClient): # Simulate a preflight from the Vite dev origin. resp = client.options( "/api/parse-837", headers={ "Origin": "http://localhost:5173", "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "content-type", }, ) assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173" assert "POST" in resp.headers.get("access-control-allow-methods", "").upper() def test_cors_headers_present_for_loopback_ip(client: TestClient): # ``http://127.0.0.1:5173`` is a distinct origin from # ``http://localhost:5173`` per the CORS spec, even though both resolve # to the same Vite dev server. Both must be allow-listed or tabs opened # via the IP form silently break. resp = client.options( "/api/parse-837", headers={ "Origin": "http://127.0.0.1:5173", "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "content-type", }, ) assert resp.headers.get("access-control-allow-origin") == "http://127.0.0.1:5173" def test_cors_extra_origins_via_env(client: TestClient, monkeypatch): # LAN / staging hosts opt in via CYCLONE_ALLOWED_ORIGINS. The env var # is a comma-separated list; the middleware must reflect each entry. # The allow-list is built at module import, so we re-execute the # module under the env var and build a TestClient against the # reloaded app. monkeypatch.setenv( "CYCLONE_ALLOWED_ORIGINS", "http://192.168.1.42:5173,https://staging.example.com" ) import importlib from cyclone import api as api_module from fastapi.testclient import TestClient as _TC importlib.reload(api_module) try: with _TC(api_module.app) as tc: for origin in ("http://192.168.1.42:5173", "https://staging.example.com"): resp = tc.options( "/api/parse-837", headers={ "Origin": origin, "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "content-type", }, ) assert resp.headers.get("access-control-allow-origin") == origin finally: monkeypatch.delenv("CYCLONE_ALLOWED_ORIGINS", raising=False) # Reload once more so the module-level allow-list returns to its # default for any test that imports `cyclone.api` after this one. importlib.reload(api_module)