generated from john/python-template
Updated test suite
This commit is contained in:
@@ -39,6 +39,10 @@ dev = [
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-markers -q"
|
||||
asyncio_mode = "strict"
|
||||
filterwarnings = [
|
||||
"error:coroutine .* was never awaited:RuntimeWarning",
|
||||
]
|
||||
markers = [
|
||||
"unit: pure logic tests with no external dependencies",
|
||||
"integration: tests that touch framework or database contracts",
|
||||
|
||||
@@ -4,6 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from contextlib import AsyncExitStack
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import status
|
||||
@@ -18,10 +22,14 @@ from .db import create_all
|
||||
from .db import dispose_database_runtime
|
||||
from .db import initialize_database_runtime
|
||||
from .services import ServiceBundle
|
||||
from .services.jobs import JobService
|
||||
from .ui import register_pages
|
||||
from .worker import worker_consumer_lifespan
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI):
|
||||
configure_logging()
|
||||
@@ -37,6 +45,8 @@ async def _lifespan(app: FastAPI):
|
||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
await _recover_stale_processing_jobs(app)
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
stack.push_async_callback(dispose_database_runtime)
|
||||
stop_event, worker_notifier = await stack.enter_async_context(
|
||||
@@ -50,6 +60,20 @@ async def _lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
|
||||
async def _recover_stale_processing_jobs(app: FastAPI) -> None:
|
||||
"""Re-queue stale processing jobs at startup.
|
||||
|
||||
Any job left in PROCESSING longer than the configured provider timeout is
|
||||
assumed orphaned and moved back to QUEUED before the worker starts.
|
||||
"""
|
||||
settings = app.state.settings
|
||||
stale_before = datetime.now(UTC) - timedelta(seconds=settings.worker_provider_timeout_seconds)
|
||||
job_service = JobService(session_factory=app.state.runtime.session_factory)
|
||||
recovered = await job_service.requeue_stale_processing_jobs(stale_before=stale_before)
|
||||
if recovered > 0:
|
||||
logger.warning("Recovered %s stale processing job(s) at startup", recovered)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
||||
|
||||
@@ -11,6 +11,7 @@ from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic_settings import SettingsConfigDict
|
||||
|
||||
@@ -50,6 +51,7 @@ class Settings(BaseSettings):
|
||||
# --- worker reliability ---
|
||||
worker_max_retries: int = 0
|
||||
worker_retry_backoff_seconds: float = 0.0
|
||||
worker_provider_timeout_seconds: float = Field(default=20.0, gt=0.0, le=20.0)
|
||||
|
||||
@property
|
||||
def should_bootstrap_schema(self) -> bool:
|
||||
|
||||
@@ -54,7 +54,7 @@ class Source(SQLModel, table=True):
|
||||
# Relationships
|
||||
document: Optional["Document"] = Relationship(back_populates="sources")
|
||||
job: Optional["Job"] = Relationship(back_populates="sources")
|
||||
revision: "Revision | None" = Relationship(
|
||||
revision: Optional["Revision"] = Relationship(
|
||||
back_populates="source",
|
||||
sa_relationship_kwargs={"uselist": False},
|
||||
)
|
||||
|
||||
@@ -149,8 +149,40 @@ class JobService(ServiceBase):
|
||||
async with self._session_scope(session) as _session:
|
||||
query = (
|
||||
select(Job)
|
||||
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
||||
.options(
|
||||
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
||||
selectinload(Job.sources).selectinload(Source.revision), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
.where(Job.status == JobStatus.QUEUED)
|
||||
.order_by(Job.date_created) # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
return (await _session.exec(query)).first()
|
||||
|
||||
async def requeue_stale_processing_jobs(
|
||||
self,
|
||||
*,
|
||||
stale_before: datetime,
|
||||
session: AsyncSession | None = None,
|
||||
) -> int:
|
||||
"""Move stale processing jobs back to queued state.
|
||||
|
||||
Jobs with ``status=PROCESSING`` and ``date_updated`` older than
|
||||
``stale_before`` are considered stale and re-queued.
|
||||
"""
|
||||
async with self._session_scope(session) as _session:
|
||||
query = (
|
||||
select(Job)
|
||||
.where(Job.status == JobStatus.PROCESSING)
|
||||
.where(Job.date_updated < stale_before)
|
||||
)
|
||||
stale_jobs = (await _session.exec(query)).all()
|
||||
if not stale_jobs:
|
||||
return 0
|
||||
|
||||
now = datetime.now(UTC)
|
||||
for job in stale_jobs:
|
||||
job.status = JobStatus.QUEUED
|
||||
job.date_updated = now
|
||||
|
||||
await self._finalize(session=_session, caller_session=session, refresh=stale_jobs)
|
||||
return len(stale_jobs)
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from ..config import Settings
|
||||
from ..config import get_settings
|
||||
from ..errors import AppError
|
||||
from ..errors import ErrorCategory
|
||||
from ..errors import classify_unexpected_error
|
||||
from ..errors import format_error_detail
|
||||
from ..models import Job
|
||||
@@ -29,7 +30,7 @@ async def advance_job(
|
||||
settings = settings or get_settings()
|
||||
match job.status:
|
||||
case JobStatus.QUEUED:
|
||||
return await process_queued_job(job=job, services=services, session=session)
|
||||
return await process_queued_job(job=job, services=services, settings=settings, session=session)
|
||||
case JobStatus.FAILED:
|
||||
if job.retry_count < settings.worker_max_retries:
|
||||
return await services.jobs.update_job_state(
|
||||
@@ -49,9 +50,11 @@ async def process_queued_job(
|
||||
*,
|
||||
job: Job,
|
||||
services: ServiceBundle,
|
||||
settings: Settings | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> Job | None:
|
||||
"""Process one complete transcription attempt for a queued job."""
|
||||
runtime_settings = settings or get_settings()
|
||||
if job.status != JobStatus.QUEUED:
|
||||
logger.warning(f"Job {job.id} is not queued. Current status: {job.status}")
|
||||
return
|
||||
@@ -65,11 +68,15 @@ async def process_queued_job(
|
||||
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING, session=session)
|
||||
await session.commit()
|
||||
|
||||
source = _resolve_primary_source(job)
|
||||
source_job = await services.jobs.read_job(job_id=job.id, session=session)
|
||||
source = _resolve_primary_source(source_job)
|
||||
assert source is not None, f"Job {job.id} has no associated source record."
|
||||
|
||||
try:
|
||||
result = await transcribe_document_image(source.file_path)
|
||||
result = await asyncio.wait_for(
|
||||
transcribe_document_image(source.file_path),
|
||||
timeout=runtime_settings.worker_provider_timeout_seconds,
|
||||
)
|
||||
job = await _finalize_transcribed(job=job, services=services, result=result, session=session)
|
||||
logger.info(
|
||||
"Job transcribed operation=worker.process_job job_id=%s document_id=%s source_id=%s provider=%s",
|
||||
@@ -78,6 +85,22 @@ async def process_queued_job(
|
||||
source.id,
|
||||
result.provider,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
error = AppError(
|
||||
f"Provider call timed out after {runtime_settings.worker_provider_timeout_seconds:.1f}s",
|
||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||
suggestion="Retry the job. If this repeats, verify provider latency and request payload size.",
|
||||
retriable=True,
|
||||
)
|
||||
job = await _finalize_failed(job=job, services=services, error=error, session=session)
|
||||
logger.error(
|
||||
"Job failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
|
||||
job.id,
|
||||
job.document_id,
|
||||
source.id,
|
||||
error.error_id,
|
||||
error.category.value,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
match exc:
|
||||
case AppError() as error:
|
||||
|
||||
@@ -57,7 +57,18 @@ def register_page() -> None:
|
||||
transcription_service = TranscriptionService(session_factory=session_factory)
|
||||
render_navigation_header(current_path="/jobs")
|
||||
|
||||
job = await jobs_service.read_job(job_id=UUID(job_id))
|
||||
try:
|
||||
parsed_job_id = UUID(job_id)
|
||||
except ValueError:
|
||||
ui.label("Invalid job id").classes("text-h6 text-negative")
|
||||
return
|
||||
|
||||
try:
|
||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||
except ValueError:
|
||||
ui.label("Job not found").classes("text-h6 text-negative")
|
||||
return
|
||||
|
||||
source = _resolve_primary_source(job)
|
||||
|
||||
with ui.splitter(value=30).classes("w-full h-[calc(100vh-64px)]") as splitter:
|
||||
@@ -92,7 +103,7 @@ def register_page() -> None:
|
||||
|
||||
@ui.refreshable
|
||||
async def render_revision_panel() -> None:
|
||||
refreshed_job = await jobs_service.read_job(job_id=UUID(job_id))
|
||||
refreshed_job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||
refreshed_source = _resolve_primary_source(refreshed_job)
|
||||
if refreshed_source is None or refreshed_source.revision is None:
|
||||
ui.label("No revision exists for this source.").classes("text-body2 text-grey-3")
|
||||
|
||||
@@ -33,7 +33,25 @@ class TestPipelineSuccessFlow:
|
||||
_ = (prompt_text, image_bytes, mime_type)
|
||||
return TranscriptionResult(text="Pipeline transcript", provider="openrouter", model="test-model", prompt_name="transcribe_document.md")
|
||||
|
||||
monkeypatch.setattr("transcription.services.transcription.OpenRouterTranscriptionProvider.transcribe", _fake_transcribe)
|
||||
async def _fake_transcribe_document_image(
|
||||
image_path,
|
||||
*,
|
||||
prompt_name="transcribe_document.md",
|
||||
settings=None,
|
||||
provider=None,
|
||||
) -> TranscriptionResult:
|
||||
_ = (image_path, prompt_name, settings, provider)
|
||||
return TranscriptionResult(
|
||||
text="Pipeline transcript",
|
||||
provider="openrouter",
|
||||
model="test-model",
|
||||
prompt_name="transcribe_document.md",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"transcription.services.workflows.transcribe_document_image",
|
||||
_fake_transcribe_document_image,
|
||||
)
|
||||
|
||||
processed = await process_next_queued_job(session=async_session)
|
||||
job = await async_session.get(Job, upload_result.job_id)
|
||||
@@ -60,11 +78,20 @@ class TestPipelineFailureFlow:
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
async def _fake_transcribe(*, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
||||
_ = (prompt_text, image_bytes, mime_type)
|
||||
async def _fake_transcribe_document_image(
|
||||
image_path,
|
||||
*,
|
||||
prompt_name="transcribe_document.md",
|
||||
settings=None,
|
||||
provider=None,
|
||||
) -> TranscriptionResult:
|
||||
_ = (image_path, prompt_name, settings, provider)
|
||||
raise RuntimeError("pipeline provider failure")
|
||||
|
||||
monkeypatch.setattr("transcription.services.transcription.OpenRouterTranscriptionProvider.transcribe", _fake_transcribe)
|
||||
monkeypatch.setattr(
|
||||
"transcription.services.workflows.transcribe_document_image",
|
||||
_fake_transcribe_document_image,
|
||||
)
|
||||
|
||||
processed = await process_next_queued_job(session=async_session)
|
||||
job = await async_session.get(Job, upload_result.job_id)
|
||||
|
||||
@@ -15,7 +15,7 @@ class _FakeChat:
|
||||
self._error = error
|
||||
self.calls = []
|
||||
|
||||
def send(self, **kwargs):
|
||||
async def send_async(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
if self._error:
|
||||
raise self._error
|
||||
@@ -48,7 +48,8 @@ class TestOpenRouterProviderInit:
|
||||
class TestOpenRouterProviderTranscribe:
|
||||
"""Verify OpenRouter request construction and response parsing."""
|
||||
|
||||
def test_includes_optional_referer_and_title_when_set(self):
|
||||
@pytest.mark.asyncio
|
||||
async def test_includes_optional_referer_and_title_when_set(self):
|
||||
"""Transcribe sends app attribution fields when configured."""
|
||||
response = {"model": "vendor/model-a", "choices": [{"message": {"content": "Transcript text"}}]}
|
||||
client = _FakeClient(response=response)
|
||||
@@ -59,7 +60,7 @@ class TestOpenRouterProviderTranscribe:
|
||||
)
|
||||
provider = OpenRouterTranscriptionProvider(settings=settings, client=client)
|
||||
|
||||
result = provider.transcribe(
|
||||
result = await provider.transcribe(
|
||||
prompt_text="Prompt body",
|
||||
image_bytes=b"img-bytes",
|
||||
mime_type="image/png",
|
||||
@@ -70,7 +71,8 @@ class TestOpenRouterProviderTranscribe:
|
||||
assert send_call["x_open_router_title"] == "Transcription App"
|
||||
assert result.text == "Transcript text"
|
||||
|
||||
def test_parses_successful_response_text(self):
|
||||
@pytest.mark.asyncio
|
||||
async def test_parses_successful_response_text(self):
|
||||
"""Transcribe returns normalized text from a valid response payload."""
|
||||
response = {
|
||||
"model": "vendor/model-b",
|
||||
@@ -81,7 +83,7 @@ class TestOpenRouterProviderTranscribe:
|
||||
client=_FakeClient(response=response),
|
||||
)
|
||||
|
||||
result = provider.transcribe(
|
||||
result = await provider.transcribe(
|
||||
prompt_text="Prompt body",
|
||||
image_bytes=b"img-bytes",
|
||||
mime_type="image/jpeg",
|
||||
@@ -91,7 +93,8 @@ class TestOpenRouterProviderTranscribe:
|
||||
assert result.provider == "openrouter"
|
||||
assert result.model == "vendor/model-b"
|
||||
|
||||
def test_maps_sdk_exception_to_provider_error(self):
|
||||
@pytest.mark.asyncio
|
||||
async def test_maps_sdk_exception_to_provider_error(self):
|
||||
"""Transcribe converts SDK failures to ProviderError."""
|
||||
provider = OpenRouterTranscriptionProvider(
|
||||
settings=Settings(openrouter_api_key="test-key"),
|
||||
@@ -99,13 +102,14 @@ class TestOpenRouterProviderTranscribe:
|
||||
)
|
||||
|
||||
with pytest.raises(ProviderError):
|
||||
provider.transcribe(
|
||||
await provider.transcribe(
|
||||
prompt_text="Prompt body",
|
||||
image_bytes=b"img-bytes",
|
||||
mime_type="image/png",
|
||||
)
|
||||
|
||||
def test_raises_on_empty_or_invalid_response(self):
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_on_empty_or_invalid_response(self):
|
||||
"""Transcribe raises ProviderResponseError for missing completion text."""
|
||||
provider = OpenRouterTranscriptionProvider(
|
||||
settings=Settings(openrouter_api_key="test-key"),
|
||||
@@ -113,7 +117,7 @@ class TestOpenRouterProviderTranscribe:
|
||||
)
|
||||
|
||||
with pytest.raises(ProviderResponseError):
|
||||
provider.transcribe(
|
||||
await provider.transcribe(
|
||||
prompt_text="Prompt body",
|
||||
image_bytes=b"img-bytes",
|
||||
mime_type="image/png",
|
||||
|
||||
@@ -4,85 +4,93 @@ import pytest
|
||||
|
||||
from transcription.models import Document
|
||||
from transcription.models import Job
|
||||
from transcription.models import JobStatus
|
||||
from transcription.models import Source
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.jobs import JobService
|
||||
from transcription.services.jobs import JobStatus
|
||||
|
||||
|
||||
class TestJobService:
|
||||
class TestBasicCRUD:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_job(self, job_service: JobService):
|
||||
"""Test creating a job."""
|
||||
|
||||
def fake_job_factory():
|
||||
return Job(document_id=uuid4())
|
||||
|
||||
await job_service.create_job(job=fake_job_factory())
|
||||
|
||||
async with job_service._session_scope() as session:
|
||||
for _ in range(10):
|
||||
await job_service.create_job(job=fake_job_factory(), session=session)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backpropagation(self, job_service: JobService, document_service: DocumentService):
|
||||
"""Test that creating a job backpropagates to the related document."""
|
||||
doc_id = uuid4()
|
||||
document = Document(
|
||||
id=doc_id,
|
||||
filename="test.txt",
|
||||
file_path="/path/to/test.txt",
|
||||
)
|
||||
async def test_create_and_read_job(self, job_service: JobService, document_service: DocumentService):
|
||||
document = Document(id=uuid4(), name="test-bundle")
|
||||
await document_service.create_document(document=document)
|
||||
job = Job(document_id=doc_id)
|
||||
|
||||
job = Job(document_id=document.id)
|
||||
await job_service.create_job(job=job)
|
||||
|
||||
read_job = await job_service.read_job(job_id=job.id)
|
||||
assert isinstance(read_job.document, Document)
|
||||
assert read_job.document.id == document.id
|
||||
fetched = await job_service.read_job(job_id=job.id)
|
||||
assert fetched.id == job.id
|
||||
assert fetched.document is not None
|
||||
assert fetched.document.id == document.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reading_job(self, job_service: JobService):
|
||||
"""Test reading a job."""
|
||||
uuid = uuid4()
|
||||
await job_service.create_job(job=Job(id=uuid, document_id=uuid4()))
|
||||
job = await job_service.read_job(job_id=uuid)
|
||||
assert job.id == uuid
|
||||
async def test_update_job_state_updates_status_and_retry(self, job_service: JobService, document_service: DocumentService):
|
||||
document = Document(id=uuid4(), name="test-bundle")
|
||||
await document_service.create_document(document=document)
|
||||
|
||||
job = Job(document_id=document.id)
|
||||
await job_service.create_job(job=job)
|
||||
|
||||
updated = await job_service.update_job_state(
|
||||
job_id=job.id,
|
||||
status=JobStatus.PROCESSING,
|
||||
retry_count_increment=1,
|
||||
)
|
||||
|
||||
assert updated.status == JobStatus.PROCESSING
|
||||
assert updated.retry_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updating_job(self, job_service: JobService):
|
||||
"""Test updating a job."""
|
||||
uuid = uuid4()
|
||||
job = Job(id=uuid, document_id=uuid4())
|
||||
async with job_service._session_scope() as session:
|
||||
await job_service.create_job(job=job, session=session)
|
||||
job.status = JobStatus.PROCESSING
|
||||
await job_service.update_job(job=job, session=session)
|
||||
read_job = await job_service.read_job(job_id=uuid, session=session)
|
||||
assert read_job == job
|
||||
async def test_query_jobs_by_status(self, job_service: JobService, document_service: DocumentService):
|
||||
document = Document(id=uuid4(), name="query-doc")
|
||||
await document_service.create_document(document=document)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleting_job(self, job_service: JobService):
|
||||
"""Test deleting a job."""
|
||||
await job_service.create_job(job=Job(document_id=document.id, status=JobStatus.PROCESSING))
|
||||
await job_service.create_job(job=Job(document_id=document.id, status=JobStatus.QUEUED))
|
||||
|
||||
class TestServiceMethods:
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_jobs(self, job_service: JobService):
|
||||
"""Test querying jobs."""
|
||||
await job_service.create_job(job=Job(document_id=uuid4(), status=JobStatus.PROCESSING))
|
||||
result = await job_service.query_jobs(status=JobStatus.PROCESSING)
|
||||
jobs = {str(job.id).split("-")[0]: job.status for job in result}
|
||||
assert len(jobs) == 1
|
||||
assert len(result) == 1
|
||||
assert result[0].status == JobStatus.PROCESSING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_jobs(self, job_service: JobService):
|
||||
"""Test listing jobs."""
|
||||
n = 5
|
||||
for _ in range(n):
|
||||
await job_service.create_job(job=Job(document_id=uuid4()))
|
||||
jobs = await job_service.list_jobs()
|
||||
assert len(jobs) == n
|
||||
async def test_query_jobs_by_source_filename(self, job_service: JobService, document_service: DocumentService):
|
||||
document = Document(id=uuid4(), name="source-doc")
|
||||
await document_service.create_document(document=document)
|
||||
|
||||
job = Job(document_id=document.id)
|
||||
await job_service.create_job(job=job)
|
||||
|
||||
async with job_service._session_scope() as session:
|
||||
session.add(
|
||||
Source(
|
||||
document_id=document.id,
|
||||
job_id=job.id,
|
||||
upload_name="letter.jpg",
|
||||
filename="stored-letter.jpg",
|
||||
file_path="/uploads/stored-letter.jpg",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
result = await job_service.query_jobs(filename="stored-letter.jpg")
|
||||
assert len(result) == 1
|
||||
assert result[0].id == job.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_job_status(self, job_service: JobService):
|
||||
"""Test marking a job with a new status."""
|
||||
async def test_read_next_queued_job_orders_by_created_date(
|
||||
self,
|
||||
job_service: JobService,
|
||||
document_service: DocumentService,
|
||||
):
|
||||
document = Document(id=uuid4(), name="ordered-doc")
|
||||
await document_service.create_document(document=document)
|
||||
|
||||
first = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||
second = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||
await job_service.create_job(job=first)
|
||||
await job_service.create_job(job=second)
|
||||
|
||||
next_job = await job_service.read_next_queued_job()
|
||||
assert next_job is not None
|
||||
assert next_job.id == first.id
|
||||
|
||||
@@ -49,10 +49,11 @@ class TestRealImageExternalTranscription:
|
||||
assert REAL_IMAGES_DIR.exists()
|
||||
assert _real_image_paths()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("image_path", _real_image_paths(), ids=lambda p: p.name)
|
||||
def test_transcribes_real_image_fixture(self, image_path: Path):
|
||||
async def test_transcribes_real_image_fixture(self, image_path: Path):
|
||||
"""Real fixture image produces a non-empty transcription result."""
|
||||
result = transcribe_document_image(image_path)
|
||||
result = await transcribe_document_image(image_path)
|
||||
assert result.provider == "openrouter"
|
||||
assert isinstance(result.model, str) and result.model.strip()
|
||||
assert isinstance(result.text, str) and result.text.strip()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Reliability tests for worker workflow timeout behavior."""
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.models import Document
|
||||
from transcription.models import Job
|
||||
from transcription.models import JobStatus
|
||||
from transcription.models import Source
|
||||
from transcription.services import ServiceBundle
|
||||
from transcription.services.workflows import process_queued_job
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestWorkflowReliability:
|
||||
"""Verify timeout and terminal-state reliability behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_queued_job_timeout_marks_job_failed(self, default_session_factory, monkeypatch):
|
||||
"""Provider timeout transitions a queued job to failed with error detail."""
|
||||
services = ServiceBundle()
|
||||
object.__setattr__(services, "documents", services.documents.__class__(session_factory=default_session_factory))
|
||||
object.__setattr__(services, "jobs", services.jobs.__class__(session_factory=default_session_factory))
|
||||
object.__setattr__(services, "transcriptions", services.transcriptions.__class__(session_factory=default_session_factory))
|
||||
|
||||
async with services.jobs._session_scope() as session:
|
||||
document = Document(id=uuid4(), name="timeout-doc")
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
|
||||
job = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||
session.add(job)
|
||||
await session.flush()
|
||||
|
||||
source = Source(
|
||||
document_id=document.id,
|
||||
job_id=job.id,
|
||||
upload_name="timeout.jpg",
|
||||
filename="timeout.jpg",
|
||||
file_path=str(Path("tests/fixtures/images/real/Book Two - page 02.jpg")),
|
||||
)
|
||||
session.add(source)
|
||||
await session.commit()
|
||||
|
||||
loaded = await services.jobs.read_job(job_id=job.id, session=session)
|
||||
|
||||
async def _never_returns(image_path, *, prompt_name="transcribe_document.md", settings=None, provider=None):
|
||||
_ = (image_path, prompt_name, settings, provider)
|
||||
raise TimeoutError("simulated provider timeout")
|
||||
|
||||
monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _never_returns)
|
||||
|
||||
timeout_settings = Settings(openrouter_api_key="test-key", worker_provider_timeout_seconds=20.0)
|
||||
result = await process_queued_job(job=loaded, services=services, settings=timeout_settings)
|
||||
|
||||
assert result is not None
|
||||
assert result.status == JobStatus.FAILED
|
||||
assert result.error_detail is not None
|
||||
assert "timed out" in result.error_detail.lower()
|
||||
assert "20.0s" in result.error_detail
|
||||
+64
-29
@@ -1,5 +1,7 @@
|
||||
"""Tests for transcription.app."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -21,28 +23,43 @@ class TestAppFactory:
|
||||
class TestAppLifespan:
|
||||
"""Verify startup and shutdown lifecycle behavior."""
|
||||
|
||||
def test_startup_initializes_runtime_dependencies(self, monkeypatch):
|
||||
def test_startup_initializes_runtime_dependencies(self, monkeypatch, tmp_path):
|
||||
"""Startup initializes logging, schema, directories, and worker resources."""
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr("transcription.app.setup_logging", lambda: calls.append("logging"))
|
||||
monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: calls.append("schema"))
|
||||
monkeypatch.setattr("transcription.app.configure_logging", lambda: calls.append("logging"))
|
||||
|
||||
async def _create_all(**_kwargs):
|
||||
calls.append("schema")
|
||||
|
||||
monkeypatch.setattr("transcription.app.create_all", _create_all)
|
||||
monkeypatch.setattr(
|
||||
"transcription.app.initialize_database_runtime",
|
||||
lambda **_kwargs: type("_Runtime", (), {"engine": object()})(),
|
||||
lambda **_kwargs: type("_Runtime", (), {"engine": object(), "session_factory": object()})(),
|
||||
)
|
||||
monkeypatch.setattr("transcription.app.dispose_database_runtime", lambda: calls.append("dispose_db"))
|
||||
monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True)
|
||||
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
|
||||
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
|
||||
|
||||
class _Dir:
|
||||
def mkdir(self, parents: bool, exist_ok: bool):
|
||||
calls.append("mkdir")
|
||||
async def _dispose_runtime():
|
||||
calls.append("dispose_db")
|
||||
|
||||
monkeypatch.setattr("transcription.app.dispose_database_runtime", _dispose_runtime)
|
||||
|
||||
async def _recover_stale(_app):
|
||||
calls.append("recover")
|
||||
|
||||
monkeypatch.setattr("transcription.app._recover_stale_processing_jobs", _recover_stale)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _worker_lifespan(**_kwargs):
|
||||
calls.append("worker_start")
|
||||
yield object(), object()
|
||||
calls.append("worker_stop")
|
||||
|
||||
monkeypatch.setattr("transcription.app.worker_consumer_lifespan", _worker_lifespan)
|
||||
|
||||
class _Settings:
|
||||
upload_dir = _Dir()
|
||||
prompt_dir = _Dir()
|
||||
should_bootstrap_schema = True
|
||||
upload_dir = tmp_path / "uploads"
|
||||
prompt_dir = tmp_path / "prompts"
|
||||
|
||||
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
|
||||
|
||||
@@ -52,32 +69,50 @@ class TestAppLifespan:
|
||||
|
||||
assert "logging" in calls
|
||||
assert "schema" in calls
|
||||
assert "mkdir" in calls
|
||||
assert "start_worker" in calls
|
||||
assert "recover" in calls
|
||||
assert "worker_start" in calls
|
||||
assert "worker_stop" in calls
|
||||
assert "dispose_db" in calls
|
||||
assert _Settings.upload_dir.exists()
|
||||
assert _Settings.prompt_dir.exists()
|
||||
|
||||
def test_shutdown_stops_worker_resources(self, monkeypatch):
|
||||
def test_shutdown_stops_worker_resources(self, monkeypatch, tmp_path):
|
||||
"""Shutdown signals and stops worker resources cleanly."""
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr("transcription.app.setup_logging", lambda: None)
|
||||
monkeypatch.setattr("transcription.app.create_all", lambda **_kwargs: None)
|
||||
monkeypatch.setattr("transcription.app.configure_logging", lambda: calls.append("logging"))
|
||||
|
||||
async def _create_all(**_kwargs):
|
||||
calls.append("schema")
|
||||
|
||||
monkeypatch.setattr("transcription.app.create_all", _create_all)
|
||||
monkeypatch.setattr(
|
||||
"transcription.app.initialize_database_runtime",
|
||||
lambda **_kwargs: type("_Runtime", (), {"engine": object()})(),
|
||||
lambda **_kwargs: type("_Runtime", (), {"engine": object(), "session_factory": object()})(),
|
||||
)
|
||||
monkeypatch.setattr("transcription.app.dispose_database_runtime", lambda: calls.append("dispose_db"))
|
||||
monkeypatch.setattr("transcription.app.should_bootstrap_schema", lambda _settings: True)
|
||||
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
|
||||
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
|
||||
|
||||
class _Dir:
|
||||
def mkdir(self, parents: bool, exist_ok: bool):
|
||||
return None
|
||||
async def _dispose_runtime():
|
||||
calls.append("dispose_db")
|
||||
|
||||
monkeypatch.setattr("transcription.app.dispose_database_runtime", _dispose_runtime)
|
||||
|
||||
async def _recover_stale(_app):
|
||||
calls.append("recover")
|
||||
|
||||
monkeypatch.setattr("transcription.app._recover_stale_processing_jobs", _recover_stale)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _worker_lifespan(**_kwargs):
|
||||
calls.append("worker_start")
|
||||
yield object(), object()
|
||||
calls.append("worker_stop")
|
||||
|
||||
monkeypatch.setattr("transcription.app.worker_consumer_lifespan", _worker_lifespan)
|
||||
|
||||
class _Settings:
|
||||
upload_dir = _Dir()
|
||||
prompt_dir = _Dir()
|
||||
should_bootstrap_schema = True
|
||||
upload_dir = tmp_path / "uploads"
|
||||
prompt_dir = tmp_path / "prompts"
|
||||
|
||||
monkeypatch.setattr("transcription.app.get_settings", lambda: _Settings())
|
||||
|
||||
@@ -85,4 +120,4 @@ class TestAppLifespan:
|
||||
with TestClient(app):
|
||||
pass
|
||||
|
||||
assert calls == ["start_worker", "stop_worker", "dispose_db"]
|
||||
assert calls == ["logging", "schema", "recover", "worker_start", "worker_stop", "dispose_db"]
|
||||
|
||||
+41
-71
@@ -1,97 +1,67 @@
|
||||
"""Tests for transcription.db — schema bootstrap and session factory."""
|
||||
"""Tests for transcription.db runtime and schema bootstrap behavior."""
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
from sqlmodel.pool import StaticPool
|
||||
import pytest
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.db import create_all
|
||||
from transcription.db import dispose_database_runtime
|
||||
from transcription.db import get_session
|
||||
from transcription.db import initialize_database_runtime
|
||||
|
||||
|
||||
def _in_memory_engine():
|
||||
"""Create a fresh in-memory SQLite engine for isolated db tests."""
|
||||
return create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_all_creates_expected_tables(tmp_path):
|
||||
settings = Settings(
|
||||
openrouter_api_key="test-key",
|
||||
database_url=f"sqlite:///{tmp_path / 'schema.db'}",
|
||||
environment="test",
|
||||
)
|
||||
runtime = initialize_database_runtime(settings=settings)
|
||||
|
||||
try:
|
||||
await create_all(engine=runtime.engine)
|
||||
async with runtime.engine.connect() as conn:
|
||||
table_names = set(await conn.run_sync(lambda c: inspect(c).get_table_names()))
|
||||
|
||||
class TestSchemaBootstrap:
|
||||
"""Verify create_all produces the expected table set."""
|
||||
|
||||
def test_create_all_creates_expected_tables(self):
|
||||
"""After create_all(), document, source, job, and revision tables exist."""
|
||||
engine = _in_memory_engine()
|
||||
# Ensure models are imported so metadata is populated
|
||||
from transcription.models import Document, Job, Revision, Source # noqa: F401
|
||||
|
||||
import transcription.db as db_module
|
||||
|
||||
db_module.create_all(engine=engine)
|
||||
|
||||
inspector = inspect(engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
assert "document" in table_names
|
||||
assert "job" in table_names
|
||||
assert "source" in table_names
|
||||
assert "revision" in table_names
|
||||
finally:
|
||||
await dispose_database_runtime()
|
||||
|
||||
|
||||
class TestSessionFactory:
|
||||
"""Verify get_session yields and cleans up sessions."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_session_yields_async_session(tmp_path):
|
||||
settings = Settings(
|
||||
openrouter_api_key="test-key",
|
||||
database_url=f"sqlite:///{tmp_path / 'session.db'}",
|
||||
environment="test",
|
||||
)
|
||||
initialize_database_runtime(settings=settings)
|
||||
|
||||
def test_get_session_yields_session(self):
|
||||
"""get_session() yields a usable Session object."""
|
||||
engine = _in_memory_engine()
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
import transcription.db as db_module
|
||||
|
||||
with db_module.get_session(engine=engine) as session:
|
||||
assert isinstance(session, Session)
|
||||
|
||||
def test_session_is_closed_after_generator_exit(self):
|
||||
"""After the context manager exits, the session is closed."""
|
||||
engine = _in_memory_engine()
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
import transcription.db as db_module
|
||||
|
||||
with db_module.get_session(engine=engine) as session:
|
||||
# Session is usable inside the context
|
||||
session.execute(text("SELECT 1"))
|
||||
captured = session
|
||||
|
||||
# After exiting, the session's internal connection is released
|
||||
# (no active transaction bound to the session)
|
||||
assert captured._transaction is None
|
||||
try:
|
||||
async with get_session(settings=settings) as session:
|
||||
assert session is not None
|
||||
finally:
|
||||
await dispose_database_runtime()
|
||||
|
||||
|
||||
class TestBootstrapPolicy:
|
||||
"""Verify schema bootstrap policy defaults and overrides."""
|
||||
|
||||
def test_production_defaults_to_no_bootstrap(self):
|
||||
"""Production defaults to explicit non-bootstrap startup behavior."""
|
||||
from transcription.config import Settings
|
||||
from transcription.db import should_bootstrap_schema
|
||||
|
||||
def test_bootstrap_policy_production_defaults_false():
|
||||
settings = Settings(openrouter_api_key="test-key", environment="production")
|
||||
assert should_bootstrap_schema(settings) is False
|
||||
assert settings.should_bootstrap_schema is False
|
||||
|
||||
def test_development_defaults_to_bootstrap(self):
|
||||
"""Development defaults to schema bootstrap for local workflows."""
|
||||
from transcription.config import Settings
|
||||
from transcription.db import should_bootstrap_schema
|
||||
|
||||
def test_bootstrap_policy_development_defaults_true():
|
||||
settings = Settings(openrouter_api_key="test-key", environment="development")
|
||||
assert should_bootstrap_schema(settings) is True
|
||||
assert settings.should_bootstrap_schema is True
|
||||
|
||||
def test_explicit_override_wins(self):
|
||||
"""Explicit bootstrap_schema_on_startup overrides environment default."""
|
||||
from transcription.config import Settings
|
||||
from transcription.db import should_bootstrap_schema
|
||||
|
||||
def test_bootstrap_policy_explicit_override_true():
|
||||
settings = Settings(
|
||||
openrouter_api_key="test-key",
|
||||
environment="production",
|
||||
bootstrap_schema_on_startup=True,
|
||||
)
|
||||
assert should_bootstrap_schema(settings) is True
|
||||
assert settings.should_bootstrap_schema is True
|
||||
|
||||
@@ -9,19 +9,19 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
|
||||
"tests/integration/test_pipeline_flow.py",
|
||||
],
|
||||
"REQ-1": [
|
||||
"tests/services/test_upload.py",
|
||||
"tests/integration/test_pipeline_flow.py",
|
||||
"tests/ui/test_upload_page.py",
|
||||
],
|
||||
"REQ-2": [
|
||||
"tests/services/test_worker.py",
|
||||
"tests/services/test_workflows_reliability.py",
|
||||
"tests/integration/test_pipeline_flow.py",
|
||||
],
|
||||
"REQ-3": [
|
||||
"tests/services/test_worker.py",
|
||||
"tests/services/test_job_service.py",
|
||||
"tests/ui/test_jobs_page.py",
|
||||
],
|
||||
"REQ-4": [
|
||||
"tests/services/test_worker.py",
|
||||
"tests/services/test_workflows_reliability.py",
|
||||
"tests/integration/test_pipeline_flow.py",
|
||||
],
|
||||
"REQ-5": [
|
||||
@@ -30,7 +30,7 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
|
||||
],
|
||||
"REQ-6": [
|
||||
"tests/test_app.py",
|
||||
"tests/services/test_worker.py",
|
||||
"tests/services/test_workflows_reliability.py",
|
||||
],
|
||||
"REQ-8": [
|
||||
"tests/test_app.py",
|
||||
@@ -38,7 +38,7 @@ MVP_REQUIREMENT_TEST_MAP: dict[str, list[str]] = {
|
||||
],
|
||||
"REQ-12": [
|
||||
"tests/test_prompts.py",
|
||||
"tests/services/test_transcription.py",
|
||||
"tests/services/test_transcription_external.py",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
+32
-21
@@ -21,9 +21,10 @@ from transcription.db import initialize_database_runtime
|
||||
from transcription.models import Document
|
||||
from transcription.models import Job
|
||||
from transcription.models import JobStatus
|
||||
from transcription.models import Transcript
|
||||
from transcription.models import Revision
|
||||
from transcription.models import Source
|
||||
|
||||
TranscriptSeed = tuple[int, str | None, str | None]
|
||||
RevisionSeed = str
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -54,7 +55,8 @@ def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
|
||||
|
||||
async def _clear() -> None:
|
||||
async with get_session(session_factory=app.state.runtime.session_factory) as session:
|
||||
await session.exec(delete(Transcript))
|
||||
await session.exec(delete(Revision))
|
||||
await session.exec(delete(Source))
|
||||
await session.exec(delete(Job))
|
||||
await session.exec(delete(Document))
|
||||
await session.commit()
|
||||
@@ -64,7 +66,7 @@ def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
|
||||
|
||||
@pytest.fixture
|
||||
def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
||||
"""Return a helper for inserting a document/job/transcript trio."""
|
||||
"""Return a helper for inserting a document/job/source/(optional revision) tuple."""
|
||||
app, _ = app_client
|
||||
fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid"
|
||||
|
||||
@@ -72,9 +74,9 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
||||
*,
|
||||
filename: str = "sample.pdf",
|
||||
status: JobStatus = JobStatus.TRANSCRIBED,
|
||||
transcript_text: str | None = "Sample transcript text",
|
||||
transcription_text: str | None = "Sample transcript text",
|
||||
error_detail: str | None = None,
|
||||
transcript_revisions: list[TranscriptSeed] | None = None,
|
||||
revision_text: RevisionSeed | None = None,
|
||||
source_file: Path | None = None,
|
||||
) -> UUID:
|
||||
async def _insert() -> UUID:
|
||||
@@ -84,29 +86,38 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
||||
source_path = source_file or fixtures_dir / "small_png.png"
|
||||
stored_path.write_bytes(source_path.read_bytes())
|
||||
|
||||
document = Document(filename=filename, file_path=str(stored_path))
|
||||
document = Document(name=filename)
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
|
||||
job = Job(document_id=document.id, status=status, retry_count=0)
|
||||
job = Job(
|
||||
document_id=document.id,
|
||||
status=status,
|
||||
retry_count=0,
|
||||
text=transcription_text,
|
||||
error_detail=error_detail,
|
||||
provider="openrouter",
|
||||
model="google/gemini-2.5-flash",
|
||||
prompt_name="transcribe_document.md",
|
||||
)
|
||||
session.add(job)
|
||||
await session.flush()
|
||||
|
||||
revisions = transcript_revisions
|
||||
if revisions is None and (transcript_text is not None or error_detail is not None):
|
||||
revisions = [(0, transcript_text, error_detail)]
|
||||
|
||||
if revisions is not None:
|
||||
for revision, revision_text, revision_error in revisions:
|
||||
session.add(
|
||||
Transcript(
|
||||
source = Source(
|
||||
document_id=document.id,
|
||||
job_id=job.id,
|
||||
revision=revision,
|
||||
provider="openrouter",
|
||||
model="google/gemini-2.5-flash",
|
||||
prompt_name="transcribe_document",
|
||||
upload_name=filename,
|
||||
filename=filename,
|
||||
file_path=str(stored_path),
|
||||
)
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
|
||||
if revision_text is not None:
|
||||
session.add(
|
||||
Revision(
|
||||
source_id=source.id,
|
||||
text=revision_text,
|
||||
error_detail=revision_error,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -18,13 +18,12 @@ class TestPageRendering:
|
||||
response = client.get("/ui/jobs")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Transcription Jobs" in response.text
|
||||
assert "No jobs yet." in response.text
|
||||
|
||||
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
|
||||
"""GET /ui/jobs lists seeded jobs from the in-memory database."""
|
||||
_, client = app_client
|
||||
seed_job(filename="sample.pdf", status=JobStatus.TRANSCRIBED, transcript_text="done")
|
||||
seed_job(filename="sample.pdf", status=JobStatus.TRANSCRIBED, transcription_text="done")
|
||||
|
||||
response = client.get("/ui/jobs")
|
||||
|
||||
@@ -39,23 +38,20 @@ class TestPageRendering:
|
||||
job_id = seed_job(
|
||||
filename="detail.pdf",
|
||||
status=JobStatus.TRANSCRIBED,
|
||||
transcript_revisions=[
|
||||
(0, None, "first attempt failed"),
|
||||
(1, "hello", None),
|
||||
],
|
||||
transcription_text="original text",
|
||||
revision_text="hello",
|
||||
source_file=fixture_path,
|
||||
)
|
||||
|
||||
response = client.get(f"/ui/jobs/{job_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Job Detail" in response.text
|
||||
assert "Job overview" in response.text
|
||||
assert "Original Transcription" in response.text
|
||||
assert "detail.pdf" in response.text
|
||||
assert "Transcripts" in response.text
|
||||
assert "Revision" in response.text
|
||||
assert "first attempt failed" in response.text
|
||||
assert "Revision" in response.text
|
||||
assert "hello" in response.text
|
||||
assert "original text" in response.text
|
||||
assert "Document preview" in response.text
|
||||
assert "/uploads/detail.pdf" in response.text
|
||||
|
||||
|
||||
Reference in New Issue
Block a user