Files
cyclone/backend/tests/test_inbox_endpoints.py
T
Tyler 2c0afbe9c5 feat(sp10): 277CA parser + Payer-Rejected Inbox lane
- Add cyclone.parsers.models_277ca + parse_277ca (X12 005010X214)
  - Per-Patient HL ClaimStatus with REF*1K (PCN), REF*EJ (tax ID),
    STC category code, amount, service date
  - STC classifier: A1-A3 accepted, A4/A6/A7 rejected, A8/A9 pended,
    P1-P5 paid, anything else unknown
  - Multiple STCs per Patient HL: last wins (canonical pattern for
    'first pended, then paid' acknowledgments)
  - Subscriber-level STCs surface in unscoped_statuses
- Add apply_277ca_rejections — stamps Claim.payer_rejected_* fields on
  matching rows (lookup by patient_control_number, mirrors 999 path)
- New /api/parse-277ca, /api/277ca-acks, /api/277ca-acks/{id} endpoints
- New two77ca_acks table + Two77caAck ORM model
- New Claim columns: payer_rejected_at, _reason, _status_code, _by_277ca_id
- New payer_rejected lane in /api/inbox/lanes (distinct from 999
  envelope rejected lane)
- New PayerConfig277CA block in config/payers.yaml + Pydantic model
- Migration 0008 bumps user_version to 8

Tests: 654 -> 688 (parser 22 + apply 6 + API 8 + config 6 + adjustments).
All 688 backend tests pass; 1 pre-existing skipped test class unaffected.
2026-06-20 23:41:01 -06:00

250 lines
8.9 KiB
Python

