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