From 86e69d5b950dc05ba789d6e29cb7884943ff09aa Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 19:25:23 -0600 Subject: [PATCH] feat(backend): wire parse-837/parse-835 to persist to InMemoryStore --- backend/src/cyclone/api.py | 26 ++++++++ backend/tests/test_api_parse_persists.py | 81 ++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 backend/tests/test_api_parse_persists.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 875fea4..c2e59e4 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -17,6 +17,7 @@ from __future__ import annotations import json import logging +import uuid from typing import Any, Iterator from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile @@ -32,6 +33,7 @@ from cyclone.parsers.payer import PayerConfig, PayerConfig835 from cyclone.parsers.parse_837 import parse from cyclone.parsers.parse_835 import parse as parse_835 from cyclone.parsers.validator_835 import validate as validate_835 +from cyclone.store import BatchRecord, store, utcnow log = logging.getLogger(__name__) @@ -195,11 +197,24 @@ async def parse_837( if _has_claim_validation_errors(result): # Per spec: 422 when claims failed validation. # Body still includes the full ParseResult so the client can show errors. + # Validation-failed parses are NOT persisted (the data is suspect); + # only parses that survive validation end up in the store. return JSONResponse( status_code=422, content=json.loads(result.model_dump_json()), ) + # Persist the cleaned-up result so the cleaned result is what clients + # retrieve via /api/batches/{id}. + rec = BatchRecord( + id=uuid.uuid4().hex, + kind="837p", + input_filename=file.filename or "upload.txt", + parsed_at=utcnow(), + result=result, + ) + store.add(rec) + if _client_wants_json(request): return JSONResponse(content=json.loads(result.model_dump_json())) @@ -321,6 +336,17 @@ async def parse_835_endpoint( content=json.loads(result.model_dump_json()), ) + # Persist the cleaned-up result so the cleaned result is what clients + # retrieve via /api/batches/{id}. + rec = BatchRecord( + id=uuid.uuid4().hex, + kind="835", + input_filename=file.filename or "upload.txt", + parsed_at=utcnow(), + result=result, + ) + store.add(rec) + if _client_wants_json(request): return JSONResponse(content=json.loads(result.model_dump_json())) diff --git a/backend/tests/test_api_parse_persists.py b/backend/tests/test_api_parse_persists.py new file mode 100644 index 0000000..daede6c --- /dev/null +++ b/backend/tests/test_api_parse_persists.py @@ -0,0 +1,81 @@ +"""Successful parses must persist to the store; failed parses must not.""" + +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 + +FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt" + + +@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_successful_parse_837_creates_batch(client: TestClient): + assert len(global_store.list()) == 0 + text = FIXTURE.read_text() + resp = client.post( + "/api/parse-837", + files={"file": ("test.txt", text, "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert resp.status_code == 200, resp.text + assert len(global_store.list()) == 1 + rec = global_store.list()[0] + assert rec.kind == "837p" + assert rec.input_filename == "test.txt" + + +def test_failed_parse_837_does_not_create_batch(client: TestClient): + """An empty / garbage file should NOT create a batch.""" + assert len(global_store.list()) == 0 + resp = client.post( + "/api/parse-837", + files={"file": ("garbage.txt", "not-edi", "text/plain")}, + headers={"Accept": "application/json"}, + ) + # Either a 4xx from the parser, or a 200 with 0 claims — the contract + # is that NO batch is added either way. + assert resp.status_code in (200, 400, 422) + assert len(global_store.list()) == 0 + + +def test_validation_failed_837_does_not_create_batch(client: TestClient): + """A parse that produced claims with validation errors (422) must not be stored. + + The spec only mandates "do not store on CycloneParseError or unhandled + exception"; we choose the stricter interpretation that 422 validation + failures are also excluded so the data on disk is always clean. + """ + # Construct a minimal but technically malformed 837P that the parser + # will accept but validation will reject. We inject a wrong NPI + # length to trigger the per-claim NPI validation rule. + text = FIXTURE.read_text() + # Replace the provider NPI (XX*1881068062) with an invalid 8-digit NPI + # (the rule is NPI must be 10 digits). This keeps the parse step + # green while failing claim validation. + text = text.replace("XX*1881068062", "XX*12345678") + resp = client.post( + "/api/parse-837", + files={"file": ("bad.txt", text, "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert resp.status_code == 422, resp.text + assert len(global_store.list()) == 0