Files
cyclone/backend/tests/test_provider_extended_response.py

178 lines
6.8 KiB
Python

"""Tests for the extended GET /api/config/providers/{npi} (SP21 Task 1.6).
The endpoint gains two new top-level arrays for the drill-down panel:
``recent_claims`` (top 10 by submission date desc) and ``recent_activity``
(top 10 by ``ts`` desc, joined to claims by ``claim_id`` because
``ActivityEvent`` has no direct ``provider_npi`` column).
Existing SP9 fields (``label``, ``legal_name``, ``tax_id``,
``address_line1``, ``city``, ``state``, ``zip``, etc.) must remain
present — the new arrays are additive only.
The fixtures in ``fixtures/minimal_837p.txt`` and ``fixtures/minimal_835.txt``
pair up to a single claim with ``provider_npi='1993999998'``. That NPI is
NOT in the seeded provider set (Montrose/Delta/Salida → 1881068062/1851446637/
1467507269). For the tests we hit the seeded Montrose NPI, which is the
canonical SP9 fixture NPI. The arrays come back as empty lists — the
contract under test is the *shape* (array, ≤10) and the *backwards compat*
of the existing fields; the data-path itself is exercised by the existing
ingestion path that backs ``/api/claims``.
"""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.providers import Provider
from cyclone.store import store
FIXTURE_837 = Path(__file__).parent / "fixtures" / "minimal_837p.txt"
FIXTURE_835 = Path(__file__).parent / "fixtures" / "minimal_835.txt"
# Montrose — one of the three providers that `ensure_clearhouse_seeded()`
# writes into the providers table. Using a seeded NPI means the endpoint
# won't 404; the seeded claims (provider_npi='1993999998') won't appear
# under this NPI, so recent_claims/activity are expected empty lists.
MONTROSE_NPI = "1881068062"
# NPI the minimal 837P fixture bills under. NOT in the default seed —
# registering it before ingest is required to pass R204 (NPI must exist
# in the providers table).
TEST_837_NPI = "1993999998"
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture
def seeded_db(client: TestClient):
"""Seed the clearhouse + ingest the minimal 837P/835 fixtures.
Mirrors the Task 1.5 ``seeded_db`` pattern: seed → ingest → hand the
client back so the test hits the same TestClient.
The 837 fixture bills under ``TEST_837_NPI``; without first registering
that provider the parser's R204 rule rejects the claim with HTTP 422.
"""
store.ensure_clearhouse_seeded()
test_provider = Provider(
npi=TEST_837_NPI,
label="Test Provider",
legal_name="Test Provider Inc",
tax_id="123456789",
taxonomy_code="207R00000X",
address_line1="123 Test St",
city="Denver",
state="CO",
zip="80202",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
store.upsert_provider(test_provider)
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_provider_detail_includes_recent_claims(seeded_db: TestClient):
"""The extended response gains a recent_claims array (top 10)."""
resp = seeded_db.get(f"/api/config/providers/{MONTROSE_NPI}")
assert resp.status_code == 200, resp.text
data = resp.json()
assert "recent_claims" in data
assert isinstance(data["recent_claims"], list)
assert len(data["recent_claims"]) <= 10
def test_provider_detail_includes_recent_activity(seeded_db: TestClient):
"""The extended response gains a recent_activity array (top 10)."""
resp = seeded_db.get(f"/api/config/providers/{MONTROSE_NPI}")
assert resp.status_code == 200, resp.text
data = resp.json()
assert "recent_activity" in data
assert isinstance(data["recent_activity"], list)
assert len(data["recent_activity"]) <= 10
def test_provider_detail_backwards_compat(seeded_db: TestClient):
"""All SP9 fields still present; new arrays don't break the contract.
The Provider Pydantic model (backend/src/cyclone/providers.py)
serializes snake_case fields — that's what the wire carries. The
TS ``Provider`` interface in ``src/types/index.ts`` is the
in-memory sample shape and intentionally diverges; the contract
being verified here is the API's actual payload.
"""
resp = seeded_db.get(f"/api/config/providers/{MONTROSE_NPI}")
assert resp.status_code == 200, resp.text
data = resp.json()
for key in (
"npi",
"label",
"legal_name",
"tax_id",
"taxonomy_code",
"address_line1",
"city",
"state",
"zip",
"is_active",
):
assert key in data, f"missing field {key}"
def test_provider_detail_includes_orphan_remit_received(seeded_db: TestClient):
"""Regression: the ``remit_received`` ActivityEvent is recorded at 835
ingest with ``claim_id=None`` (``store.add`` lines 999-1003) — the
remittance hasn't been matched to a claim yet. The original
``ActivityEvent.claim_id IN (claim_ids)`` filter misses it because
the orphan's claim_id is NULL.
Once reconciliation (auto or manual) populates
``Remittance.claim_id``, the activity filter must surface the event
via the ``Remittance.claim_id IN (claim_ids)`` branch of the OR.
Without that branch, a provider's activity feed appears to freeze
the moment an 835 lands — the most common activity, invisible.
Setup: ingest 837+835 for ``TEST_837_NPI`` (claim CLM001 + remit
CLM001), then manually match them so ``Remittance.claim_id`` is
populated. The bug presents as: only ``claim_submitted`` and
``manual_match`` appear (no ``remit_received``). The fix surfaces
all three.
"""
# Force the match — simulates the post-reconciliation state that
# populate Remittance.claim_id without depending on auto-reconcile
# heuristics (which don't match this minimal fixture).
match_resp = seeded_db.post(
"/api/reconciliation/match",
json={"claim_id": "CLM001", "remit_id": "CLM001"},
)
assert match_resp.status_code == 200, match_resp.text
resp = seeded_db.get(f"/api/config/providers/{TEST_837_NPI}")
assert resp.status_code == 200, resp.text
data = resp.json()
kinds = {event["kind"] for event in data["recent_activity"]}
assert "remit_received" in kinds, (
f"expected remit_received in recent_activity (orphan remits "
f"must surface via the Remittance join), got kinds={sorted(kinds)}"
)