Files
cyclone/backend/tests/test_api_277ca.py
T
Tyler 2c0afbe9c5 feat(sp10): 277CA parser + Payer-Rejected Inbox lane
- Add cyclone.parsers.models_277ca + parse_277ca (X12 005010X214)
  - Per-Patient HL ClaimStatus with REF*1K (PCN), REF*EJ (tax ID),
    STC category code, amount, service date
  - STC classifier: A1-A3 accepted, A4/A6/A7 rejected, A8/A9 pended,
    P1-P5 paid, anything else unknown
  - Multiple STCs per Patient HL: last wins (canonical pattern for
    'first pended, then paid' acknowledgments)
  - Subscriber-level STCs surface in unscoped_statuses
- Add apply_277ca_rejections — stamps Claim.payer_rejected_* fields on
  matching rows (lookup by patient_control_number, mirrors 999 path)
- New /api/parse-277ca, /api/277ca-acks, /api/277ca-acks/{id} endpoints
- New two77ca_acks table + Two77caAck ORM model
- New Claim columns: payer_rejected_at, _reason, _status_code, _by_277ca_id
- New payer_rejected lane in /api/inbox/lanes (distinct from 999
  envelope rejected lane)
- New PayerConfig277CA block in config/payers.yaml + Pydantic model
- Migration 0008 bumps user_version to 8

Tests: 654 -> 688 (parser 22 + apply 6 + API 8 + config 6 + adjustments).
All 688 backend tests pass; 1 pre-existing skipped test class unaffected.
2026-06-20 23:41:01 -06:00

170 lines
6.0 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:
c1 = s.get(Claim, "c1")
c2 = s.get(Claim, "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"]]