9bca4b608a
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.
219 lines
8.0 KiB
Python
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)
|