Files
cyclone/backend/tests/test_inbox_endpoints.py
T
Tyler 534130ee2b feat(db): migration 0014 relaxes claims/remittances PK to (batch_id, id)
Migration 0014 changes the PRIMARY KEYs of claims and remittances from
single-column id to composite (batch_id, id). Enables the spec'd
cross-batch CLM01 / CLP01 collision workflow: same CLM01 in multiple
batches is now representable (resubmits), pre-flight dedup 409 path is
genuinely exercisable, force-insert can skip pre-existing duplicates.

Strategy: PRAGMA defer_foreign_keys = ON + table recreation for every
table whose FKs pointed at the old single-column PK. Tables recreated:
remittances, claims, matches, cas_adjustments, service_line_payments,
line_reconciliations. Each child table gains a batch_id (or
remittance_batch_id) column for the composite FK side; INSERT INTO new
SELECT FROM old JOINs populate it from the already-recreated parent.

Cross-table FKs (remittances.claim_id, claims.matched_remittance_id)
cannot be SQL-enforced with composite PKs (SQLite has no ALTER
CONSTRAINT). Dropped at SQL level; enforced via application-layer
invariants in store.manual_match / manual_unmatch / reconcile.run and
the dedup.preflight_* helpers.

ORM updates (db.py):
- Claim / Remittance: composite PK via explicit PrimaryKeyConstraint in
  __table_args__ (column order matches SQL: batch_id, id).
- New Claim.matched_remittance_batch_id column.
- Match / CasAdjustment / ServiceLinePayment / LineReconciliation:
  added the batch side of their composite FK to the parent table.
- SQLAlchemy before_insert events auto-populate the batch side of the
  composite FK from session.new, then identity_map, then SQL fallback.

Production code updates:
- store.manual_match / manual_unmatch: also write
  matched_remittance_batch_id on the claim (was missing).
- api.py manual-match endpoint: same fix.
- reconcile.run: same fix for auto-matched pairs.

Test updates: replaced s.get(Claim, X) with the composite key
(batch_id, id) where batch_id is known, or s.query().filter().first()
where the test only knows the id. Tests that previously inserted a Match
row pointing at a non-existent Remittance now seed the parent Remittance
so the new NOT NULL composite FK is satisfied.
2026-06-21 18:56:18 -06:00

256 lines
9.4 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:
# Migration 0014: composite PK — filter-by-id returns the single
# claim with this id (no cross-batch duplicates in this fixture).
c = s.query(Claim).filter(Claim.id == "C1").first()
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:
# Migration 0014: composite PK (batch_id, id).
c = s.get(Claim, ("B-1", "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:
# Migration 0014: filter-by-id since the parse-837 batch id is
# a fresh UUID we don't know here.
c = s.query(Claim).filter(Claim.id == real_id).first()
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:
# Migration 0014: filter-by-id (batch_id is a fresh UUID).
c = s.query(Claim).filter(Claim.id == real_id).first()
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"}