generated from john/python-template
242 lines
8.9 KiB
Python
242 lines
8.9 KiB
Python
"""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, ErrorCategory
|
|
from transcription.models import Document, Job, JobStatus, Transcript, TranscriptRevision
|
|
from transcription.providers.base import TranscriptionResult
|
|
from transcription.worker import process_next_queued_job, 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
|