"""Tests for the /api/parse-ta1 endpoint and the ta1_acks list/detail endpoints.""" from __future__ import annotations from pathlib import Path import pytest from fastapi.testclient import TestClient from cyclone.api import app from cyclone.store import store as global_store from cyclone import db ACCEPTED = ( "ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " "*260520*1750*^*00501*000000001*0*P*:~" "TA1*000000001*20260520*1750*A*000*20260520~" "IEA*1*000000001~" ) REJECTED = ( "ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*TP11525703 " "*260520*1750*^*00501*000000001*0*P*:~" "TA1*320293557*260520*2338*R*006~" "IEA*0*000000001~" ) @pytest.fixture(autouse=True) def clear_store(): """Reset the module-level store before and after each test.""" with global_store._lock: global_store._batches.clear() yield with global_store._lock: global_store._batches.clear() @pytest.fixture def client() -> TestClient: return TestClient(app) def test_parse_ta1_endpoint_happy_path(client: TestClient): """Accepted TA1 → 200 with parsed envelope + persisted ta1 row.""" resp = client.post( "/api/parse-ta1", files={"file": ("accepted.ta1", ACCEPTED, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 200, resp.text body = resp.json() ta1 = body["ta1"] assert ta1["ack_code"] == "A" assert ta1["control_number"] == "000000001" assert ta1["note_code"] == "000" assert ta1["interchange_date"] == "2026-05-20" assert ta1["source_batch_id"] == "TA1-000000001" # Round-trip text present and well-formed. assert ta1["raw_ta1_text"].startswith("TA1*") assert ta1["raw_ta1_text"].endswith("~") # Parsed shape mirrors the model. assert body["parsed"]["ta1"]["ack_code"] == "A" def test_parse_ta1_endpoint_rejected_persists(client: TestClient): """Rejected TA1 (Colorado YYMMDD format) → 200 + R ack_code in DB.""" resp = client.post( "/api/parse-ta1", files={"file": ("rejected.ta1", REJECTED, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["ta1"]["ack_code"] == "R" assert body["ta1"]["note_code"] == "006" # Persisted to DB list_resp = client.get("/api/ta1-acks?limit=10", headers={"Accept": "application/json"}) assert list_resp.status_code == 200, list_resp.text list_body = list_resp.json() assert list_body["total"] == 1 assert list_body["items"][0]["ack_code"] == "R" def test_parse_ta1_endpoint_empty_file_returns_400(client: TestClient): """Empty upload → 400 (never 500).""" resp = client.post( "/api/parse-ta1", files={"file": ("empty.ta1", b"", "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 400 def test_parse_ta1_endpoint_malformed_returns_400(client: TestClient): """No TA1 segment → 400 with parse-error detail.""" text = ( "ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER " "*260520*1750*^*00501*000000001*0*P*:~" "IEA*0*000000001~" ) resp = client.post( "/api/parse-ta1", files={"file": ("bad.ta1", text, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 400 body = resp.json() assert "TA1" in body["detail"] def test_parse_ta1_endpoint_rejects_missing_file(client: TestClient): """FastAPI File(...) with no default → 422.""" resp = client.post("/api/parse-ta1") assert resp.status_code == 422 def test_get_ta1_ack_detail_regenerates_segment(client: TestClient): """GET /api/ta1-acks/{id} includes the raw TA1 segment rebuilt from the row.""" resp = client.post( "/api/parse-ta1", files={"file": ("accepted.ta1", ACCEPTED, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 200 ta1_id = resp.json()["ta1"]["id"] detail_resp = client.get(f"/api/ta1-acks/{ta1_id}", headers={"Accept": "application/json"}) assert detail_resp.status_code == 200, detail_resp.text body = detail_resp.json() assert body["id"] == ta1_id assert body["raw_ta1_text"].startswith("TA1*000000001*") assert body["raw_ta1_text"].rstrip().endswith("~") # raw_json carries the full ParseResultTa1 for the detail view. assert body["raw_json"]["ta1"]["ack_code"] == "A" def test_get_ta1_ack_404_for_missing(client: TestClient): """GET /api/ta1-acks/{id} returns 404 for a missing id.""" resp = client.get("/api/ta1-acks/9999", headers={"Accept": "application/json"}) assert resp.status_code == 404 def test_list_ta1_acks_empty(client: TestClient): """Empty DB → empty list with total=0.""" resp = client.get("/api/ta1-acks", headers={"Accept": "application/json"}) assert resp.status_code == 200 body = resp.json() assert body == {"total": 0, "items": []} def test_list_ta1_acks_newest_first(client: TestClient): """Multiple uploads: list returns rows newest first (by auto-increment id desc).""" for i, payload in enumerate([ACCEPTED, REJECTED]): resp = client.post( "/api/parse-ta1", files={"file": (f"file{i}.ta1", payload, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 200, resp.text resp = client.get("/api/ta1-acks", headers={"Accept": "application/json"}) items = resp.json()["items"] assert len(items) == 2 # The REJECTED (uploaded second) is first. assert items[0]["ack_code"] == "R" assert items[1]["ack_code"] == "A" # --------------------------------------------------------------------------- # # SP35: parse-ta1 envelope regression lock # --------------------------------------------------------------------------- # # # The TA1 parser already raises CycloneParseError("Expected TA1, got ") # when fed a file that doesn't have a TA1 segment as its first payload # segment (parse_ta1.py line 111). This regression lock confirms the HTTP # surface converts that error into a 400 (never 200, never 500). TA1 has # no ST envelope, so the test uses an 837 fixture (which has ISA + GS + # ST*837 but no TA1 segment) to exercise the parser-level guard. def test_parse_ta1_endpoint_rejects_837_input(client: TestClient): """Posting an 837P file to /api/parse-ta1 must surface 400, not 200. The TA1 envelope has no ST (it's the bare interchange-ack segment), so the wrong-kind check is structural — the parser looks for the TA1 segment and raises when it doesn't find one. An 837 file has ISA + GS + ST*837 but no TA1, which triggers that branch. """ wrong_kind = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt" text = wrong_kind.read_text() assert "ST*837" in text # sanity check on the fixture resp = client.post( "/api/parse-ta1", files={"file": ("co_medicaid_837p.txt", text, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 400, resp.text body = resp.json() assert "error" in body detail = body.get("detail", "") assert "TA1" in detail or body["error"] == "Parse error", body def test_parse_ta1_endpoint_rejects_835_input(client: TestClient): """Posting an 835 file to /api/parse-ta1 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 resp = client.post( "/api/parse-ta1", files={"file": ("co_medicaid_835.txt", text, "text/plain")}, headers={"Accept": "application/json"}, ) assert resp.status_code == 400, resp.text assert "error" in resp.json()