diff --git a/backend/src/cyclone/api_routers/rebill.py b/backend/src/cyclone/api_routers/rebill.py index a5277d5..6735162 100644 --- a/backend/src/cyclone/api_routers/rebill.py +++ b/backend/src/cyclone/api_routers/rebill.py @@ -38,6 +38,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, ConfigDict, Field, field_validator from cyclone.auth.deps import matrix_gate +from cyclone.rebill.run import run_rebill log = logging.getLogger(__name__) @@ -133,8 +134,6 @@ def post_rebill(req: RebillRequest) -> dict[str, Any]: envelope with CORS headers. Per-file failures land in the summary CSV (and the ``counts`` dict) rather than raising here. """ - from cyclone.rebill.run import run_rebill # local import — keeps --help fast - start_str, end_str = req.window.split("..", 1) # Validator already proved these parse cleanly. window_start = date.fromisoformat(start_str) @@ -212,11 +211,11 @@ def get_rebill_status( counts: dict[str, int] = {} with summary_path.open(newline="") as f: for row in csv.DictReader(f): - disp = (row.get("disposition") or "UNKNOWN").strip() or "UNKNOWN" + disp = (row.get("disposition", "UNKNOWN") or "").strip() or "UNKNOWN" counts[disp] = counts.get(disp, 0) + 1 recent.append({ "as_of": d.name, "summary_path": str(summary_path), "counts": counts, }) - return {"recent_runs": recent} \ No newline at end of file + return {"recent_runs": recent} diff --git a/backend/tests/test_api_rebill.py b/backend/tests/test_api_rebill.py index 5a7c974..b0fb034 100644 --- a/backend/tests/test_api_rebill.py +++ b/backend/tests/test_api_rebill.py @@ -190,4 +190,37 @@ def test_get_rebill_status_returns_recent_runs(client, tmp_path, monkeypatch): 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 \ No newline at end of file + assert counts.get("EXCLUDED_TIMELY_FILING") == 1, counts + + +def test_post_rebill_rejects_malformed_window(client): + """Pydantic window validator should 422 on bad shapes (no `..`, non-dates, reversed).""" + bad_windows = [ + "2026-01-01", # no `..` separator + "foo..bar", # non-ISO dates + "2026-12-01..2026-01-01", # start after end + ] + for w in bad_windows: + resp = client.post( + "/api/admin/rebill-from-835", + json={"window": w}, + ) + assert resp.status_code == 422, ( + f"expected 422 for window={w!r}, got {resp.status_code}: {resp.text}" + ) + + +def test_get_rebill_status_returns_empty_when_no_runs(client, tmp_path, monkeypatch): + """GET /status on an empty rebills dir must return {"recent_runs": []}, not 500. + + Monkeypatches REBILLS_DIR to a fresh tmp_path subdir so the test + doesn't depend on (or pollute) any pre-existing dev/rebills/ tree. + """ + from cyclone.api_routers import rebill + + monkeypatch.setattr(rebill, "REBILLS_DIR", tmp_path / "rebills_empty") + + resp = client.get("/api/admin/rebill-from-835/status") + assert resp.status_code == 200, resp.text + assert resp.json() == {"recent_runs": []} +