"""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="CO_TXIX"``, charge_amount=100.00; the minimal 835 carries one CLP for the same claim with total_paid=85.00. So ``/api/payers/CO_TXIX/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. ``CO_TXIX``). 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="CO_TXIX"`` 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 CO_TXIX → 200 with the spec shape.""" resp = seeded_db.get("/api/payers/CO_TXIX/summary") assert resp.status_code == 200, resp.text data = resp.json() assert data["payer_id"] == "CO_TXIX" 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/CO_TXIX/summary") resp2 = seeded_db.get("/api/payers/CO_TXIX/summary") assert resp1.status_code == 200 assert resp2.status_code == 200 assert resp1.json() == resp2.json()