Files
cyclone/backend/tests/test_api.py
T
Tyler 9bca4b608a feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens
Backend:

- New POST /api/batches/{id}/export-837: regenerate X12 837 files
  for a list of claim_ids into a ZIP using HCPF file naming standards,
  with a unique interchange/group control number per export. Wire
  the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
  (NM1*40) blocks so the serializer no longer falls back to
  CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
  batch_id in both JSON and NDJSON response shapes so the frontend
  can hit batch-scoped endpoints without an extra listBatches
  round-trip.
- Filename helpers and the 837 serializer updated to match the new
  HCPF envelope; tests cover batch export, parse batch_id, and the
  serializer's control-number uniqueness guarantee.

Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
  EditorialNote, ExportBar, TickerTape, and a charts/ set
  (BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
  the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
  colors to Tailwind theme tokens (bg-card, text-foreground,
  border/60, etc.) for consistency with the rest of the instrument
  chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
  reworked to compose the new shared components and consume the new
  batch-scoped API surface (notably ExportBar wired into Batches).

Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
  components and the useBatchExport hook.

Rolls up into the v0.2.0 release tag.
2026-06-22 11:01:58 -06:00

219 lines
8.0 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
# 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)