b6607b2009
Three independent improvements that fix real browser-facing bugs: 1. CORS: allow 127.0.0.1:5173 in addition to localhost:5173. Both resolve to the same Vite dev server but CORS treats them as distinct origins, so tabs opened via the IP form silently break. 2. CORS: support CYCLONE_ALLOWED_ORIGINS env var (comma-separated) for LAN / staging hosts. The middleware reads it at module import. 3. Catch-all exception handler: returns JSON 500 with CORS headers instead of a bare Uvicorn text/plain response. Without this, any unhandled exception is misreported by browsers as a CORS error because the body can't be read without the allow-origin header. 4. IntegrityError → 409: when (batch_id, patient_control_number) is UNIQUE-constrained and a duplicate collides, return 409 with the batch id instead of letting the exception 500. Same problem as (3) for the most common ingest failure mode. Tests added: - test_cors_headers_present_for_loopback_ip - test_cors_extra_origins_via_env (uses importlib.reload because the allow-list is built at module import)
211 lines
7.5 KiB
Python
211 lines
7.5 KiB
Python
"""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
|
|
|
|
|
|
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)
|