feat(backend): FastAPI endpoint with NDJSON streaming and CORS for Vite frontend
This commit is contained in:
+87
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Python module + CLI for parsing X12 837P professional claim files into one
|
Python module + CLI for parsing X12 837P professional claim files into one
|
||||||
validated JSON per claim. Output is consumed by the Cyclone Vite/React
|
validated JSON per claim. Output is consumed by the Cyclone Vite/React
|
||||||
frontend (later via a FastAPI service).
|
frontend via a FastAPI service (see **REST API** below).
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -62,6 +62,92 @@ pytest -v
|
|||||||
The CLI smoke test against `docs/prodfiles/837p-from-axiscare/*.txt` is
|
The CLI smoke test against `docs/prodfiles/837p-from-axiscare/*.txt` is
|
||||||
auto-skipped if no production files are present.
|
auto-skipped if no production files are present.
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
The Cyclone Vite/React frontend (default dev origin `http://localhost:5173`)
|
||||||
|
talks to the parser over HTTP. The API module is `cyclone.api:app`.
|
||||||
|
|
||||||
|
### Run the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn cyclone.api:app --reload --port 8000
|
||||||
|
# or, equivalently:
|
||||||
|
python -m cyclone serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| ------ | ---------------- | -------------------------------------------------- |
|
||||||
|
| GET | `/api/health` | Liveness probe: `{"status": "ok", "version": ...}` |
|
||||||
|
| POST | `/api/parse-837` | Upload an X12 file, get parsed claims back |
|
||||||
|
|
||||||
|
`POST /api/parse-837` accepts `multipart/form-data` with a single `file`
|
||||||
|
field. Optional query parameters:
|
||||||
|
|
||||||
|
- `payer` — `co_medicaid` (default) or `generic_837p`
|
||||||
|
- `include_raw_segments` — `true` (default) or `false`
|
||||||
|
- `strict` — `false` (default) or `true` (promote warnings to errors)
|
||||||
|
|
||||||
|
The response shape depends on the request's `Accept` header:
|
||||||
|
|
||||||
|
- `Accept: application/json` → a single `ParseResult`-shaped JSON object
|
||||||
|
(envelope, claims, summary).
|
||||||
|
- No `Accept` header (or anything that isn't `application/json`) →
|
||||||
|
`application/x-ndjson` streaming, one JSON object per line in this order:
|
||||||
|
`{"type": "envelope", "data": {...}}`, one `{"type": "claim", "data": {...}}`
|
||||||
|
per claim, then `{"type": "summary", "data": {...}}`.
|
||||||
|
|
||||||
|
Error responses are JSON of the form `{"error": "...", "detail": "..."}`:
|
||||||
|
|
||||||
|
- `400` — file-level parse error (`CycloneParseError`) or unknown `payer`
|
||||||
|
- `422` — at least one claim failed validation (body still includes the
|
||||||
|
full `ParseResult` so the client can render the errors)
|
||||||
|
- `500` — unexpected internal error
|
||||||
|
|
||||||
|
CORS is configured to allow `http://localhost:5173` with `GET` and `POST`
|
||||||
|
and any headers.
|
||||||
|
|
||||||
|
### Example: upload + JSON response
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/parse-837 \
|
||||||
|
-F "file=@./samples/co_837p.txt" \
|
||||||
|
-H "Accept: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"envelope": { "sender_id": "...", "receiver_id": "...", ... },
|
||||||
|
"claims": [ { "claim_id": "C1", ... }, { "claim_id": "C2", ... } ],
|
||||||
|
"summary": { "total_claims": 2, "passed": 2, "failed": 0, ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: stream NDJSON from the browser
|
||||||
|
|
||||||
|
```js
|
||||||
|
const resp = await fetch("http://localhost:8000/api/parse-837", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData, // FormData with a "file" entry
|
||||||
|
});
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
for (const line of buffer.split("\n")) {
|
||||||
|
if (!line) continue;
|
||||||
|
const evt = JSON.parse(line);
|
||||||
|
if (evt.type === "claim") renderClaim(evt.data);
|
||||||
|
else if (evt.type === "summary") showSummary(evt.data);
|
||||||
|
}
|
||||||
|
buffer = buffer.slice(buffer.lastIndexOf("\n") + 1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Spec & plan
|
## Spec & plan
|
||||||
|
|
||||||
- Design: `docs/superpowers/specs/2026-06-19-cyclone-837p-parser-design.md`
|
- Design: `docs/superpowers/specs/2026-06-19-cyclone-837p-parser-design.md`
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ requires-python = ">=3.11"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"pydantic>=2.6,<3",
|
"pydantic>=2.6,<3",
|
||||||
"click>=8.1,<9",
|
"click>=8.1,<9",
|
||||||
|
"fastapi>=0.110,<1",
|
||||||
|
"uvicorn[standard]>=0.27,<1",
|
||||||
|
"python-multipart>=0.0.9,<1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"pytest-cov>=4.1",
|
"pytest-cov>=4.1",
|
||||||
|
"httpx>=0.27,<1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -1,4 +1,27 @@
|
|||||||
from cyclone.cli import main
|
"""Entry point for ``python -m cyclone``.
|
||||||
|
|
||||||
|
* ``python -m cyclone`` (no args) — Click CLI (``cli.main``)
|
||||||
|
* ``python -m cyclone serve`` — start the FastAPI app on port 8000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
|
||||||
|
# Strip the "serve" subcommand so uvicorn doesn't try to interpret
|
||||||
|
# any additional flags we might forward in the future.
|
||||||
|
sys.argv = [sys.argv[0], "cyclone.api:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.main()
|
||||||
|
return
|
||||||
|
from cyclone.cli import main as cli_main
|
||||||
|
|
||||||
|
cli_main()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""FastAPI HTTP surface for the Cyclone 837P parser.
|
||||||
|
|
||||||
|
The frontend (Vite/React on http://localhost:5173) uploads an X12 file via
|
||||||
|
``POST /api/parse-837`` and receives either:
|
||||||
|
|
||||||
|
* a single ``ParseResult`` JSON object when it sends ``Accept: application/json``,
|
||||||
|
or
|
||||||
|
* a stream of newline-delimited JSON objects (``application/x-ndjson``) — one
|
||||||
|
envelope, one line per claim, then one summary — so the UI can render
|
||||||
|
claims incrementally as they're produced.
|
||||||
|
|
||||||
|
CORS is configured to allow the Vite dev origin (``http://localhost:5173``)
|
||||||
|
plus GET/POST with any header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from cyclone import __version__
|
||||||
|
from cyclone.parsers.exceptions import CycloneParseError
|
||||||
|
from cyclone.parsers.models import ClaimOutput, ParseResult
|
||||||
|
from cyclone.parsers.payer import PayerConfig
|
||||||
|
from cyclone.parsers.parse_837 import parse
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Mirror cli._PAYER_FACTORIES. Kept local so the API doesn't depend on the
|
||||||
|
# Click-based CLI module.
|
||||||
|
PAYER_FACTORIES: dict[str, Any] = {
|
||||||
|
"co_medicaid": PayerConfig.co_medicaid,
|
||||||
|
"generic_837p": PayerConfig.generic_837p,
|
||||||
|
}
|
||||||
|
|
||||||
|
VITE_DEV_ORIGIN = "http://localhost:5173"
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Cyclone 837P Parser API",
|
||||||
|
version=__version__,
|
||||||
|
description="Upload X12 837P files and receive parsed claims as JSON or NDJSON.",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[VITE_DEV_ORIGIN],
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["GET", "POST"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_payer(name: str) -> PayerConfig:
|
||||||
|
if name not in PAYER_FACTORIES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"error": "Unknown payer",
|
||||||
|
"detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES)}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return PAYER_FACTORIES[name]()
|
||||||
|
|
||||||
|
|
||||||
|
def _strict_rewrite(result: ParseResult) -> ParseResult:
|
||||||
|
"""Promote warnings to errors (mirrors the CLI's --strict)."""
|
||||||
|
claims: list[ClaimOutput] = []
|
||||||
|
for claim in result.claims:
|
||||||
|
promoted = [
|
||||||
|
issue.model_copy(update={"severity": "error"})
|
||||||
|
for issue in claim.validation.warnings
|
||||||
|
]
|
||||||
|
new_errors = claim.validation.errors + promoted
|
||||||
|
claims.append(
|
||||||
|
claim.model_copy(
|
||||||
|
update={
|
||||||
|
"validation": claim.validation.model_copy(
|
||||||
|
update={"errors": new_errors, "passed": not new_errors}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
passed = sum(1 for c in claims if c.validation.passed)
|
||||||
|
failed = len(claims) - passed
|
||||||
|
summary = result.summary.model_copy(
|
||||||
|
update={
|
||||||
|
"passed": passed,
|
||||||
|
"failed": failed,
|
||||||
|
"failed_claim_ids": [c.claim_id for c in claims if not c.validation.passed],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result.model_copy(update={"claims": claims, "summary": summary})
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_raw_segments(result: ParseResult) -> ParseResult:
|
||||||
|
"""Return a copy of ``result`` with ``raw_segments`` cleared on every claim."""
|
||||||
|
claims = [c.model_copy(update={"raw_segments": []}) for c in result.claims]
|
||||||
|
return result.model_copy(update={"claims": claims})
|
||||||
|
|
||||||
|
|
||||||
|
def _client_wants_json(request: Request) -> bool:
|
||||||
|
"""Content negotiation: prefer ``application/json`` when the client asks for it.
|
||||||
|
|
||||||
|
NDJSON is the default for browser uploads that don't set ``Accept``. The
|
||||||
|
frontend opts into JSON via ``Accept: application/json``.
|
||||||
|
"""
|
||||||
|
accept = request.headers.get("accept", "")
|
||||||
|
# If the client mentions JSON at all (and isn't asking for NDJSON
|
||||||
|
# specifically) treat it as a single-object request. The browser default
|
||||||
|
# ``*/*`` falls through to NDJSON.
|
||||||
|
if "application/json" in accept and "application/x-ndjson" not in accept:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_claim_validation_errors(result: ParseResult) -> bool:
|
||||||
|
return any(not c.validation.passed for c in result.claims)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok", "version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/parse-837")
|
||||||
|
async def parse_837(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
payer: str = Query("co_medicaid"),
|
||||||
|
include_raw_segments: bool = Query(True),
|
||||||
|
strict: bool = Query(False),
|
||||||
|
) -> Any:
|
||||||
|
raw = await file.read()
|
||||||
|
if not raw:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
text = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"error": "Encoding error", "detail": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
config = _resolve_payer(payer)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = parse(text, config, input_file=file.filename or "")
|
||||||
|
except CycloneParseError as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"error": "Parse error", "detail": str(exc)},
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - safety net
|
||||||
|
log.exception("Unexpected parser failure")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": "Internal server error", "detail": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
if strict:
|
||||||
|
result = _strict_rewrite(result)
|
||||||
|
if not include_raw_segments:
|
||||||
|
result = _drop_raw_segments(result)
|
||||||
|
|
||||||
|
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.
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content=json.loads(result.model_dump_json()),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _client_wants_json(request):
|
||||||
|
return JSONResponse(content=json.loads(result.model_dump_json()))
|
||||||
|
|
||||||
|
# Default: NDJSON stream.
|
||||||
|
return StreamingResponse(
|
||||||
|
_ndjson_stream(result),
|
||||||
|
media_type="application/x-ndjson",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ndjson_stream(result: ParseResult) -> Iterator[bytes]:
|
||||||
|
"""Yield one JSON object per line: envelope → claims → summary."""
|
||||||
|
envelope_obj = (
|
||||||
|
result.envelope.model_dump() if result.envelope is not None else None
|
||||||
|
)
|
||||||
|
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
|
||||||
|
for claim in result.claims:
|
||||||
|
yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
||||||
|
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Tests for the FastAPI surface in ``cyclone.api``.
|
||||||
|
|
||||||
|
All tests use ``fastapi.testclient.TestClient`` — no real network or uvicorn
|
||||||
|
process is started. The fixture file is the same one used by the parser's
|
||||||
|
own end-to-end test (``co_medicaid_837p.txt`` → 2 claims, both pass).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone import __version__
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Health
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_endpoint(client: TestClient):
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body == {"status": "ok", "version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# JSON response path
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_837_endpoint_returns_json(client: TestClient):
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert "envelope" in body
|
||||||
|
assert body["envelope"] is not None
|
||||||
|
assert "claims" in body and len(body["claims"]) == 2
|
||||||
|
assert "summary" in body
|
||||||
|
assert body["summary"]["total_claims"] == 2
|
||||||
|
assert body["summary"]["passed"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# NDJSON streaming path
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_837_endpoint_streams_ndjson(client: TestClient):
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.headers["content-type"].startswith("application/x-ndjson")
|
||||||
|
|
||||||
|
# Consume line-by-line (this is exactly what the React frontend will do).
|
||||||
|
lines = list(resp.iter_lines())
|
||||||
|
# 1 envelope + 2 claims + 1 summary
|
||||||
|
assert len(lines) == 4
|
||||||
|
|
||||||
|
parsed = [json.loads(line) for line in lines]
|
||||||
|
assert parsed[0]["type"] == "envelope"
|
||||||
|
assert parsed[0]["data"] is not None
|
||||||
|
assert parsed[1]["type"] == "claim"
|
||||||
|
assert parsed[2]["type"] == "claim"
|
||||||
|
assert parsed[3]["type"] == "summary"
|
||||||
|
|
||||||
|
# When include_raw_segments defaults to True, each claim carries raw segments.
|
||||||
|
for obj in parsed[1:3]:
|
||||||
|
assert "raw_segments" in obj["data"]
|
||||||
|
assert isinstance(obj["data"]["raw_segments"], list)
|
||||||
|
|
||||||
|
# Summary numbers match the JSON path.
|
||||||
|
assert parsed[3]["data"]["total_claims"] == 2
|
||||||
|
assert parsed[3]["data"]["passed"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-837?include_raw_segments=false",
|
||||||
|
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"].startswith("application/x-ndjson")
|
||||||
|
|
||||||
|
claims = [json.loads(line) for line in resp.iter_lines() if json.loads(line)["type"] == "claim"]
|
||||||
|
assert len(claims) == 2
|
||||||
|
for c in claims:
|
||||||
|
assert c["data"]["raw_segments"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Validation / error paths
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_837_endpoint_rejects_missing_file(client: TestClient):
|
||||||
|
# FastAPI's `File(...)` (no default) → 422 Unprocessable Entity.
|
||||||
|
resp = client.post("/api/parse-837")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_837_endpoint_handles_payer_query_param(client: TestClient):
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
for payer in ("co_medicaid", "generic_837p"):
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/parse-837?payer={payer}",
|
||||||
|
files={"file": ("co_medicaid_837p.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, (payer, resp.text)
|
||||||
|
body = resp.json()
|
||||||
|
assert body["summary"]["passed"] == 2
|
||||||
|
assert body["summary"]["total_claims"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CORS
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_cors_headers_present(client: TestClient):
|
||||||
|
# Simulate a preflight from the Vite dev origin.
|
||||||
|
resp = client.options(
|
||||||
|
"/api/parse-837",
|
||||||
|
headers={
|
||||||
|
"Origin": "http://localhost:5173",
|
||||||
|
"Access-Control-Request-Method": "POST",
|
||||||
|
"Access-Control-Request-Headers": "content-type",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||||
|
assert "POST" in resp.headers.get("access-control-allow-methods", "").upper()
|
||||||
Reference in New Issue
Block a user