"""Tests for transcription.worker.""" from pathlib import Path from threading import Event import pytest from sqlmodel import select from transcription.config import Settings from transcription.errors import AppError from transcription.errors import ErrorCategory from transcription.models import Document from transcription.models import Job from transcription.models import JobStatus from transcription.models import Transcript from transcription.models import TranscriptRevision from transcription.providers.base import TranscriptionResult from transcription.worker import process_next_queued_job from transcription.worker import run_worker_loop def _create_queued_job(session, *, filename: str = "doc.jpg", file_path: str = "uploads/doc.jpg") -> Job: document = Document(filename=filename, file_path=file_path) session.add(document) session.commit() session.refresh(document) job = Job(document_id=document.id, status=JobStatus.QUEUED) session.add(job) session.commit() session.refresh(job) return job @pytest.mark.integration class TestWorkerQueueBehavior: """Verify worker behavior when selecting queued jobs.""" def test_returns_false_when_queue_empty(self, session): """process_next_queued_job returns False when there are no queued jobs.""" processed = process_next_queued_job(session=session) assert processed is False @pytest.mark.integration class TestWorkerSuccessPath: """Verify worker success-path lifecycle transitions and transcript persistence.""" def test_transitions_processing_to_transcribed(self, session, monkeypatch, tmp_path: Path): """process_next_queued_job transitions queued jobs to transcribed on success.""" job = _create_queued_job(session) def _fake_transcribe(_path): return TranscriptionResult(text="ok", provider="openrouter", model="test-model") monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe) monkeypatch.setattr( "transcription.worker.get_settings", lambda: Settings(openrouter_api_key="test-key", prompt_dir=tmp_path), ) processed = process_next_queued_job(session=session) session.refresh(job) assert processed is True assert job.status == JobStatus.TRANSCRIBED def test_persists_transcript_text_on_success(self, session, monkeypatch, tmp_path: Path): """process_next_queued_job stores transcript text for successful jobs.""" job = _create_queued_job(session) def _fake_transcribe(_path): return TranscriptionResult(text="Transcript body", provider="openrouter", model="test-model") monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe) monkeypatch.setattr( "transcription.worker.get_settings", lambda: Settings(openrouter_api_key="test-key", prompt_dir=tmp_path), ) process_next_queued_job(session=session) transcript = session.exec(select(Transcript).where(Transcript.job_id == job.id)).first() revision = session.exec( select(TranscriptRevision) .where(TranscriptRevision.job_id == job.id) .order_by(TranscriptRevision.revision_number) ).first() assert transcript is not None assert transcript.text == "Transcript body" assert transcript.error_detail is None assert revision is not None assert revision.revision_number == 1 assert revision.text == "Transcript body" assert revision.source == "worker" assert revision.accepted is False @pytest.mark.integration class TestWorkerFailurePath: """Verify worker failure-path lifecycle transitions and error persistence.""" def test_sets_failed_and_error_detail_on_failure(self, session, monkeypatch, tmp_path: Path): """process_next_queued_job marks failed and stores error detail on exception.""" job = _create_queued_job(session) def _fake_transcribe(_path): raise RuntimeError("provider failure") monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe) monkeypatch.setattr( "transcription.worker.get_settings", lambda: Settings(openrouter_api_key="test-key", prompt_dir=tmp_path), ) processed = process_next_queued_job(session=session) session.refresh(job) transcript = session.exec( select(Transcript).where(Transcript.job_id == job.id) ).first() assert processed is True assert job.status == JobStatus.FAILED assert transcript is not None assert transcript.text is None assert "provider failure" not 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, tmp_path: Path): """process_next_queued_job updates existing transcript instead of duplicating.""" job = _create_queued_job(session) existing = Transcript(job_id=job.id, text="old", error_detail=None) session.add(existing) session.commit() session.refresh(existing) def _fake_transcribe(_path): raise RuntimeError("provider failure") monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe) monkeypatch.setattr( "transcription.worker.get_settings", lambda: Settings(openrouter_api_key="test-key", prompt_dir=tmp_path), ) process_next_queued_job(session=session) transcripts = session.exec( select(Transcript).where(Transcript.job_id == job.id) ).all() assert len(transcripts) == 1 assert transcripts[0].id == existing.id assert transcripts[0].text is None assert "provider failure" not in transcripts[0].error_detail assert "[internal_unexpected_error]" in transcripts[0].error_detail assert "error_id=" in transcripts[0].error_detail @pytest.mark.integration class TestWorkerRetryBehavior: """Verify worker retry and terminal failure policies.""" def test_retriable_failure_requeues_until_limit(self, session, monkeypatch, tmp_path: Path): """Retriable failures requeue jobs while retry budget remains.""" job = _create_queued_job(session) def _fake_transcribe(_path): raise AppError( "temporary upstream outage", category=ErrorCategory.EXTERNAL_PROVIDER, suggestion="Retry from jobs page.", retriable=True, ) monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe) monkeypatch.setattr( "transcription.worker.get_settings", lambda: Settings( openrouter_api_key="test-key", prompt_dir=tmp_path, worker_max_retries=1, worker_retry_backoff_seconds=0.0, ), ) processed = process_next_queued_job(session=session) session.refresh(job) assert processed is True assert job.status == JobStatus.QUEUED assert job.retry_count == 1 def test_retriable_failure_exhaustion_sets_failed(self, session, monkeypatch, tmp_path: Path): """Retriable failures transition to failed when retry budget is exhausted.""" job = _create_queued_job(session) job.retry_count = 1 session.add(job) session.commit() def _fake_transcribe(_path): raise AppError( "temporary upstream outage", category=ErrorCategory.EXTERNAL_PROVIDER, suggestion="Retry from jobs page.", retriable=True, ) monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe) monkeypatch.setattr( "transcription.worker.get_settings", lambda: Settings( openrouter_api_key="test-key", prompt_dir=tmp_path, worker_max_retries=1, worker_retry_backoff_seconds=0.0, ), ) process_next_queued_job(session=session) session.refresh(job) assert job.status == JobStatus.FAILED assert job.retry_count == 1 @pytest.mark.unit class TestWorkerLoopControl: """Verify worker loop start/stop behavior.""" def test_stops_when_stop_event_is_set(self, monkeypatch): """run_worker_loop exits when a stop event is set.""" stop_event = Event() stop_event.set() called = {"value": False} def _fake_process_next_queued_job(**_kwargs): called["value"] = True return False monkeypatch.setattr("transcription.worker.process_next_queued_job", _fake_process_next_queued_job) run_worker_loop(stop_event=stop_event, poll_interval_seconds=0.01) assert called["value"] is False