"""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" FIXTURE_835 = Path(__file__).parent / "fixtures" / "co_medicaid_835.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) # --------------------------------------------------------------------------- # # SP35: parse-837 input guards (defense in depth against misroute ingest) # --------------------------------------------------------------------------- # # # Before SP35, /api/parse-837 silently accepted any file that had a parseable # ISA envelope. An 835 file dropped on the Upload page while the dropdown # still said "837p" would land in the DB as an empty batch (claims=[]) and a # bogus row on the History tab. SP35 fixes that at three layers: # # 1. Server envelope check: ST*837 (or ST*837P) required, else 400 with # error="Mismatched file kind". # 2. Server empty-claims check: even with the right envelope, if zero CLM # segments were parsed, return 400 with error="No claims parsed" # and DO NOT persist the batch. # 3. UI auto-detect (separate file: src/pages/Upload.test.tsx). # # These tests are the server-layer regression locks. They run against the # TestClient and use the existing fixtures. The 835 fixture has an ST*835 # envelope; posting it to /api/parse-837 must surface a 400 and must not # create a BatchRecord. def test_parse_837_endpoint_rejects_835_input(client: TestClient): """Uploading an 835 file to /api/parse-837 must fail loudly, not persist. Repro for the original bug: user drops an 835 file on the Upload page while the kind dropdown still says "837p" (Upload.tsx default). Before SP35 the endpoint accepted it, ran it through the 837 parser (which found zero CLM segments because the file has none), and persisted a claims=[] batch — a bogus row on the History tab and on /api/batches. SP35 closes the door at the server so the UI bug becomes cosmetic instead of data-corrupting. """ text_835 = FIXTURE_835.read_text() # Sanity check: the fixture really is an 835 file. If this ever flips, # the test would still pass for the wrong reason. assert "ST*835" in text_835, "fixture is no longer ST*835 — update SP35 tests" # Snapshot the batch count BEFORE the bad upload so we can assert the # request did NOT persist anything. Using the public /api/batches JSON # endpoint (already exercised by the Dashboard). before = client.get("/api/batches", headers={"Accept": "application/json"}).json() total_before = before.get("total", len(before.get("items", []))) resp = client.post( "/api/parse-837", files={"file": ("co_medicaid_835.txt", text_835, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 400, resp.text body = resp.json() assert body["error"] == "Mismatched file kind" assert body["expected"] == "837p" assert body["detected_st"].startswith("835") after = client.get("/api/batches", headers={"Accept": "application/json"}).json() total_after = after.get("total", len(after.get("items", []))) assert total_after == total_before, "Server persisted a batch from a 835 file" def test_parse_837_endpoint_rejects_empty_envelope(client: TestClient): """Right envelope (ST*837), zero CLM segments → 400 No claims parsed. Synthetic input: a complete ISA/GS/ST envelope with a BHT, a closing SE/GE/IEA, and no CLM loops. The 837 parser will tokenize and build the envelope cleanly, then return claims=[]. SP35 must surface this as a 400 with error="No claims parsed" and must not persist a batch. """ # Bare 837 envelope — no HL/CLM loops. A real X12 file with ST*837 # but no claims is unusual but possible (e.g. a header-only test file # or a truncated/cancelled run). The right behavior is to reject, # not to silently persist an empty batch. synthetic = ( "ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " "*260617*1937*^*00501*000000001*0*P*:~" "GS*HC*SENDER*RECEIVER*20260706*1937*1*X*005010X222A1~" "ST*837*0001*005010X222A1~" "BHT*0019*00*0001*20260706*1937*CH~" "SE*2*0001~" "GE*1*1~" "IEA*1*000000001~" ) assert "ST*837" in synthetic before = client.get("/api/batches", headers={"Accept": "application/json"}).json() total_before = before.get("total", len(before.get("items", []))) resp = client.post( "/api/parse-837", files={"file": ("empty_837.txt", synthetic, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 400, resp.text body = resp.json() assert body["error"] == "No claims parsed" after = client.get("/api/batches", headers={"Accept": "application/json"}).json() total_after = after.get("total", len(after.get("items", []))) assert total_after == total_before, "Server persisted an empty-claims batch" def test_parse_837_endpoint_happy_path_still_works(client: TestClient): """Regression guard: real 837 fixture must still parse → 200 with claims. Sits next to the new SP35 rejection tests so any future tightening of the guards that accidentally blocks the happy path fails here loudly. """ 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 body.get("summary", {}).get("total_claims", 0) >= 1 assert "batch_id" in body and len(body["batch_id"]) == 32