feat(sp6): inbox endpoints — lanes, match, dismiss, resubmit, csv export

T7-T10 combined (single edit: all endpoints share a section).

- GET /api/inbox/lanes — four lanes in one call
- POST /api/inbox/candidates/{remit_id}/match — 409 on conflict
- POST /api/inbox/candidates/dismiss — session-scoped dismissed set
- POST /api/inbox/rejected/resubmit — 200 with conflicts list
- GET /api/inbox/export.csv?lane=<lane> — streams CSV

Also adds module-level imports (db, Claim, ClaimState, Remittance,
csv, io, datetime) that the new endpoints need; cleans up the
duplicated local imports in the parse-999 SP6 T4 block.
This commit is contained in:
Tyler
2026-06-20 18:34:09 -06:00
parent cdffb3faf3
commit f4baceb574
2 changed files with 283 additions and 4 deletions
+143
View File
@@ -0,0 +1,143 @@
"""TDD: /api/inbox/* endpoints.
Plan: docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md
Tasks 7-10 (T7-T10).
"""
from __future__ import annotations
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]