Files
cyclone/backend/tests/test_api_gets.py
T

400 lines
14 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
# --------------------------------------------------------------------------- #
# /api/reconciliation/unmatched (T14)
# --------------------------------------------------------------------------- #
def test_get_reconciliation_unmatched_empty(client: TestClient):
"""With no parsed batches, both sides of the unmatched list are empty."""
resp = client.get("/api/reconciliation/unmatched")
assert resp.status_code == 200
assert resp.json() == {"claims": [], "remittances": []}
def test_get_reconciliation_unmatched_returns_orphans(client: TestClient):
"""An unmatched Claim (left) and an unmatched Remittance (right) both surface.
The 837 subscriber's ``member_id="M1"`` intentionally differs from the
835 remit's ``payer_claim_control_number="PCN-OTHER"`` so the auto-matcher
in ``reconcile.run`` does not pair them on ingest — we want them to land
in the unmatched list so the endpoint has something to return.
"""
from datetime import date, datetime, timezone
from decimal import Decimal
from cyclone.parsers.models import (
BatchSummary, BillingProvider, ClaimHeader, ClaimOutput,
Envelope, Payer, ParseResult, Subscriber, ValidationReport,
)
from cyclone.parsers.models_835 import (
BatchSummary as BatchSummary835,
ClaimPayment, Envelope as Envelope835,
FinancialInfo, Payer835, ParseResult835, Payee835,
ReassociationTrace,
)
from cyclone.store import BatchRecord837, BatchRecord835
# Add a single 837 Claim. member_id="M1" so PCN-based auto-match won't pair it.
co = ClaimOutput(
claim_id="CLM-1",
control_number="0001",
transaction_date=date(2026, 6, 19),
billing_provider=BillingProvider(name="Test", npi="1234567890"),
subscriber=Subscriber(
first_name="Jane", last_name="Doe", member_id="M1",
),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(
claim_id="CLM-1", total_charge=Decimal("100"),
frequency_code="1", place_of_service="11",
),
diagnoses=[],
service_lines=[],
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
global_store.add(BatchRecord837(
id="b-837", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=ParseResult(
claims=[co],
summary=BatchSummary(
input_file="c.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=1, passed=1, failed=0,
),
),
))
# Add a single 835 Remittance with PCN="PCN-OTHER" (won't auto-match "M1").
cp = ClaimPayment(
payer_claim_control_number="PCN-OTHER",
status_code="1",
total_charge=Decimal("50"), total_paid=Decimal("50"),
service_payments=[],
)
global_store.add(BatchRecord835(
id="b-835", kind="835", input_filename="e.txt",
parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc),
result=ParseResult835(
envelope=Envelope835(
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
financial_info=FinancialInfo(
handling_code="C", paid_amount=Decimal("0"),
credit_debit_flag="C", payment_method=None,
),
trace=ReassociationTrace(
trace_type_code="1", trace_number="0001",
originating_company_id="S",
),
payer=Payer835(name="X", id="SKCO0"),
payee=Payee835(name="Y", npi="1234567890"),
claims=[cp],
summary=BatchSummary835(
input_file="e.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=1, passed=1, failed=0,
),
),
))
resp = client.get("/api/reconciliation/unmatched")
assert resp.status_code == 200
body = resp.json()
assert len(body["claims"]) == 1
assert body["claims"][0]["id"] == "CLM-1"
assert len(body["remittances"]) == 1
assert body["remittances"][0]["payerClaimControlNumber"] == "PCN-OTHER"