generated from john/python-template
Error handling added to MVP according to error_handling.md guideline
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"""Tests for API error response envelope handlers."""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from transcription.api.errors import register_error_handlers
|
||||
from transcription.errors import AppError, ErrorCategory
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestApiErrorResponses:
|
||||
"""Verify API-level error serialization and status mapping."""
|
||||
|
||||
def test_app_error_returns_structured_envelope(self):
|
||||
"""AppError maps to policy envelope fields and status code."""
|
||||
app = FastAPI()
|
||||
register_error_handlers(app)
|
||||
|
||||
@app.get("/boom")
|
||||
def boom() -> dict[str, str]:
|
||||
raise AppError(
|
||||
"Bad upload payload",
|
||||
category=ErrorCategory.VALIDATION,
|
||||
suggestion="Upload a non-empty file",
|
||||
error_id="abc12345",
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/boom")
|
||||
|
||||
assert response.status_code == 400
|
||||
payload = response.json()
|
||||
assert payload["error_id"] == "abc12345"
|
||||
assert payload["category"] == "validation_error"
|
||||
assert payload["message"] == "Bad upload payload"
|
||||
assert payload["suggestion"] == "Upload a non-empty file"
|
||||
assert "timestamp" in payload
|
||||
|
||||
def test_unexpected_error_returns_internal_unexpected_envelope(self):
|
||||
"""Unexpected exceptions map to internal_unexpected_error with 500."""
|
||||
app = FastAPI()
|
||||
register_error_handlers(app)
|
||||
|
||||
@app.get("/explode")
|
||||
def explode() -> dict[str, str]:
|
||||
raise RuntimeError("unexpected failure")
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
response = client.get("/explode")
|
||||
|
||||
assert response.status_code == 500
|
||||
payload = response.json()
|
||||
assert payload["category"] == "internal_unexpected_error"
|
||||
assert "error_id" in payload
|
||||
assert payload["suggestion"]
|
||||
@@ -72,3 +72,5 @@ class TestPipelineFailureFlow:
|
||||
assert transcript is not None
|
||||
assert transcript.text is None
|
||||
assert "pipeline provider failure" in transcript.error_detail
|
||||
assert "[internal_unexpected_error]" in transcript.error_detail
|
||||
assert "error_id=" in transcript.error_detail
|
||||
|
||||
@@ -60,9 +60,12 @@ class TestPromptLoading:
|
||||
prompt_dir.mkdir()
|
||||
|
||||
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
|
||||
with pytest.raises(PromptLoadError):
|
||||
with pytest.raises(PromptLoadError) as exc_info:
|
||||
load_prompt_text(settings=settings)
|
||||
|
||||
assert exc_info.value.category.value == "infrastructure_persistent_error"
|
||||
assert "verify prompt_dir" in exc_info.value.suggestion.lower()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestImageLoading:
|
||||
@@ -83,9 +86,12 @@ class TestImageLoading:
|
||||
"""Image loader raises TranscriptionError when image file does not exist."""
|
||||
missing = tmp_path / "missing.png"
|
||||
|
||||
with pytest.raises(TranscriptionError):
|
||||
with pytest.raises(TranscriptionError) as exc_info:
|
||||
load_image_payload(missing)
|
||||
|
||||
assert exc_info.value.category.value == "not_found_error"
|
||||
assert "verify" in exc_info.value.suggestion.lower()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTranscriptionService:
|
||||
@@ -123,5 +129,9 @@ class TestTranscriptionService:
|
||||
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
|
||||
provider = _FakeProvider(error=ProviderError("upstream failure"))
|
||||
|
||||
with pytest.raises(TranscriptionError):
|
||||
with pytest.raises(TranscriptionError) as exc_info:
|
||||
transcribe_document_image(image_path, settings=settings, provider=provider)
|
||||
|
||||
assert exc_info.value.category.value == "external_provider_error"
|
||||
assert exc_info.value.retriable is True
|
||||
assert "retry" in exc_info.value.suggestion.lower()
|
||||
|
||||
@@ -16,7 +16,7 @@ class TestUploadValidation:
|
||||
def test_rejects_empty_bytes(self, session, tmp_path: Path):
|
||||
"""create_upload_job rejects an empty upload payload."""
|
||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
||||
with pytest.raises(UploadError):
|
||||
with pytest.raises(UploadError) as exc_info:
|
||||
create_upload_job(
|
||||
filename="letter.jpg",
|
||||
file_bytes=b"",
|
||||
@@ -24,10 +24,13 @@ class TestUploadValidation:
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert exc_info.value.category.value == "validation_error"
|
||||
assert "non-empty" in exc_info.value.suggestion.lower()
|
||||
|
||||
def test_rejects_unsupported_extension(self, session, tmp_path: Path):
|
||||
"""create_upload_job rejects unsupported filename extensions."""
|
||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
||||
with pytest.raises(UploadError):
|
||||
with pytest.raises(UploadError) as exc_info:
|
||||
create_upload_job(
|
||||
filename="notes.txt",
|
||||
file_bytes=b"content",
|
||||
@@ -35,6 +38,9 @@ class TestUploadValidation:
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
assert exc_info.value.category.value == "user_input_error"
|
||||
assert "jpg" in exc_info.value.suggestion.lower()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestUploadPersistence:
|
||||
|
||||
@@ -95,6 +95,9 @@ class TestWorkerFailurePath:
|
||||
assert transcript is not None
|
||||
assert transcript.text is None
|
||||
assert "provider failure" in transcript.error_detail
|
||||
assert "[internal_unexpected_error]" in transcript.error_detail
|
||||
assert "error_id=" in transcript.error_detail
|
||||
assert "suggestion=" in transcript.error_detail
|
||||
|
||||
def test_updates_existing_transcript_if_present(self, session, monkeypatch):
|
||||
"""process_next_queued_job updates existing transcript instead of duplicating."""
|
||||
@@ -118,6 +121,8 @@ class TestWorkerFailurePath:
|
||||
assert transcripts[0].id == existing.id
|
||||
assert transcripts[0].text is None
|
||||
assert "provider failure" in transcripts[0].error_detail
|
||||
assert "[internal_unexpected_error]" in transcripts[0].error_detail
|
||||
assert "error_id=" in transcripts[0].error_detail
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Tests for shared error taxonomy and helpers."""
|
||||
|
||||
import pytest
|
||||
|
||||
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error, new_error_id
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestErrorCategoryContract:
|
||||
"""Verify stable category identifiers."""
|
||||
|
||||
def test_category_values_match_policy_contract(self):
|
||||
"""Error category values match docs/error_handling.md identifiers."""
|
||||
assert ErrorCategory.VALIDATION.value == "validation_error"
|
||||
assert ErrorCategory.USER_INPUT.value == "user_input_error"
|
||||
assert ErrorCategory.NOT_FOUND.value == "not_found_error"
|
||||
assert ErrorCategory.CONFLICT.value == "conflict_error"
|
||||
assert ErrorCategory.EXTERNAL_PROVIDER.value == "external_provider_error"
|
||||
assert ErrorCategory.INFRA_TRANSIENT.value == "infrastructure_transient_error"
|
||||
assert ErrorCategory.INFRA_PERSISTENT.value == "infrastructure_persistent_error"
|
||||
assert ErrorCategory.INTERNAL_UNEXPECTED.value == "internal_unexpected_error"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAppErrorHelpers:
|
||||
"""Verify helper behavior for IDs and normalization."""
|
||||
|
||||
def test_new_error_id_returns_short_identifier(self):
|
||||
"""new_error_id returns a short non-empty identifier."""
|
||||
value = new_error_id()
|
||||
assert isinstance(value, str)
|
||||
assert len(value) == 8
|
||||
|
||||
def test_classify_unexpected_error_returns_internal_unexpected(self):
|
||||
"""Unexpected exceptions are normalized to internal_unexpected_error."""
|
||||
err = classify_unexpected_error(RuntimeError("boom"), operation="unit.test")
|
||||
|
||||
assert isinstance(err, AppError)
|
||||
assert err.category == ErrorCategory.INTERNAL_UNEXPECTED
|
||||
assert "unit.test" in err.message
|
||||
assert "boom" in err.message
|
||||
assert err.suggestion
|
||||
assert err.error_id
|
||||
Reference in New Issue
Block a user