Files
cyclone/backend/tests/test_unexpected_error_outcome.py
Nora abaf23c122 fix(sp37-followup): SubmitOutcome.UNEXPECTED_ERROR for non-typed failures
Followup #3 from the SP37 final-state tracker. The router's
per-file try/except previously coerced every unexpected exception
to SubmitOutcome.SFTP_FAILED — misleading because a RuntimeError
from cycl_store.add has nothing to do with SFTP.

Changes:
  * submission/result.py — add UNEXPECTED_ERROR = 'unexpected_error'
    enum value. Docstring distinguishes it from the typed SFTP_FAILED.
  * api_routers/submission.py — exception handler now returns
    UNEXPECTED_ERROR (was SFTP_FAILED).
  * test_api_submit_batch.py::test_submit_batch_unexpected_exception_recorded_in_results
    — updated assertion (was sftp_failed, now unexpected_error).
  * test_unexpected_error_outcome.py (new, 4 tests):
    - enum value exists
    - distinct from typed failure values
    - API surfaces 'unexpected_error' for uncaught exceptions
    - typed SFTP_FAILED path still surfaces 'sftp_failed' (regression lock)

Tests: 40/40 pass in 1.41s (full SP37 chain + new file).
Live curl: POST /api/submit-batch in stub mode → HTTP 409 (unchanged
behavior, no regression on the config-level guards).
2026-07-07 12:19:42 -06:00

139 lines
5.0 KiB
Python

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