"""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"]] # --------------------------------------------------------------------------- # # SP35: parse-277ca envelope regression lock # --------------------------------------------------------------------------- # # # The 277CA parser already raises CycloneParseError("Expected ST*277 or # ST*277CA, got ST*") when fed a file with the wrong ST envelope # (parse_277ca.py line 298). This regression lock confirms the HTTP # surface converts that error into a 400 (never 200, never 500). def test_parse_277ca_endpoint_rejects_835_input(client: TestClient): """Posting an 835 file to /api/parse-277ca must surface 400, not 200.""" wrong_kind = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt" text = wrong_kind.read_text() assert "ST*835" in text # sanity check on the fixture resp = client.post( "/api/parse-277ca", files={"file": ("co_medicaid_835.txt", text, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 400, resp.text body = resp.json() assert "error" in body # The parser-level message must survive (mentions "ST" and the # expected vs actual set id). detail = body.get("detail", "") assert "ST" in detail and ("277" in detail or body["error"] == "Parse error"), body def test_parse_277ca_endpoint_rejects_837_input(client: TestClient): """Posting an 837P file to /api/parse-277ca must surface 400, not 200.""" wrong_kind = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt" text = wrong_kind.read_text() assert "ST*837" in text # sanity check resp = client.post( "/api/parse-277ca", files={"file": ("co_medicaid_837p.txt", text, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 400, resp.text assert "error" in resp.json()