feat(sp37): POST /api/submit-batch endpoint
Thin HTTP wrapper around `cyclone.submission.submit_file` — the
HTTP twin of the `cyclone submit-batch` CLI (SP37 Task 5). Same
walker pattern, same `._*` AppleDouble skip, same `limit`
semantics, same per-file outcomes; both surfaces now agree on DB +
SFTP state byte-for-byte.
Body: `{ingest_dir, validate?, actor?, limit?}`. Response:
`{submitted, skipped, failed, results:[{file, outcome, batch_id,
error}]}`. Status codes:
- 200 on completed runs (per-file failures live in the body)
- 401 unauthenticated (matrix_gate)
- 404 no clearhouse seeded
- 409 clearhouse SFTP block in stub mode
- 422 ingest_dir missing on disk or Pydantic body error
Does NOT inject `sftp_client_factory` — submit_file uses its
default paramiko factory so SKIPPED is reachable in production.
Tests monkey-patch submit_file itself.
Permissions entry: `POST /api/submit-batch` → WRITE_ROLES (admin +
user), matching `POST /api/resubmit` posture.
Tests (11): happy path, AppleDouble skip, limit, empty ingest_dir
422, mixed-outcome 200, unexpected-exception-as-failed,
Pydantic-missing-422, on-disk-missing-422, no-clearhouse-404,
stub-mode-409, auth-gate-401.
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
"""SP37 Task 6: POST /api/submit-batch endpoint.
|
||||
|
||||
Thin HTTP wrapper around ``cyclone.submission.submit_file``. Mirrors the
|
||||
``cyclone submit-batch`` CLI walker exactly: each ``batch-*-claims/*.x12``
|
||||
under ``ingest_dir`` is submitted in order, ``._*`` AppleDouble files are
|
||||
skipped, ``limit`` truncates after collection.
|
||||
|
||||
Tests monkey-patch ``cyclone.api_routers.submission.submit_file`` so we
|
||||
never touch the real paramiko factory or a live MFT.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone.submission.result import SubmitOutcome, SubmitResult
|
||||
|
||||
_FIXTURE = Path(__file__).parent / "fixtures" / "submit-batch" / "single-claim.x12"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path) -> TestClient:
|
||||
"""Standard TestClient; conftest.py autouse handles DB + auth gate.
|
||||
|
||||
Does NOT touch env vars (per the plan-vs-reality correction in
|
||||
Task 6's instructions). Seeds the clearhouse then flips
|
||||
``sftp_block.stub`` to ``False`` so the file-walking tests reach
|
||||
the walker (otherwise the default-seeded stub=True trips the 409
|
||||
guard before any per-file work runs). The ``stub_mode`` test
|
||||
re-seeds stub=True explicitly so its 409 still fires.
|
||||
"""
|
||||
from cyclone import db as db_mod
|
||||
from cyclone.store import store as cycl_store
|
||||
|
||||
db_mod._reset_for_tests()
|
||||
db_mod.init_db()
|
||||
cycl_store.ensure_clearhouse_seeded()
|
||||
ch = cycl_store.get_clearhouse()
|
||||
# Flip stub=False so the walker actually runs (SFTP calls are
|
||||
# intercepted via the submit_file monkey-patch in each test).
|
||||
cycl_store.update_clearhouse(
|
||||
ch.model_copy(update={"sftp_block": ch.sftp_block.model_copy(update={"stub": False})}),
|
||||
)
|
||||
|
||||
from cyclone.api import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _stage_batch(tmp_path: Path, n: int) -> Path:
|
||||
"""Copy N copies of the single-claim fixture into ``batch-test-claims/``."""
|
||||
batch_dir = tmp_path / "batch-test-claims"
|
||||
batch_dir.mkdir()
|
||||
payload = _FIXTURE.read_bytes()
|
||||
for i in range(n):
|
||||
(batch_dir / f"claim-{i}.x12").write_bytes(payload)
|
||||
return batch_dir
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Happy path + walker semantics
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_submit_batch_happy_path(client: TestClient, tmp_path, monkeypatch):
|
||||
"""3 valid 837 files → 200 + body shape with submitted=3 (or skipped=3 on re-run).
|
||||
|
||||
Monkey-patches ``cyclone.api_routers.submission.submit_file`` to
|
||||
return a synthetic SUBMITTED SubmitResult per file. Skipped stays
|
||||
possible because SubmitResult's batch_id is what matters and the
|
||||
fake returns it consistently — but a fresh DB should always land
|
||||
in the SUBMITTED branch.
|
||||
"""
|
||||
_stage_batch(tmp_path, 3)
|
||||
|
||||
def _fake_submit(path, **_kw):
|
||||
return SubmitResult(
|
||||
file=path.name, outcome=SubmitOutcome.SUBMITTED, batch_id="b1",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"cyclone.api_routers.submission.submit_file", _fake_submit,
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/submit-batch",
|
||||
json={"ingest_dir": str(tmp_path), "validate": False},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
# Tolerate either counter — counter totals are deterministic with a
|
||||
# fresh DB, but the assertion below focuses on the structural shape
|
||||
# the operator cares about: every file is accounted for.
|
||||
assert body["submitted"] + body["skipped"] + body["failed"] == 3
|
||||
assert isinstance(body["results"], list)
|
||||
assert len(body["results"]) == 3
|
||||
for row in body["results"]:
|
||||
assert "file" in row
|
||||
assert "outcome" in row
|
||||
assert "batch_id" in row
|
||||
assert "error" in row
|
||||
|
||||
|
||||
def test_submit_batch_skips_apple_double_files(client: TestClient, tmp_path, monkeypatch):
|
||||
"""``._foo.x12`` AppleDouble files must be skipped (mirrors the CLI walker)."""
|
||||
batch_dir = tmp_path / "batch-test-claims"
|
||||
batch_dir.mkdir()
|
||||
(batch_dir / "real.x12").write_bytes(_FIXTURE.read_bytes())
|
||||
(batch_dir / "._real.x12").write_bytes(b"\x00\x01\x02") # macOS AppleDouble noise
|
||||
|
||||
def _fake_submit(path, **_kw):
|
||||
return SubmitResult(
|
||||
file=path.name, outcome=SubmitOutcome.SUBMITTED, batch_id="b1",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"cyclone.api_routers.submission.submit_file", _fake_submit,
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/submit-batch",
|
||||
json={"ingest_dir": str(tmp_path), "validate": False},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
# Only real.x12 was submitted (one call); AppleDouble was skipped.
|
||||
assert len(body["results"]) == 1
|
||||
assert body["results"][0]["file"] == "real.x12"
|
||||
|
||||
|
||||
def test_submit_batch_limit_truncates(client: TestClient, tmp_path, monkeypatch):
|
||||
"""``limit=1`` against 3 files → body has exactly 1 result."""
|
||||
_stage_batch(tmp_path, 3)
|
||||
|
||||
submitted_files: list[str] = []
|
||||
|
||||
def _fake_submit(path, **_kw):
|
||||
submitted_files.append(path.name)
|
||||
return SubmitResult(
|
||||
file=path.name, outcome=SubmitOutcome.SUBMITTED, batch_id="b1",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"cyclone.api_routers.submission.submit_file", _fake_submit,
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/submit-batch",
|
||||
json={"ingest_dir": str(tmp_path), "validate": False, "limit": 1},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert len(body["results"]) == 1
|
||||
assert body["submitted"] + body["skipped"] + body["failed"] == 1
|
||||
assert len(submitted_files) == 1
|
||||
|
||||
|
||||
def test_submit_batch_empty_ingest_dir_returns_422(client: TestClient, tmp_path):
|
||||
"""No batch-*-claims dir under ingest_dir → 422 (matches the CLI's exit 2 path)."""
|
||||
resp = client.post(
|
||||
"/api/submit-batch",
|
||||
json={"ingest_dir": str(tmp_path), "validate": False},
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
def test_submit_batch_per_file_failure_keeps_status_200(
|
||||
client: TestClient, tmp_path, monkeypatch,
|
||||
):
|
||||
"""A SUBMITTED + SFTP_FAILED mix → 200 with failed=1, submitted=1.
|
||||
|
||||
Per-file failures live in the JSON body, NOT in the status code.
|
||||
The endpoint always returns 200 on a completed run.
|
||||
"""
|
||||
_stage_batch(tmp_path, 2)
|
||||
|
||||
outcomes = iter([
|
||||
SubmitResult(file="claim-0.x12", outcome=SubmitOutcome.SUBMITTED, batch_id="b1"),
|
||||
SubmitResult(
|
||||
file="claim-1.x12", outcome=SubmitOutcome.SFTP_FAILED,
|
||||
batch_id="b2", error="sftp down",
|
||||
),
|
||||
])
|
||||
|
||||
def _fake_submit(path, **_kw):
|
||||
return next(outcomes)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"cyclone.api_routers.submission.submit_file", _fake_submit,
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/submit-batch",
|
||||
json={"ingest_dir": str(tmp_path), "validate": False},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["submitted"] == 1
|
||||
assert body["failed"] == 1
|
||||
assert body["skipped"] == 0
|
||||
assert len(body["results"]) == 2
|
||||
# The failure row carries the error string so the operator can see
|
||||
# what went wrong without a separate API call.
|
||||
assert body["results"][1]["outcome"] == "sftp_failed"
|
||||
assert body["results"][1]["error"] == "sftp down"
|
||||
|
||||
|
||||
def test_submit_batch_unexpected_exception_recorded_in_results(
|
||||
client: TestClient, tmp_path, monkeypatch,
|
||||
):
|
||||
"""If submit_file raises, the file lands in results[] as a failed row.
|
||||
|
||||
The router catches the exception, builds a SubmitResult with
|
||||
``outcome=SubmitOutcome.SFTP_FAILED`` (per Task 6's instruction
|
||||
that "any enum value works as a marker; choose whichever is least
|
||||
misleading"), and counts it as ``failed``. The full error string
|
||||
(including exception class name) is in ``error`` so the operator
|
||||
can tell it was a real exception, not a typed failure path.
|
||||
"""
|
||||
_stage_batch(tmp_path, 1)
|
||||
|
||||
def _explode(path, **_kw):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"cyclone.api_routers.submission.submit_file", _explode,
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/submit-batch",
|
||||
json={"ingest_dir": str(tmp_path), "validate": False},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["failed"] == 1
|
||||
# The marker is the SFTP_FAILED enum value (per Task 6 design
|
||||
# choice). The full exception class+message is in ``error`` so the
|
||||
# operator can distinguish a real exception from a typed failure.
|
||||
assert body["results"][0]["outcome"] == "sftp_failed"
|
||||
assert "kaboom" in body["results"][0]["error"]
|
||||
assert "RuntimeError" in body["results"][0]["error"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Request validation
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_submit_batch_missing_ingest_dir_returns_422(client: TestClient):
|
||||
"""Body without ``ingest_dir`` → 422 from Pydantic (NOT your manual check)."""
|
||||
resp = client.post("/api/submit-batch", json={"validate": False})
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
def test_submit_batch_ingest_dir_missing_on_disk_returns_422(
|
||||
client: TestClient, tmp_path,
|
||||
):
|
||||
"""Body has a path that doesn't exist on disk → 422."""
|
||||
resp = client.post(
|
||||
"/api/submit-batch",
|
||||
json={
|
||||
"ingest_dir": str(tmp_path / "does-not-exist"),
|
||||
"validate": False,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
assert "does not exist" in resp.text or "does-not-exist" in resp.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Clearhouse posture (404 / 409)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_submit_batch_no_clearhouse_returns_404(client: TestClient, tmp_path, monkeypatch):
|
||||
"""Delete the clearhouse row → endpoint must refuse with 404, not 500.
|
||||
|
||||
404 per the Task 6 status code contract: a missing clearhouse is a
|
||||
config-level "missing" (4xx), not an unexpected exception.
|
||||
"""
|
||||
from cyclone import db as db_mod
|
||||
from cyclone.db import ClearhouseORM
|
||||
|
||||
# Wipe the seeded clearhouse row.
|
||||
with db_mod.SessionLocal()() as s:
|
||||
row = s.get(ClearhouseORM, 1)
|
||||
if row is not None:
|
||||
s.delete(row)
|
||||
s.commit()
|
||||
|
||||
_stage_batch(tmp_path, 1)
|
||||
|
||||
resp = client.post(
|
||||
"/api/submit-batch",
|
||||
json={"ingest_dir": str(tmp_path), "validate": False},
|
||||
)
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert "clearhouse" in resp.text.lower()
|
||||
|
||||
|
||||
def test_submit_batch_stub_mode_returns_409(client: TestClient, tmp_path, monkeypatch):
|
||||
"""When the seeded clearhouse's ``sftp_block.stub`` is True → 409.
|
||||
|
||||
The default client fixture flips stub=False so file-walking tests
|
||||
reach the walker. This test flips it back to True so the
|
||||
stub-mode 409 guard fires before any per-file work runs.
|
||||
"""
|
||||
from cyclone.store import store as cycl_store
|
||||
|
||||
ch = cycl_store.get_clearhouse()
|
||||
assert ch is not None
|
||||
# Re-flip stub=True (the default client fixture set it to False).
|
||||
cycl_store.update_clearhouse(
|
||||
ch.model_copy(update={
|
||||
"sftp_block": ch.sftp_block.model_copy(update={"stub": True}),
|
||||
}),
|
||||
)
|
||||
|
||||
_stage_batch(tmp_path, 1)
|
||||
|
||||
resp = client.post(
|
||||
"/api/submit-batch",
|
||||
json={"ingest_dir": str(tmp_path), "validate": False},
|
||||
)
|
||||
assert resp.status_code == 409, resp.text
|
||||
assert "stub" in resp.text.lower()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Auth gate
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_submit_batch_auth_gate(client: TestClient, tmp_path, monkeypatch):
|
||||
"""No cookie + AUTH_DISABLED=False → 401 from matrix_gate.
|
||||
|
||||
The conftest autouse fixture sets ``AUTH_DISABLED = True`` at
|
||||
fixture setup and resets to False at teardown. Inside the test we
|
||||
flip the module attribute so the gate fires — but to avoid leaking
|
||||
the flip into a sibling test we use monkeypatch.setattr (which
|
||||
restores at test teardown, before conftest's finally block runs).
|
||||
"""
|
||||
import cyclone.auth.deps as auth_deps
|
||||
|
||||
monkeypatch.setattr(auth_deps, "AUTH_DISABLED", False)
|
||||
|
||||
_stage_batch(tmp_path, 1)
|
||||
|
||||
resp = client.post(
|
||||
"/api/submit-batch",
|
||||
json={"ingest_dir": str(tmp_path), "validate": False},
|
||||
)
|
||||
assert resp.status_code == 401, resp.text
|
||||
Reference in New Issue
Block a user