feat(sp41): POST/GET /api/admin/rebill-from-835 (auth-gated)
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""SP41 Task 11 — HTTP endpoints for the in-window rebill pipeline.
|
||||
|
||||
Three tests:
|
||||
|
||||
1. ``test_post_rebill_requires_auth_or_returns_404_or_422_when_no_input`` —
|
||||
bare POST with no body. With ``AUTH_DISABLED = True`` (the autouse
|
||||
conftest default) the request reaches the handler; without a body,
|
||||
Pydantic returns 422. If AUTH_DISABLED is ever flipped off, the
|
||||
matrix_gate returns 401/403 first. Either outcome proves the route
|
||||
is wired.
|
||||
|
||||
2. ``test_post_rebill_runs_and_returns_summary_path`` — happy path.
|
||||
Builds a 1-row visits CSV + zero-SVC 835 fixture under tmp_path,
|
||||
POSTs with the window pinned + paths pointed at the fixtures,
|
||||
asserts 200 + ``summary_path`` + a ``counts`` dict that includes
|
||||
the REBILLED_B key (the in-window visit lands as NOT_IN_835 →
|
||||
REBILLED_B because the 835 has no SVCs and the visit is fresh
|
||||
enough to clear the 120-day gate).
|
||||
|
||||
3. ``test_get_rebill_status_returns_recent_runs`` — seeds a stub
|
||||
``summary.csv`` under ``tmp_path / "rebills_root"`` and
|
||||
monkey-patches :data:`cyclone.api_routers.rebill.REBILLS_DIR` to
|
||||
point there, so the GET handler's filesystem scan finds it
|
||||
without the test having to chdir the whole process.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
"""Standard TestClient; conftest.py autouse handles DB + auth gate.
|
||||
|
||||
AUTH_DISABLED is True so the matrix_gate short-circuits and the
|
||||
request reaches the handler (no login dance required). The seeded
|
||||
clearhouse isn't needed — the rebill pipeline doesn't touch SFTP.
|
||||
"""
|
||||
from cyclone.api import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _write_visits_csv(path: Path, rows: list[tuple[str, str, str, str]]) -> Path:
|
||||
"""rows: list of (dos_mmddyyyy, member, procedure, billed_str)."""
|
||||
with path.open("w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["Visit Date", "Member ID", "Procedure Code", "Billable Amount"])
|
||||
for r in rows:
|
||||
w.writerow(r)
|
||||
return path
|
||||
|
||||
|
||||
def _stub_835(ingest_dir: Path, name: str = "x.835") -> Path:
|
||||
"""An *.835 file parseable enough to not crash the walker.
|
||||
|
||||
Content is just an empty 835 envelope; the rebill pipeline's
|
||||
``parse_835_svc`` reparser emits zero SVCs, which is what we want
|
||||
for the NOT_IN_835 → REBILLED_B classification.
|
||||
"""
|
||||
ingest_dir.mkdir(exist_ok=True)
|
||||
p = ingest_dir / name
|
||||
p.write_text("ST*835*0001~SE*0*0001~")
|
||||
return p
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tests
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_post_rebill_requires_auth_or_returns_404_or_422_when_no_input(client):
|
||||
"""Bare POST with no body — should NOT 500.
|
||||
|
||||
With AUTH_DISABLED the matrix_gate short-circuits and Pydantic
|
||||
validation fires on the empty body (422). With auth enabled the
|
||||
matrix_gate returns 401/403 first. Either outcome proves the route
|
||||
is wired and the request reached the handler.
|
||||
"""
|
||||
resp = client.post("/api/admin/rebill-from-835")
|
||||
assert resp.status_code in (401, 403, 422), (
|
||||
f"unexpected status {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
def test_post_rebill_runs_and_returns_summary_path(client, tmp_path):
|
||||
"""Happy path: 1 in-window visit, 0 SVCs → REBILLED_B + summary.csv."""
|
||||
visits_path = _write_visits_csv(tmp_path / "visits.csv", [
|
||||
("06/27/2026", "J813715", "T1019", "$2.32"),
|
||||
])
|
||||
ingest_dir = _stub_835(tmp_path / "ingest")
|
||||
out_dir = tmp_path / "out"
|
||||
|
||||
resp = client.post(
|
||||
"/api/admin/rebill-from-835",
|
||||
json={
|
||||
"window": "2026-01-01..2026-06-27",
|
||||
"override_filing": False,
|
||||
"visits_csv_path": str(visits_path),
|
||||
"ingest_dir": str(ingest_dir),
|
||||
"out_dir": str(out_dir),
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "summary_path" in body, body
|
||||
assert "counts" in body, body
|
||||
assert "pipeline_a_files" in body, body
|
||||
assert "pipeline_b_files" in body, body
|
||||
|
||||
# summary_path is real and points at the out_dir we passed in.
|
||||
assert Path(body["summary_path"]).exists(), body["summary_path"]
|
||||
assert str(out_dir) in body["summary_path"]
|
||||
|
||||
# The counts dict surfaces REBILLED_B for the in-window visit (the
|
||||
# 835 has no SVCs so the visit falls into NOT_IN_835 → REBILLED_B
|
||||
# — it's well within the 120-day gate).
|
||||
counts = body["counts"]
|
||||
assert "REBILLED_B" in counts, counts
|
||||
assert counts["REBILLED_B"] >= 1, counts
|
||||
|
||||
# Pipeline B emitted exactly one file for the one REBILLED_B visit.
|
||||
assert len(body["pipeline_b_files"]) == 1, body["pipeline_b_files"]
|
||||
|
||||
# The summary CSV header matches the writer's contract so the GET
|
||||
# /status handler can parse it.
|
||||
with open(body["summary_path"], newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
rows = list(reader)
|
||||
assert len(rows) == 1, rows
|
||||
assert rows[0]["disposition"] == "REBILLED_B"
|
||||
assert rows[0]["member_id"] == "J813715"
|
||||
|
||||
|
||||
def test_get_rebill_status_returns_recent_runs(client, tmp_path, monkeypatch):
|
||||
"""GET /status scans the configured REBILLS_DIR and tallies per-disposition counts.
|
||||
|
||||
The handler reads :data:`cyclone.api_routers.rebill.REBILLS_DIR`
|
||||
(module-level so tests can monkeypatch it). We point that at a
|
||||
tmp_path-relative tree, seed one dated subdir with a 2-row
|
||||
summary.csv, and assert the response surfaces that directory's
|
||||
name + per-disposition tally.
|
||||
"""
|
||||
from cyclone.api_routers import rebill
|
||||
|
||||
# Build a date-named subdir with a real summary.csv shape so the
|
||||
# handler's csv.DictReader finds a `disposition` column.
|
||||
rebills_root = tmp_path / "rebills_root"
|
||||
dated_dir = rebills_root / "2026-07-07"
|
||||
dated_dir.mkdir(parents=True)
|
||||
summary = dated_dir / "summary.csv"
|
||||
with summary.open("w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow([
|
||||
"dos", "member_id", "procedure", "billed",
|
||||
"disposition", "unpaid", "cas_reasons", "file_path",
|
||||
])
|
||||
w.writerow([
|
||||
"2026-06-27", "MEM-A", "T1019", "2.32",
|
||||
"REBILLED_B", "2.32", "", "pipeline-b/",
|
||||
])
|
||||
w.writerow([
|
||||
"2020-01-01", "MEM-B", "T1019", "2.32",
|
||||
"EXCLUDED_TIMELY_FILING", "2.32", "", "",
|
||||
])
|
||||
|
||||
# Repoint the handler's filesystem root at our tmp_path tree.
|
||||
monkeypatch.setattr(rebill, "REBILLS_DIR", rebills_root)
|
||||
|
||||
resp = client.get("/api/admin/rebill-from-835/status")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "recent_runs" in body, body
|
||||
assert len(body["recent_runs"]) == 1, body["recent_runs"]
|
||||
|
||||
run = body["recent_runs"][0]
|
||||
assert run["as_of"] == "2026-07-07", run
|
||||
assert "summary_path" in run, run
|
||||
assert Path(run["summary_path"]).exists(), run["summary_path"]
|
||||
counts = run["counts"]
|
||||
# Each disposition surfaced exactly once — matches the 2-row CSV.
|
||||
assert counts.get("REBILLED_B") == 1, counts
|
||||
assert counts.get("EXCLUDED_TIMELY_FILING") == 1, counts
|
||||
Reference in New Issue
Block a user