Files
cyclone/backend/tests/test_api_277ca.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

172 lines
6.1 KiB
Python

"""Tests for the FastAPI surface in ``cyclone.api`` for the 277CA endpoint.
SP10 T3. Mirrors ``test_api_999.py``:
- 400 on empty / undecodable / malformed EDI (never 500).
- 200 on success with the parsed envelope + counts.
- After parse, ``apply_277ca_rejections`` stamps matching claim rows.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone import db
from cyclone.api import app
from cyclone.db import Claim, init_db
ACCEPTED_FIXTURE = Path(__file__).parent / "fixtures" / "minimal_277ca.txt"
REJECTED_FIXTURE = Path(__file__).parent / "fixtures" / "minimal_277ca_rejected_only.txt"
@pytest.fixture(autouse=True)
def _fresh_db():
"""Each test gets a fresh DB and a clean 277CA ack list."""
init_db()
yield
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def _seed_claim(claim_id: str, pcn: str) -> None:
with db.SessionLocal()() as s:
s.add(Claim(
id=claim_id, batch_id="BATCH-1",
patient_control_number=pcn, charge_amount=100.00,
))
s.commit()
# --------------------------------------------------------------------------- #
# Happy path
# --------------------------------------------------------------------------- #
class TestParse277CAEndpointHappyPath:
def test_upload_minimal_277ca_returns_200(self, client: TestClient):
text = ACCEPTED_FIXTURE.read_text()
resp = client.post(
"/api/parse-277ca",
files={"file": ("minimal_277ca.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert "ack" in body
assert "parsed" in body
ack = body["ack"]
assert ack["accepted_count"] == 1
assert ack["rejected_count"] == 1
assert ack["pended_count"] == 1
assert ack["control_number"] == "000000123"
def test_persists_two77ca_row(self, client: TestClient):
text = ACCEPTED_FIXTURE.read_text()
client.post(
"/api/parse-277ca",
files={"file": ("minimal_277ca.txt", text, "text/plain")},
)
rows_resp = client.get("/api/277ca-acks")
assert rows_resp.status_code == 200
rows = rows_resp.json()
assert rows["total"] == 1
assert rows["items"][0]["control_number"] == "000000123"
def test_get_277ca_ack_by_id(self, client: TestClient):
text = ACCEPTED_FIXTURE.read_text()
post_resp = client.post(
"/api/parse-277ca",
files={"file": ("minimal_277ca.txt", text, "text/plain")},
)
ack_id = post_resp.json()["ack"]["id"]
detail = client.get(f"/api/277ca-acks/{ack_id}")
assert detail.status_code == 200
assert detail.json()["control_number"] == "000000123"
def test_stamps_matching_claim(self, client: TestClient):
"""A rejected 277CA claim with REF*1K=CLAIM002 stamps claim c2."""
# Seed two claims matching the fixture's PCNs.
_seed_claim("c1", "CLAIM001")
_seed_claim("c2", "CLAIM002")
text = ACCEPTED_FIXTURE.read_text()
client.post(
"/api/parse-277ca",
files={"file": ("minimal_277ca.txt", text, "text/plain")},
)
with db.SessionLocal()() as s:
# Migration 0014: composite PK (batch_id, id). The _seed_claim
# helper uses batch_id="BATCH-1".
c1 = s.get(Claim, ("BATCH-1", "c1"))
c2 = s.get(Claim, ("BATCH-1", "c2"))
assert c1.payer_rejected_at is None
assert c2.payer_rejected_at is not None
assert c2.payer_rejected_status_code == "A6"
assert "A6" in c2.payer_rejected_reason
# --------------------------------------------------------------------------- #
# Error paths
# --------------------------------------------------------------------------- #
class TestParse277CAEndpointErrors:
def test_empty_file_raises_400(self, client: TestClient):
resp = client.post(
"/api/parse-277ca",
files={"file": ("empty.txt", "", "text/plain")},
)
assert resp.status_code == 400, resp.text
assert "error" in resp.json()
def test_garbage_raises_400(self, client: TestClient):
resp = client.post(
"/api/parse-277ca",
files={"file": ("garbage.txt", "this is not EDI", "text/plain")},
)
assert resp.status_code == 400, resp.text
def test_wrong_transaction_set_raises_400(self, client: TestClient):
"""A 999 must NOT be accepted as a 277CA — different transaction set id."""
text = (
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
"GS*HN*A*B*20240620*1200*1*X*005010X231A1~"
"ST*999*0001*005010X231A1~"
"AK1*HC*0001~"
"AK9*A*0*0*0~"
"SE*4*0001~"
"GE*1*1~"
"IEA*1*000000001~"
)
resp = client.post(
"/api/parse-277ca",
files={"file": ("bad.txt", text, "text/plain")},
)
assert resp.status_code == 400, resp.text
# --------------------------------------------------------------------------- #
# Inbox lane
# --------------------------------------------------------------------------- #
class TestInboxPayerRejectedLane:
def test_payer_rejected_claim_appears_in_lane(self, client: TestClient):
"""A claim with payer_rejected_at set must appear in the payer_rejected lane."""
_seed_claim("c1", "CLAIM099")
text = REJECTED_FIXTURE.read_text() # single A7 for CLAIM099
client.post(
"/api/parse-277ca",
files={"file": ("rejected.txt", text, "text/plain")},
)
lanes = client.get("/api/inbox/lanes").json()
assert "payer_rejected" in lanes
ids = [c["id"] for c in lanes["payer_rejected"]]
assert "c1" in ids
# The rejected lane (999 envelope) must be empty — we haven't
# uploaded a 999, so this claim isn't there.
assert "c1" not in [c["id"] for c in lanes["rejected"]]