534130ee2b
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.
172 lines
6.1 KiB
Python
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"]]
|