178 lines
6.8 KiB
Python
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)}"
|
|
)
|