"""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)}" )