From bc21a97019a37636ace463017fae76fd35a4c5ff Mon Sep 17 00:00:00 2001 From: Jim Lancaster <40281233+zoltan57@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:20:46 -0500 Subject: [PATCH] Updated test suite --- pyproject.toml | 4 + src/transcription/app.py | 24 +++ src/transcription/config.py | 2 + src/transcription/models.py | 2 +- src/transcription/services/jobs.py | 34 ++++- src/transcription/services/workflows.py | 29 +++- src/transcription/ui/pages/jobs_page.py | 15 +- tests/integration/test_pipeline_flow.py | 35 ++++- tests/providers/test_openrouter.py | 22 +-- tests/services/test_job_service.py | 144 +++++++++--------- tests/services/test_transcription_external.py | 5 +- tests/services/test_workflows_reliability.py | 63 ++++++++ tests/test_app.py | 93 +++++++---- tests/test_db.py | 126 ++++++--------- tests/test_traceability.py | 12 +- tests/ui/conftest.py | 57 ++++--- tests/ui/test_jobs_page.py | 16 +- 17 files changed, 447 insertions(+), 236 deletions(-) create mode 100644 tests/services/test_workflows_reliability.py diff --git a/pyproject.toml b/pyproject.toml index 4b821bb..786ae4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/transcription/app.py b/src/transcription/app.py index f061fe8..2cbdb0f 100644 --- a/src/transcription/app.py +++ b/src/transcription/app.py @@ -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) diff --git a/src/transcription/config.py b/src/transcription/config.py index d56c024..7c20a0e 100644 --- a/src/transcription/config.py +++ b/src/transcription/config.py @@ -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: diff --git a/src/transcription/models.py b/src/transcription/models.py index 89f96d8..f34f38e 100644 --- a/src/transcription/models.py +++ b/src/transcription/models.py @@ -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}, ) diff --git a/src/transcription/services/jobs.py b/src/transcription/services/jobs.py index 66a8a56..3fc6126 100644 --- a/src/transcription/services/jobs.py +++ b/src/transcription/services/jobs.py @@ -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) diff --git a/src/transcription/services/workflows.py b/src/transcription/services/workflows.py index c06ffb1..da96c3a 100644 --- a/src/transcription/services/workflows.py +++ b/src/transcription/services/workflows.py @@ -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: diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index 5d446ab..2d3b8a7 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -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") diff --git a/tests/integration/test_pipeline_flow.py b/tests/integration/test_pipeline_flow.py index 481f893..7345243 100644 --- a/tests/integration/test_pipeline_flow.py +++ b/tests/integration/test_pipeline_flow.py @@ -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) diff --git a/tests/providers/test_openrouter.py b/tests/providers/test_openrouter.py index 027daa2..25e519b 100644 --- a/tests/providers/test_openrouter.py +++ b/tests/providers/test_openrouter.py @@ -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", diff --git a/tests/services/test_job_service.py b/tests/services/test_job_service.py index ae442fc..6b20123 100644 --- a/tests/services/test_job_service.py +++ b/tests/services/test_job_service.py @@ -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.""" + @pytest.mark.asyncio + 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) - def fake_job_factory(): - return Job(document_id=uuid4()) + job = Job(document_id=document.id) + await job_service.create_job(job=job) - await job_service.create_job(job=fake_job_factory()) + 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 - 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_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) - @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", + 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_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) + + 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)) + + result = await job_service.query_jobs(status=JobStatus.PROCESSING) + assert len(result) == 1 + assert result[0].status == JobStatus.PROCESSING + + @pytest.mark.asyncio + 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 document_service.create_document(document=document) - job = Job(document_id=doc_id) - await job_service.create_job(job=job) + await session.commit() - read_job = await job_service.read_job(job_id=job.id) - assert isinstance(read_job.document, Document) - assert read_job.document.id == document.id + 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_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 + @pytest.mark.asyncio + 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) - @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 + 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) - @pytest.mark.asyncio - async def test_deleting_job(self, job_service: JobService): - """Test deleting a job.""" - - 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 - - @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 - - @pytest.mark.asyncio - async def test_mark_job_status(self, job_service: JobService): - """Test marking a job with a new status.""" + next_job = await job_service.read_next_queued_job() + assert next_job is not None + assert next_job.id == first.id diff --git a/tests/services/test_transcription_external.py b/tests/services/test_transcription_external.py index 7cb3aed..8a837f3 100644 --- a/tests/services/test_transcription_external.py +++ b/tests/services/test_transcription_external.py @@ -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() diff --git a/tests/services/test_workflows_reliability.py b/tests/services/test_workflows_reliability.py new file mode 100644 index 0000000..18ed937 --- /dev/null +++ b/tests/services/test_workflows_reliability.py @@ -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 diff --git a/tests/test_app.py b/tests/test_app.py index 302a6f8..b9721a1 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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"] diff --git a/tests/test_db.py b/tests/test_db.py index 8e069f0..1b5c6aa 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -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_bootstrap_policy_production_defaults_false(): + settings = Settings(openrouter_api_key="test-key", environment="production") + assert settings.should_bootstrap_schema is False - 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 - settings = Settings(openrouter_api_key="test-key", environment="production") - assert should_bootstrap_schema(settings) is False +def test_bootstrap_policy_development_defaults_true(): + settings = Settings(openrouter_api_key="test-key", environment="development") + assert settings.should_bootstrap_schema is True - 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 - settings = Settings(openrouter_api_key="test-key", environment="development") - assert should_bootstrap_schema(settings) 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 - - settings = Settings( - openrouter_api_key="test-key", - environment="production", - bootstrap_schema_on_startup=True, - ) - assert should_bootstrap_schema(settings) is True +def test_bootstrap_policy_explicit_override_true(): + settings = Settings( + openrouter_api_key="test-key", + environment="production", + bootstrap_schema_on_startup=True, + ) + assert settings.should_bootstrap_schema is True diff --git a/tests/test_traceability.py b/tests/test_traceability.py index f9ceea7..36f087f 100644 --- a/tests/test_traceability.py +++ b/tests/test_traceability.py @@ -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", ], } diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index 6aebe0c..530fa07 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -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,31 +86,40 @@ 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)] + source = Source( + document_id=document.id, + job_id=job.id, + upload_name=filename, + filename=filename, + file_path=str(stored_path), + ) + session.add(source) + await session.flush() - if revisions is not None: - for revision, revision_text, revision_error in revisions: - session.add( - Transcript( - job_id=job.id, - revision=revision, - provider="openrouter", - model="google/gemini-2.5-flash", - prompt_name="transcribe_document", - text=revision_text, - error_detail=revision_error, - ) + if revision_text is not None: + session.add( + Revision( + source_id=source.id, + text=revision_text, ) + ) await session.commit() return job.id diff --git a/tests/ui/test_jobs_page.py b/tests/ui/test_jobs_page.py index 5c28784..e9732a3 100644 --- a/tests/ui/test_jobs_page.py +++ b/tests/ui/test_jobs_page.py @@ -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