Files
cyclone/backend/tests/test_payer_summary.py
T

106 lines
3.8 KiB
Python

"""Tests for GET /api/payers/{payer_id}/summary (SP21 Task 1.5).
The endpoint is the payer-level aggregate that the drill-down UI's
"Payer → Claims" panel hangs off. It returns billed/received totals,
denial rate, and the top 5 NPIs by claim volume for one payer_id,
cached in-process for 60s.
The minimal 837P fixture ships one CLM with ``payer_id="SKCO0"``,
charge_amount=100.00; the minimal 835 carries one CLP for the same
claim with total_paid=85.00. So ``/api/payers/SKCO0/summary`` returns
``claim_count >= 1`` after both files are ingested.
Note: the spec calls this ``payer_id`` (the X12 NM1*PR*PI qualifier,
e.g. ``SKCO0``). It is NOT the configured payer name from
``config/payers.yaml``. The filter key in the store layer is
``Claim.payer_id`` — not the ``payer=`` substring filter used by
``/api/claims?payer=...``.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone import api as api_mod
from cyclone.api import app
FIXTURE_837 = Path(__file__).parent / "fixtures" / "minimal_837p.txt"
FIXTURE_835 = Path(__file__).parent / "fixtures" / "minimal_835.txt"
@pytest.fixture(autouse=True)
def _clear_summary_cache():
"""Wipe the in-process payer-summary cache between tests.
conftest resets the DB per test but the cache is module-level
state on ``cyclone.api``. Without this clear, a stale payload
from a previous test's seed would leak into a later test's first
call — masking recompute behavior. The 60s TTL is the only
invalidation story today (see api.py docstring on the endpoint).
"""
api_mod._clear_summary_cache()
yield
api_mod._clear_summary_cache()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture
def seeded_db(client: TestClient):
"""Ingest one minimal 837P + one minimal 835.
Both fixtures carry ``payer_id="SKCO0"`` so the summary endpoint
has something to aggregate. ``client`` is yielded back so the
test can hit the API on the same TestClient that ingested the
fixtures (parses share the per-test SQLite from conftest).
"""
text_837 = FIXTURE_837.read_text()
text_835 = FIXTURE_835.read_text()
r837 = client.post(
"/api/parse-837",
files={"file": ("x.txt", text_837, "text/plain")},
headers={"Accept": "application/json"},
)
assert r837.status_code == 200, r837.text
r835 = client.post(
"/api/parse-835",
files={"file": ("era.txt", text_835, "text/plain")},
headers={"Accept": "application/json"},
)
assert r835.status_code == 200, r835.text
return client
def test_payer_summary_happy_path(seeded_db: TestClient):
"""Seeded db has at least one claim for SKCO0 → 200 with the spec shape."""
resp = seeded_db.get("/api/payers/SKCO0/summary")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["payer_id"] == "SKCO0"
assert "claim_count" in data
assert "billed_total" in data
assert "received_total" in data
assert "denial_rate" in data
assert data["claim_count"] >= 1
# denial_rate must be a float in [0, 1] (0/1 claim → 0.0).
assert isinstance(data["denial_rate"], (int, float))
assert 0.0 <= data["denial_rate"] <= 1.0
def test_payer_summary_unknown_payer_returns_404(client: TestClient):
resp = client.get("/api/payers/DOES_NOT_EXIST/summary")
assert resp.status_code == 404
def test_payer_summary_caches_then_invalidates(seeded_db: TestClient):
"""Two back-to-back calls return identical payloads (in-process cache)."""
resp1 = seeded_db.get("/api/payers/SKCO0/summary")
resp2 = seeded_db.get("/api/payers/SKCO0/summary")
assert resp1.status_code == 200
assert resp2.status_code == 200
assert resp1.json() == resp2.json()