"""TDD: /api/inbox/* endpoints.
Plan: docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md
Tasks 7-10 (T7-T10).
"""
from __future__ import annotations
import io
import zipfile
from datetime import datetime, timezone
import pytest
from fastapi.testclient import TestClient
from cyclone import db
from cyclone.api import app
from cyclone.db import Batch, Claim, ClaimState, Remittance
@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/inbox.db")
db._reset_for_tests()
db.init_db()
# Initialize app state used by inbox endpoints.
app.state.dismissed_pairs = set()
yield
@pytest.fixture
def client():
with TestClient(app) as c:
yield c
def _seed_batch() -> None:
with db.SessionLocal()() as s:
if s.get(Batch, "B-1") is not None:
return
s.add(Batch(
id="B-1", kind="837p", input_filename="x.txt",
parsed_at=datetime.now(timezone.utc),
totals_json={"total_claims": 1},
validation_json={"passed": True, "warnings": [], "errors": []},
raw_result_json={"_": "stub"},
))
s.commit()
def test_lanes_endpoint_returns_five_keys(client: TestClient):
"""SP10 added the payer_rejected lane (distinct from 999 envelope rejection)."""
r = client.get("/api/inbox/lanes")
assert r.status_code == 200
body = r.json()
assert set(body.keys()) == {
"rejected", "payer_rejected", "candidates", "unmatched", "done_today",
}
for v in body.values():
assert isinstance(v, list)
def test_match_endpoint_links_remit_to_claim(client: TestClient):
_seed_batch()
with db.SessionLocal()() as s:
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="PCN-1",
state=ClaimState.SUBMITTED))
s.add(Remittance(id="R1", batch_id="B-1",
payer_claim_control_number="PCN-1",
status_code="1", status_label="P",
total_charge=100, total_paid=0,
received_at=datetime.now(timezone.utc)))
s.commit()
r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"})
assert r.status_code == 200, r.text
with db.SessionLocal()() as s:
c = s.get(Claim, "C1")
assert c.matched_remittance_id == "R1"
def test_match_409_if_claim_already_matched(client: TestClient):
_seed_batch()
with db.SessionLocal()() as s:
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
state=ClaimState.SUBMITTED,
matched_remittance_id="R-OTHER"))
s.add(Remittance(id="R1", batch_id="B-1",
payer_claim_control_number="X",
status_code="1", status_label="P",
total_charge=100, total_paid=0,
received_at=datetime.now(timezone.utc)))
s.commit()
r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"})
assert r.status_code == 409
assert "current_state" in r.json()["detail"]
def test_dismiss_endpoint_adds_pair_to_session_set(client: TestClient):
r = client.post(
"/api/inbox/candidates/dismiss",
json={"pairs": [{"claim_id": "C1", "remit_id": "R1"}]},
)
assert r.status_code == 200
assert frozenset({"C1", "R1"}) in client.app.state.dismissed_pairs
def test_resubmit_moves_rejected_to_submitted_and_increments_count(client: TestClient):
_seed_batch()
with db.SessionLocal()() as s:
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
state=ClaimState.REJECTED,
rejection_reason="old",
resubmit_count=2))
s.commit()
r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]})
assert r.status_code == 200, r.text
with db.SessionLocal()() as s:
c = s.get(Claim, "C1")
assert c.state == ClaimState.SUBMITTED
assert c.rejection_reason is None
assert c.resubmit_count == 3
def test_resubmit_returns_conflicts_for_non_rejected_claim(client: TestClient):
"""Resubmit returns 200 with a conflicts list (does NOT raise 409)."""
_seed_batch()
with db.SessionLocal()() as s:
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
state=ClaimState.SUBMITTED))
s.commit()
r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]})
assert r.status_code == 200
body = r.json()
assert body["resubmitted"] == []
assert any(c["claim_id"] == "C1" for c in body["conflicts"])
def test_export_csv_streams_rows_with_csv_content_type(client: TestClient):
r = client.get("/api/inbox/export.csv?lane=rejected")
assert r.status_code == 200
assert "text/csv" in r.headers["content-type"]
body = r.text
assert "id" in body.splitlines()[0]
# ---------------------------------------------------------------------------
# /api/inbox/rejected/resubmit?download=true — SP8 bundle download
# ---------------------------------------------------------------------------
#
# These tests seed a REJECTED claim with a real ClaimOutput in raw_json
# (produced by ingesting a real 837 fixture) so the download path has
# something to serialize. The bundle is built in-memory; we unzip the
# response body and assert the per-claim .x12 files round-trip back
# through the parser.
def _seed_rejected_claim_with_raw_837(client: TestClient) -> str:
"""Parse the canonical CO Medicaid 837, move its claim into REJECTED,
and return the claim_id. The raw_json column gets populated by the
/api/parse-837 endpoint as a side effect of ingesting the file."""
from pathlib import Path
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
r = client.post(
"/api/parse-837",
files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")},
headers={"Accept": "application/json"},
)
assert r.status_code == 200, r.text
real_id = r.json()["claims"][0]["claim_id"]
_seed_batch()
with db.SessionLocal()() as s:
c = s.get(Claim, real_id)
assert c is not None
c.state = ClaimState.REJECTED
c.rejection_reason = "test fixture"
s.commit()
return real_id
def test_resubmit_with_download_returns_zip_with_one_x12_per_accepted_claim(client: TestClient):
"""Happy path: 1 rejected claim → zip with 1 .x12 file that round-trips
back through the parser. Filename in Content-Disposition reflects count."""
from cyclone.parsers.parse_837 import parse as parse_837
from cyclone.parsers.payer import PayerConfig
real_id = _seed_rejected_claim_with_raw_837(client)
r = client.post(
"/api/inbox/rejected/resubmit?download=true",
json={"claim_ids": [real_id]},
)
assert r.status_code == 200, r.text
assert r.headers["content-type"].startswith("application/zip")
cd = r.headers["content-disposition"]
assert "attachment" in cd
# Count in filename comes from the number of accepted (resubmitted)
# claims, not the input list length — conflicts are excluded.
assert "resubmit-1-claims.zip" in cd
# The zip body is a real archive with one x12 per accepted claim.
buf = io.BytesIO(r.content)
with zipfile.ZipFile(buf) as zf:
names = zf.namelist()
assert names == [f"claim-{real_id}.x12"]
body = zf.read(names[0]).decode("utf-8")
# And it parses back to the same claim id.
result = parse_837(body, PayerConfig(name="CO_MEDICAID"))
assert any(c.claim_id == real_id for c in result.claims)
# Side effect: claim actually moved to SUBMITTED.
with db.SessionLocal()() as s:
c = s.get(Claim, real_id)
assert c.state == ClaimState.SUBMITTED
def test_resubmit_with_download_excludes_conflicts_from_zip(client: TestClient):
"""The download is a 'give me the files I asked for' payload — only
claims that were successfully resubmitted end up in the zip. Missing
ids and non-rejected conflicts are omitted (the user already saw them
in the JSON response on the non-download path).
"""
_seed_batch()
accepted_id = _seed_rejected_claim_with_raw_837(client)
conflict_id = "C-NOT-REJECTED"
with db.SessionLocal()() as s:
s.add(Claim(id=conflict_id, batch_id="B-1",
patient_control_number="X",
state=ClaimState.SUBMITTED))
s.commit()
r = client.post(
"/api/inbox/rejected/resubmit?download=true",
json={"claim_ids": [accepted_id, conflict_id, "C-DOES-NOT-EXIST"]},
)
assert r.status_code == 200, r.text
# Filename count = accepted count, not input length.
assert "resubmit-1-claims.zip" in r.headers["content-disposition"]
buf = io.BytesIO(r.content)
with zipfile.ZipFile(buf) as zf:
names = set(zf.namelist())
assert names == {f"claim-{accepted_id}.x12"}