"""SP37 follow-up #3: SubmitOutcome.UNEXPECTED_ERROR for non-typed failures. The router's per-file try/except previously coerced every unexpected exception to ``SubmitOutcome.SFTP_FAILED`` because the helper had no "unknown failure" enum value. That was misleading — a ``RuntimeError`` from ``cycl_store.add`` (e.g. ``RuntimeError("db down")``) is definitely not an SFTP failure. This test pins the new behavior: ``SubmitOutcome.UNEXPECTED_ERROR`` exists, the API surfaces it under the JSON key ``"unexpected_error"``, and the typed SFTP_FAILED path is reserved for actual SFTP failures. """ from __future__ import annotations from pathlib import Path import pytest from fastapi.testclient import TestClient _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. Seeds the clearhouse then flips sftp_block.stub to False so the file-walking tests reach the walker. Matches the fixture shape in test_api_submit_batch.py — local because only these two test files need it (no conftest move yet). """ 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() 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 test_unexpected_error_enum_value_exists(): """The enum MUST have UNEXPECTED_ERROR so the API can distinguish a non-typed exception from a real SFTP failure. """ from cyclone.submission.result import SubmitOutcome assert hasattr(SubmitOutcome, "UNEXPECTED_ERROR") assert SubmitOutcome.UNEXPECTED_ERROR.value == "unexpected_error" def test_unexpected_error_is_distinct_from_typed_failures(): """The new value must NOT collide with any existing failure value.""" from cyclone.submission.result import SubmitOutcome # Compare against the typed failure values only (not UNEXPECTED_ERROR # itself, which would trivially be in any set including it). typed_failure_values = { SubmitOutcome.SUBMITTED.value, SubmitOutcome.SKIPPED.value, SubmitOutcome.PARSE_FAILED.value, SubmitOutcome.PAYER_MISMATCH.value, SubmitOutcome.DB_FAILED.value, SubmitOutcome.SFTP_FAILED.value, } assert SubmitOutcome.UNEXPECTED_ERROR.value not in typed_failure_values def test_router_maps_unexpected_exception_to_unexpected_error(client: TestClient, monkeypatch, tmp_path): """If submit_file raises an unexpected exception, the API surfaces ``outcome="unexpected_error"`` (not ``"sftp_failed"``). """ from cyclone.submission.result import SubmitResult, SubmitOutcome # Stage one file so the walker has something to submit. batch_dir = tmp_path / "batch-test-claims" batch_dir.mkdir() (batch_dir / "claim-0.x12").write_bytes(_FIXTURE.read_bytes()) 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 operator-visible outcome MUST be "unexpected_error", not "sftp_failed". assert body["results"][0]["outcome"] == SubmitOutcome.UNEXPECTED_ERROR.value # The full exception class + message stays in ``error`` so the # operator can tell it was a real exception, not a typed failure. assert "kaboom" in body["results"][0]["error"] assert "RuntimeError" in body["results"][0]["error"] def test_typed_sftp_failure_still_uses_sftp_failed(client: TestClient, monkeypatch, tmp_path): """Typed SFTP failure (returned by submit_file) MUST still surface as ``outcome="sftp_failed"`` — the new enum value is for *unexpected* exceptions only, not the typed SFTP_FAILED path. """ from cyclone.submission.result import SubmitResult, SubmitOutcome batch_dir = tmp_path / "batch-test-claims" batch_dir.mkdir() (batch_dir / "claim-0.x12").write_bytes(_FIXTURE.read_bytes()) def _fake_submit(path, **_kw): return SubmitResult( file=path.name, outcome=SubmitOutcome.SFTP_FAILED, error="sftp connection refused", ) 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["failed"] == 1 assert body["results"][0]["outcome"] == SubmitOutcome.SFTP_FAILED.value assert body["results"][0]["error"] == "sftp connection refused"