feat(backend): add NDJSON streaming on list endpoints

This commit is contained in:
Tyler
2026-06-19 19:28:17 -06:00
parent cbffdbd2af
commit 74a673a360
2 changed files with 207 additions and 33 deletions
+113 -15
View File
@@ -40,20 +40,23 @@ def seeded_store():
return client
JSON = {"Accept": "application/json"}
# --------------------------------------------------------------------------- #
# /api/batches
# --------------------------------------------------------------------------- #
def test_batches_returns_empty_list_when_no_parses(client: TestClient):
resp = client.get("/api/batches")
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")
resp = seeded_store.get("/api/batches", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
@@ -68,7 +71,7 @@ def test_batches_returns_summary_after_parse(seeded_store):
def test_get_batch_by_id_returns_full_result(seeded_store):
batches = seeded_store.get("/api/batches").json()["items"]
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
@@ -89,7 +92,7 @@ def test_get_batch_404_when_missing(client: TestClient):
def test_claims_returns_empty_list_when_no_parses(client: TestClient):
resp = client.get("/api/claims")
resp = client.get("/api/claims", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
@@ -97,7 +100,7 @@ def test_claims_returns_empty_list_when_no_parses(client: TestClient):
def test_claims_filters_by_status_submitted(seeded_store):
resp = seeded_store.get("/api/claims?status=submitted")
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"])
@@ -105,15 +108,15 @@ def test_claims_filters_by_status_submitted(seeded_store):
def test_claims_filters_by_batch_id(seeded_store):
bid = seeded_store.get("/api/batches").json()["items"][0]["id"]
resp = seeded_store.get(f"/api/claims?batch_id={bid}")
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")
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"]]
@@ -121,7 +124,7 @@ def test_claims_sorts_by_billed_amount_desc(seeded_store):
def test_claims_pagination_limit_offset(seeded_store):
resp1 = seeded_store.get("/api/claims?limit=1&offset=0")
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
@@ -133,7 +136,7 @@ def test_claims_pagination_limit_offset(seeded_store):
def test_remittances_returns_empty_list_when_no_parses(client: TestClient):
resp = client.get("/api/remittances")
resp = client.get("/api/remittances", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
@@ -141,7 +144,7 @@ def test_remittances_returns_empty_list_when_no_parses(client: TestClient):
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")
resp = seeded_store.get("/api/remittances?batch_id=nope", headers=JSON)
assert resp.status_code == 200
assert resp.json()["items"] == []
@@ -152,14 +155,14 @@ def test_remittances_filters_by_batch_id(seeded_store):
def test_providers_empty_when_no_parses(client: TestClient):
resp = client.get("/api/providers")
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")
resp = seeded_store.get("/api/providers", headers=JSON)
assert resp.status_code == 200
body = resp.json()
assert len(body["items"]) == 1
@@ -172,15 +175,110 @@ def test_providers_returns_one_per_distinct_npi(seeded_store):
def test_activity_empty_when_no_parses(client: TestClient):
resp = client.get("/api/activity")
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")
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_ndjson(seeded_store):
"""No Accept header → NDJSON (consistent with parse endpoints' default)."""
resp = seeded_store.get("/api/claims")
assert resp.headers["content-type"].startswith("application/x-ndjson")