Files
cyclone/backend/tests/test_api_ta1.py
Tyler 76278ec9f6 feat(ta1): TA1 interchange ACK parser, persistence, and API
Adds full TA1 (Interchange Acknowledgment, X12 envelope-level) support
to mirror the existing 999 transaction-set ACK pipeline.

Parser & models
- parsers/models_ta1.py: Pydantic Ta1Ack + ParseResultTa1 models
  (AckCode = Literal['A','E','R'], date serializer mirroring 999)
- parsers/parse_ta1.py: TA1 segment parser
  - Tolerant YYMMDD (6-digit) + CCYYMMDD (8-digit) date handling
    for CO Medicaid interchange_date / ack_generated_date
  - 106-char ISA validation (15-char sender/receiver IDs)
  - source_batch_id = 'TA1-<ISA13>'

Persistence
- migrations/0005_create_ta1_acks.sql: ta1_acks table + indexes
  on source_batch_id and ack_code
- db.py: Ta1Ack ORM model (flat columns + raw_json, mirrors Ack)
- store.py: add_ta1_ack, list_ta1_acks (returns all rows),
  get_ta1_ack
- db version bumped 4 -> 5 (test_acks.py updated)

API (api.py)
- POST /api/parse-ta1: text/file ingest, persists ta1_ack,
  returns detail-ready payload
- GET /api/ta1-acks: list with limit + total (mirrors 999 pattern)
- GET /api/ta1-acks/{ack_id}: detail
- _ta1_to_ui / _serialize_ta1 / _serialize_ta1_from_row helpers

Tests (17 new, 508 total passing)
- tests/test_parse_ta1.py (8): CCYYMMDD + YYMMDD acceptance,
  E/R codes, source_batch_id, missing ISA/TA1, short defaults
- tests/test_api_ta1.py (9): happy path, rejected persists,
  empty/malformed 400, missing file 422, detail regenerates
  segment, 404, empty list, newest-first
- tests/test_prodfiles_smoke.py: extended to smoke-test 352
  production TA1 files (A=0, R=352, E=0)
- tests/test_api_parse_persists.py: cross-pipeline reconciliation
  test asserting invariants across 837P + 835 prod files

Real-data finding: all 352 production TA1s are R (rejected);
operator follow-up warranted.
2026-06-20 18:58:56 -06:00

166 lines
5.7 KiB
Python

"""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"