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