106 lines
3.8 KiB
Python
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() |