f4baceb574
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.
144 lines
4.8 KiB
Python
144 lines
4.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
|
|
|
|
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]
|