300522aac1
Spec 6.2 says: 'Default JSON response wraps the same data in a
{items, total, returned, has_more} envelope so the frontend can
paginate uniformly.' Previously the GET list endpoints defaulted
to NDJSON (consistent with the parse endpoints), which forced
every curl call to set Accept: application/json.
Now NDJSON is strictly opt-in via Accept: application/x-ndjson;
JSON is the default. Parse endpoints keep their existing NDJSON
default (browser uploads).
T8 test 'test_claims_default_accept_returns_ndjson' updated to
assert the new default.
290 lines
9.3 KiB
Python
290 lines
9.3 KiB
Python
"""Tests for the 6 new GET endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from cyclone.api import app
|
|
from cyclone.store import store as global_store
|
|
|
|
FIXTURE_837 = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_store():
|
|
with global_store._lock:
|
|
global_store._batches.clear()
|
|
yield
|
|
with global_store._lock:
|
|
global_store._batches.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> TestClient:
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_store():
|
|
"""One parsed 837P fixture batch."""
|
|
text = FIXTURE_837.read_text()
|
|
client = TestClient(app)
|
|
client.post(
|
|
"/api/parse-837",
|
|
files={"file": ("x.txt", text, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
return client
|
|
|
|
|
|
JSON = {"Accept": "application/json"}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/batches
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_batches_returns_empty_list_when_no_parses(client: TestClient):
|
|
resp = client.get("/api/batches", headers=JSON)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body == {"items": [], "total": 0, "returned": 0, "has_more": False}
|
|
|
|
|
|
def test_batches_returns_summary_after_parse(seeded_store):
|
|
resp = seeded_store.get("/api/batches", headers=JSON)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["total"] == 1
|
|
assert body["returned"] == 1
|
|
assert body["items"][0]["kind"] == "837p"
|
|
assert body["items"][0]["inputFilename"] == "x.txt"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/batches/{id}
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_get_batch_by_id_returns_full_result(seeded_store):
|
|
batches = seeded_store.get("/api/batches", headers=JSON).json()["items"]
|
|
bid = batches[0]["id"]
|
|
resp = seeded_store.get(f"/api/batches/{bid}")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert "envelope" in body
|
|
assert "claims" in body
|
|
assert len(body["claims"]) == 2
|
|
|
|
|
|
def test_get_batch_404_when_missing(client: TestClient):
|
|
resp = client.get("/api/batches/does-not-exist")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/claims
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_claims_returns_empty_list_when_no_parses(client: TestClient):
|
|
resp = client.get("/api/claims", headers=JSON)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["items"] == []
|
|
assert body["total"] == 0
|
|
|
|
|
|
def test_claims_filters_by_status_submitted(seeded_store):
|
|
resp = seeded_store.get("/api/claims?status=submitted", headers=JSON)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert all(c["status"] == "submitted" for c in body["items"])
|
|
assert body["total"] >= 1
|
|
|
|
|
|
def test_claims_filters_by_batch_id(seeded_store):
|
|
bid = seeded_store.get("/api/batches", headers=JSON).json()["items"][0]["id"]
|
|
resp = seeded_store.get(f"/api/claims?batch_id={bid}", headers=JSON)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert all(c["batchId"] == bid for c in body["items"])
|
|
|
|
|
|
def test_claims_sorts_by_billed_amount_desc(seeded_store):
|
|
resp = seeded_store.get("/api/claims?sort=billedAmount&order=desc", headers=JSON)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
amounts = [c["billedAmount"] for c in body["items"]]
|
|
assert amounts == sorted(amounts, reverse=True)
|
|
|
|
|
|
def test_claims_pagination_limit_offset(seeded_store):
|
|
resp1 = seeded_store.get("/api/claims?limit=1&offset=0", headers=JSON)
|
|
body1 = resp1.json()
|
|
assert body1["returned"] == 1
|
|
assert body1["has_more"] is True
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/remittances
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_remittances_returns_empty_list_when_no_parses(client: TestClient):
|
|
resp = client.get("/api/remittances", headers=JSON)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["items"] == []
|
|
|
|
|
|
def test_remittances_filters_by_batch_id(seeded_store):
|
|
"""No 835 fixture parsed yet — but the endpoint should return 200 with empty."""
|
|
resp = seeded_store.get("/api/remittances?batch_id=nope", headers=JSON)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["items"] == []
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/providers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_providers_empty_when_no_parses(client: TestClient):
|
|
resp = client.get("/api/providers", headers=JSON)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["items"] == []
|
|
|
|
|
|
def test_providers_returns_one_per_distinct_npi(seeded_store):
|
|
resp = seeded_store.get("/api/providers", headers=JSON)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert len(body["items"]) == 1
|
|
assert body["items"][0]["npi"] == "1881068062"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# /api/activity
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_activity_empty_when_no_parses(client: TestClient):
|
|
resp = client.get("/api/activity", headers=JSON)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["items"] == []
|
|
|
|
|
|
def test_activity_returns_one_event_per_claim(seeded_store):
|
|
resp = seeded_store.get("/api/activity", headers=JSON)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert len(body["items"]) == 2 # co_medicaid_837p.txt has 2 claims
|
|
assert all(e["kind"] == "claim_submitted" for e in body["items"])
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# NDJSON streaming on the list endpoints
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _assert_ndjson_envelope(resp, expected_total: int) -> list[dict]:
|
|
"""Common helper: parse NDJSON body, return the item payloads."""
|
|
import json as _json
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"].startswith("application/x-ndjson"), resp.headers
|
|
lines = [l for l in resp.text.split("\n") if l.strip()]
|
|
events = [_json.loads(l) for l in lines]
|
|
items = [e["data"] for e in events if e["type"] == "item"]
|
|
summaries = [e for e in events if e["type"] == "summary"]
|
|
assert len(summaries) == 1, events
|
|
s = summaries[0]["data"]
|
|
assert s["total"] == expected_total
|
|
assert s["returned"] == len(items)
|
|
return items
|
|
|
|
|
|
def test_batches_ndjson_returns_parseable_lines(seeded_store):
|
|
resp = seeded_store.get(
|
|
"/api/batches",
|
|
headers={"Accept": "application/x-ndjson"},
|
|
)
|
|
items = _assert_ndjson_envelope(resp, expected_total=1)
|
|
assert len(items) == 1
|
|
assert items[0]["kind"] == "837p"
|
|
|
|
|
|
def test_claims_ndjson_returns_parseable_lines(seeded_store):
|
|
resp = seeded_store.get(
|
|
"/api/claims",
|
|
headers={"Accept": "application/x-ndjson"},
|
|
)
|
|
items = _assert_ndjson_envelope(resp, expected_total=2)
|
|
assert len(items) == 2
|
|
|
|
|
|
def test_remittances_ndjson_returns_parseable_lines(seeded_store):
|
|
resp = seeded_store.get(
|
|
"/api/remittances",
|
|
headers={"Accept": "application/x-ndjson"},
|
|
)
|
|
# 0 batches parsed, 0 remits
|
|
_assert_ndjson_envelope(resp, expected_total=0)
|
|
|
|
|
|
def test_providers_ndjson_returns_parseable_lines(seeded_store):
|
|
resp = seeded_store.get(
|
|
"/api/providers",
|
|
headers={"Accept": "application/x-ndjson"},
|
|
)
|
|
items = _assert_ndjson_envelope(resp, expected_total=1)
|
|
assert items[0]["npi"] == "1881068062"
|
|
|
|
|
|
def test_activity_ndjson_returns_parseable_lines(seeded_store):
|
|
resp = seeded_store.get(
|
|
"/api/activity",
|
|
headers={"Accept": "application/x-ndjson"},
|
|
)
|
|
items = _assert_ndjson_envelope(resp, expected_total=2)
|
|
assert all(it["kind"] == "claim_submitted" for it in items)
|
|
|
|
|
|
def test_claims_json_default_wraps_in_envelope(seeded_store):
|
|
"""Accept: application/json → wrapped JSON response (items/total/returned/has_more)."""
|
|
resp = seeded_store.get(
|
|
"/api/claims",
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert set(body.keys()) == {"items", "total", "returned", "has_more"}
|
|
assert body["total"] == 2
|
|
assert body["returned"] == 2
|
|
|
|
|
|
def test_claims_ndjson_wins_over_json_when_both_accepted(seeded_store):
|
|
"""When both NDJSON and JSON are in Accept, the explicit NDJSON hint wins."""
|
|
resp = seeded_store.get(
|
|
"/api/claims",
|
|
headers={"Accept": "application/x-ndjson, application/json"},
|
|
)
|
|
assert resp.headers["content-type"].startswith("application/x-ndjson")
|
|
|
|
|
|
def test_claims_default_accept_returns_json(seeded_store):
|
|
"""No Accept header → JSON envelope (per spec 6.2: JSON is the default for
|
|
list endpoints; NDJSON is the opt-in)."""
|
|
resp = seeded_store.get("/api/claims")
|
|
body = resp.json()
|
|
assert "items" in body
|
|
assert "total" in body
|
|
assert "returned" in body
|
|
assert "has_more" in body
|