Files
cyclone/backend/tests/test_inbox_endpoints.py
T
Tyler ec9eae7a2c feat(sp8): resubmit endpoint supports ?download=zip for regenerated 837s
Add an optional ``?download=true`` query param to
``POST /api/inbox/rejected/resubmit`` that returns the same operation
result as a ZIP archive of regenerated 837P files (one
``claim-{id}.x12`` per successfully resubmitted claim) rather than the
JSON envelope.

Why: operators who mass-resubmit rejected claims want to hand the
files straight to their clearinghouse; round-tripping through copy-
paste is error-prone.

Implementation notes:
  * Uses ``serialize_837_for_resubmit`` so each X12 file in the bundle
    gets a unique interchange/group control number (back-to-back
    resubmits would otherwise collide on ISA13/GS06 = "000000001").
  * Conflicts and missing ids are deliberately excluded from the ZIP
    — the user already saw them in the JSON path on prior calls; the
    download is the "give me the files I asked for" payload.
  * Empty resubmit + download returns 200 with an empty ZIP so the UI
    can still hand the user a downloadable artifact.

Tests (test_inbox_endpoints.py): 2 new tests covering the success
shape (one .x12 per accepted claim) and the conflict-exclusion
contract.
2026-06-20 20:49:08 -06:00

247 lines
8.8 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_four_keys(client: TestClient):
r = client.get("/api/inbox/lanes")
assert r.status_code == 200
body = r.json()
assert set(body.keys()) == {"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"}