From f3002e4cbe1660bf861bd99bcd0565e42d19991a Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 08:06:21 -0600 Subject: [PATCH] fix(api): regenerate raw_999_text in /api/acks/{id} detail response (SP3 P3 follow-up) The P3 subagent shipped a Download button on /acks that fell back to 'no raw text' because the detail endpoint returned raw_json (the parsed model dump) but never the regenerated X12. Fix: detail endpoint now re-serializes raw_json via serialize_999 and returns it as raw_999_text, so the operator can actually download the 999 file. List endpoint unchanged (keeps payload small). --- backend/src/cyclone/api.py | 14 ++++++++++++++ backend/tests/test_api_999.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 5d74285..091f9bf 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -971,6 +971,20 @@ def get_ack_endpoint(ack_id: int) -> dict: ) body = _ack_to_ui(row) body["raw_json"] = row.raw_json + # Regenerate the X12 text from raw_json so the operator can download + # the actual 999 file. (SP3 P3 follow-up: list endpoint doesn't carry + # the regenerated text to keep payloads small; detail does.) + if row.raw_json: + try: + from cyclone.parsers.models_999 import ParseResult999 + regenerated = ParseResult999.model_validate(row.raw_json) + icn = regenerated.envelope.control_number or "000000001" + body["raw_999_text"] = serialize_999(regenerated, interchange_control_number=icn) + except Exception as exc: # noqa: BLE001 — never 500 on a regen failure + log.warning("Could not regenerate 999 for ack %s: %s", ack_id, exc) + body["raw_999_text"] = None + else: + body["raw_999_text"] = None return body diff --git a/backend/tests/test_api_999.py b/backend/tests/test_api_999.py index 61ce51c..e3d69d5 100644 --- a/backend/tests/test_api_999.py +++ b/backend/tests/test_api_999.py @@ -108,3 +108,35 @@ def test_parse_999_rejected_fixture_surfaces_r_code(client: TestClient): assert body["ack"]["ack_code"] == "R" assert body["ack"]["rejected_count"] == 1 assert body["ack"]["accepted_count"] == 0 + + +def test_get_ack_detail_regenerates_x12_text(client: TestClient) -> None: + """SP3 P3 follow-up: GET /api/acks/{id} must include `raw_999_text` + regenerated from raw_json so the operator can actually download the + 999 file. The P3 subagent shipped `raw_999_text: None` as a stub.""" + text = REJECTED.read_text() + post_resp = client.post( + "/api/parse-999", + files={"file": ("rejected_999.txt", text, "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert post_resp.status_code == 200 + ack_id = post_resp.json()["ack"]["id"] + detail_resp = client.get(f"/api/acks/{ack_id}", headers={"Accept": "application/json"}) + assert detail_resp.status_code == 200, detail_resp.text + body = detail_resp.json() + # raw_999_text must be present and non-empty X12. + assert "raw_999_text" in body + assert body["raw_999_text"] is not None + assert body["raw_999_text"].startswith("ISA*") + assert "AK1*" in body["raw_999_text"] + assert "AK9*" in body["raw_999_text"] + # IEA segment exists in output; serializer writes `IEA*1*~`. + assert "IEA*" in body["raw_999_text"] + assert body["raw_999_text"].rstrip().endswith("~") + + +def test_get_ack_404_for_missing(client: TestClient) -> None: + """GET /api/acks/{id} returns 404 for a missing id (not 500).""" + resp = client.get("/api/acks/9999", headers={"Accept": "application/json"}) + assert resp.status_code == 404