feat(api): GET /api/payers/{payer_id}/summary with 60s cache + pubsub invalidation
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user