model used being carried thru

This commit is contained in:
John Lancaster
2026-06-29 19:04:16 -05:00
parent 9ada09accf
commit 7df687d6f5
27 changed files with 1442 additions and 210 deletions
+3 -2
View File
@@ -1,11 +1,12 @@
"""Tests for API error response envelope handlers."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from transcription.api.errors import register_error_handlers
from transcription.errors import AppError, ErrorCategory
from transcription.errors import AppError
from transcription.errors import ErrorCategory
@pytest.mark.integration
+53 -19
View File
@@ -3,12 +3,15 @@
from pathlib import Path
import pytest
from sqlmodel import desc
from sqlmodel import select
from transcription.config import Settings
from transcription.models import Job, JobStatus, Transcript
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
from transcription.providers.base import TranscriptionResult
from transcription.services.upload import create_upload_job
from transcription.services.store import create_upload_job
from transcription.worker import process_next_queued_job
@@ -16,24 +19,42 @@ from transcription.worker import process_next_queued_job
class TestPipelineSuccessFlow:
"""Verify end-to-end success lifecycle behavior."""
def test_upload_then_worker_persists_transcribed_terminal_state(self, session, tmp_path: Path, monkeypatch):
@pytest.mark.asyncio
async def test_upload_then_worker_persists_transcribed_terminal_state(
self,
async_session,
tmp_path: Path,
monkeypatch,
):
"""Upload followed by worker processing persists transcript and transcribed status."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
upload_result = create_upload_job(
upload_result = await create_upload_job(
filename="pipeline.jpg",
file_bytes=b"pipeline-bytes",
session=session,
session=async_session,
settings=settings,
)
def _fake_transcribe(_path: str) -> TranscriptionResult:
return TranscriptionResult(text="Pipeline transcript", provider="openrouter", model="test-model")
async def _fake_transcribe(_path: str) -> TranscriptionResult:
return TranscriptionResult(
text="Pipeline transcript",
provider="openrouter",
prompt_name="transcribe_document.md",
model="test-model",
)
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _fake_transcribe)
processed = process_next_queued_job(session=session)
job = session.get(Job, upload_result.job_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id)).first()
processed = await process_next_queued_job(session=async_session)
job = await async_session.get(Job, upload_result.job_id)
transcript = (
await async_session.exec(
select(Transcript)
.where(Transcript.job_id == upload_result.job_id)
.order_by(desc(Transcript.revision))
.limit(1)
)
).first()
assert processed is True
assert job is not None
@@ -47,24 +68,37 @@ class TestPipelineSuccessFlow:
class TestPipelineFailureFlow:
"""Verify end-to-end failure lifecycle behavior."""
def test_upload_then_worker_persists_failed_terminal_state(self, session, tmp_path: Path, monkeypatch):
@pytest.mark.asyncio
async def test_upload_then_worker_persists_failed_terminal_state(
self,
async_session,
tmp_path: Path,
monkeypatch,
):
"""Upload followed by worker processing persists error detail and failed status."""
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
upload_result = create_upload_job(
upload_result = await create_upload_job(
filename="pipeline.jpg",
file_bytes=b"pipeline-bytes",
session=session,
session=async_session,
settings=settings,
)
def _fake_transcribe(_path: str) -> TranscriptionResult:
async def _fake_transcribe(_path: str) -> TranscriptionResult:
raise RuntimeError("pipeline provider failure")
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _fake_transcribe)
processed = process_next_queued_job(session=session)
job = session.get(Job, upload_result.job_id)
transcript = session.exec(select(Transcript).where(Transcript.job_id == upload_result.job_id)).first()
processed = await process_next_queued_job(session=async_session)
job = await async_session.get(Job, upload_result.job_id)
transcript = (
await async_session.exec(
select(Transcript)
.where(Transcript.job_id == upload_result.job_id)
.order_by(desc(Transcript.revision))
.limit(1)
)
).first()
assert processed is True
assert job is not None
+17 -11
View File
@@ -5,8 +5,10 @@ from types import SimpleNamespace
import pytest
from transcription.config import Settings
from transcription.providers.base import ProviderError, ProviderResponseError
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL, OpenRouterTranscriptionProvider
from transcription.providers.base import ProviderError
from transcription.providers.base import ProviderResponseError
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
class _FakeChat:
@@ -15,7 +17,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 +50,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 +62,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 +73,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 +85,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 +95,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 +104,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 +119,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",
+6
View File
@@ -1,5 +1,11 @@
import pytest
from transcription.services.base import ServiceBase
class FakeService(ServiceBase):
"""A fake service class for testing"""
class TestServiceBase:
class TestInitialization:
@@ -7,8 +7,9 @@ import pytest
from transcription.services.transcription import transcribe_document_image
HAS_OPENROUTER_KEY = bool(os.getenv("OPENROUTER_API_KEY"))
HAS_OPENROUTER_KEY = bool(
os.getenv("TRANSCRIPTION_OPENROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
)
REAL_IMAGES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "real"
ARTIFACTS_DIR = Path(__file__).resolve().parents[1] / "artifacts" / "transcriptions"
@@ -18,7 +19,10 @@ pytestmark = [
pytest.mark.external,
pytest.mark.skipif(
not HAS_OPENROUTER_KEY,
reason="Set OPENROUTER_API_KEY to run external real-image tests.",
reason=(
"Set TRANSCRIPTION_OPENROUTER_API_KEY "
"(or legacy OPENROUTER_API_KEY) to run external real-image tests."
),
),
]
@@ -67,4 +71,4 @@ class TestRealImageExternalTranscription:
f"{result.text}\n"
)
artifact_path.write_text(artifact_text, encoding="utf-8")
assert artifact_path.exists()
assert artifact_path.exists()
+26 -10
View File
@@ -25,14 +25,21 @@ class TestAppLifespan:
"""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)
async def _cleanup_database():
calls.append("dispose_db")
monkeypatch.setattr("transcription.app.cleanup_database", _cleanup_database)
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
@@ -41,6 +48,7 @@ class TestAppLifespan:
calls.append("mkdir")
class _Settings:
should_bootstrap_schema = True
upload_dir = _Dir()
prompt_dir = _Dir()
@@ -60,14 +68,21 @@ class TestAppLifespan:
"""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: None)
async def _create_all(**_kwargs):
return None
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)
async def _cleanup_database():
calls.append("dispose_db")
monkeypatch.setattr("transcription.app.cleanup_database", _cleanup_database)
monkeypatch.setattr("transcription.app._start_worker", lambda _app: calls.append("start_worker"))
monkeypatch.setattr("transcription.app._stop_worker", lambda _app: calls.append("stop_worker"))
@@ -76,6 +91,7 @@ class TestAppLifespan:
return None
class _Settings:
should_bootstrap_schema = True
upload_dir = _Dir()
prompt_dir = _Dir()
+6 -5
View File
@@ -5,7 +5,8 @@ from pathlib import Path
import pytest
from pydantic import ValidationError
from transcription.config import Provider, Settings
from transcription.config import Provider
from transcription.config import Settings
def _make_settings(**overrides) -> Settings:
@@ -19,14 +20,14 @@ class TestSettingsLoading:
"""Verify Settings construction and required-field validation."""
def test_loads_from_env(self, monkeypatch):
"""Settings constructs when OPENROUTER_API_KEY is provided."""
monkeypatch.setenv("OPENROUTER_API_KEY", "test-key-xyz")
"""Settings constructs when TRANSCRIPTION_OPENROUTER_API_KEY is provided."""
monkeypatch.setenv("TRANSCRIPTION_OPENROUTER_API_KEY", "test-key-xyz")
settings = Settings()
assert settings.openrouter_api_key == "test-key-xyz"
def test_requires_api_key(self, monkeypatch):
"""Settings raises ValidationError when OPENROUTER_API_KEY is missing."""
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
"""Settings raises ValidationError when required provider API key is missing."""
monkeypatch.delenv("TRANSCRIPTION_OPENROUTER_API_KEY", raising=False)
with pytest.raises(ValidationError):
Settings(_env_file=None)
+68 -23
View File
@@ -1,14 +1,18 @@
"""Tests for transcription.db — schema bootstrap and session factory."""
from sqlalchemy import inspect, text
from sqlmodel import Session, SQLModel, create_engine
import pytest
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.pool import StaticPool
def _in_memory_engine():
"""Create a fresh in-memory SQLite engine for isolated db tests."""
return create_engine(
"sqlite://",
return create_async_engine(
"sqlite+aiosqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
@@ -17,18 +21,21 @@ def _in_memory_engine():
class TestSchemaBootstrap:
"""Verify create_all produces the expected table set."""
def test_create_all_creates_expected_tables(self):
@pytest.mark.asyncio
async def test_create_all_creates_expected_tables(self):
"""After create_all(), document, job, and transcript tables exist."""
engine = _in_memory_engine()
# Ensure models are imported so metadata is populated
from transcription.models import Document, Job, Transcript # noqa: F401
import transcription.db as db_module
from transcription.models import Document # noqa: F401
from transcription.models import Job # noqa: F401
from transcription.models import Transcript # noqa: F401
db_module.create_all(engine=engine)
await db_module.create_all(engine=engine)
inspector = inspect(engine)
table_names = set(inspector.get_table_names())
async with engine.connect() as connection:
table_names = set(await connection.run_sync(lambda conn: inspect(conn).get_table_names()))
await engine.dispose()
assert "document" in table_names
assert "job" in table_names
assert "transcript" in table_names
@@ -37,31 +44,37 @@ class TestSchemaBootstrap:
class TestSessionFactory:
"""Verify get_session yields and cleans up sessions."""
def test_get_session_yields_session(self):
@pytest.mark.asyncio
async def test_get_session_yields_session(self):
"""get_session() yields a usable Session object."""
engine = _in_memory_engine()
SQLModel.metadata.create_all(engine)
async with engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)
import transcription.db as db_module
with db_module.get_session(engine=engine) as session:
assert isinstance(session, Session)
factory = db_module.async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with db_module.get_session(session_factory=factory) as session:
assert isinstance(session, AsyncSession)
await engine.dispose()
def test_session_is_closed_after_generator_exit(self):
@pytest.mark.asyncio
async 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)
async with engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)
import transcription.db as db_module
with db_module.get_session(engine=engine) as session:
factory = db_module.async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with db_module.get_session(session_factory=factory) as session:
# Session is usable inside the context
session.execute(text("SELECT 1"))
await session.exec(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
assert captured.sync_session._transaction is None
await engine.dispose()
class TestBootstrapPolicy:
@@ -72,7 +85,11 @@ class TestBootstrapPolicy:
from transcription.config import Settings
from transcription.db import should_bootstrap_schema
settings = Settings(openrouter_api_key="test-key", environment="production")
settings = Settings(
openrouter_api_key="test-key",
environment="production",
bootstrap_schema_on_startup=None,
)
assert should_bootstrap_schema(settings) is False
def test_development_defaults_to_bootstrap(self):
@@ -80,7 +97,11 @@ class TestBootstrapPolicy:
from transcription.config import Settings
from transcription.db import should_bootstrap_schema
settings = Settings(openrouter_api_key="test-key", environment="development")
settings = Settings(
openrouter_api_key="test-key",
environment="development",
bootstrap_schema_on_startup=None,
)
assert should_bootstrap_schema(settings) is True
def test_explicit_override_wins(self):
@@ -94,3 +115,27 @@ class TestBootstrapPolicy:
bootstrap_schema_on_startup=True,
)
assert should_bootstrap_schema(settings) is True
class TestRuntimeSqliteEngineConfig:
"""Verify sqlite runtime engine configuration from settings."""
def test_in_memory_sqlite_uses_static_pool_by_default(self):
"""In-memory sqlite URLs get StaticPool for consistent connections."""
from transcription.config import Settings
from transcription.db.runtime import _build_engine
settings = Settings(openrouter_api_key="test-key", database_url="sqlite://")
engine = _build_engine(settings)
assert engine.sync_engine.pool.__class__.__name__ == "StaticPool"
def test_file_sqlite_does_not_force_static_pool(self):
"""File-backed sqlite URLs keep default pool behavior."""
from transcription.config import Settings
from transcription.db.runtime import _build_engine
settings = Settings(openrouter_api_key="test-key", database_url="sqlite:///./runtime-test.db")
engine = _build_engine(settings)
assert engine.sync_engine.pool.__class__.__name__ != "StaticPool"
+4 -1
View File
@@ -2,7 +2,10 @@
import pytest
from transcription.errors import AppError, ErrorCategory, classify_unexpected_error, new_error_id
from transcription.errors import AppError
from transcription.errors import ErrorCategory
from transcription.errors import classify_unexpected_error
from transcription.errors import new_error_id
@pytest.mark.unit
+77 -16
View File
@@ -5,7 +5,10 @@ from uuid import UUID
import pytest
from sqlalchemy.exc import IntegrityError
from transcription.models import Document, Job, JobStatus, Transcript
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
def _make_document(**overrides) -> Document:
@@ -113,7 +116,14 @@ class TestTranscriptModel:
"""A Transcript with text set and error_detail None persists correctly."""
doc = _persist_document(session)
job = _persist_job(session, doc)
transcript = Transcript(job_id=job.id, text="Dear Sir, ...")
transcript = Transcript(
job_id=job.id,
revision=0,
provider="openrouter",
model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md",
text="Dear Sir, ...",
)
session.add(transcript)
session.commit()
session.refresh(transcript)
@@ -127,7 +137,14 @@ class TestTranscriptModel:
"""A Transcript with text None and error_detail set persists correctly."""
doc = _persist_document(session)
job = _persist_job(session, doc)
transcript = Transcript(job_id=job.id, error_detail="Provider timeout")
transcript = Transcript(
job_id=job.id,
revision=0,
provider="openrouter",
model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md",
error_detail="Provider timeout",
)
session.add(transcript)
session.commit()
session.refresh(transcript)
@@ -137,19 +154,46 @@ class TestTranscriptModel:
assert fetched.text is None
assert fetched.error_detail == "Provider timeout"
def test_job_id_is_unique(self, session):
"""Inserting two transcripts with the same job_id raises an integrity error."""
def test_job_revision_pair_is_unique(self, session):
"""Duplicate job_id and revision combinations raise an integrity error."""
doc = _persist_document(session)
job = _persist_job(session, doc)
t1 = Transcript(job_id=job.id, text="First")
session.add(t1)
session.add(
Transcript(
job_id=job.id,
revision=0,
provider="openrouter",
model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md",
text="First",
)
)
session.add(
Transcript(
job_id=job.id,
revision=1,
provider="openrouter",
model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md",
text="Second",
)
)
session.commit()
t2 = Transcript(job_id=job.id, text="Duplicate")
session.add(t2)
session.add(
Transcript(
job_id=job.id,
revision=1,
provider="openrouter",
model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md",
text="Duplicate",
)
)
with pytest.raises(IntegrityError):
session.commit()
session.rollback()
class TestRelationships:
@@ -165,15 +209,32 @@ class TestRelationships:
assert len(doc.jobs) == 2
assert all(isinstance(j, Job) for j in doc.jobs)
def test_job_exposes_transcript(self, session):
"""job.transcript returns the linked Transcript."""
def test_job_exposes_transcripts(self, session):
"""job.transcripts returns linked Transcript history."""
doc = _persist_document(session)
job = _persist_job(session, doc)
transcript = Transcript(job_id=job.id, text="Transcribed text")
session.add(transcript)
session.add(
Transcript(
job_id=job.id,
revision=0,
provider="openrouter",
model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md",
text="Transcribed text",
)
)
session.add(
Transcript(
job_id=job.id,
revision=1,
provider="openrouter",
model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md",
text="Transcribed text v2",
)
)
session.commit()
session.refresh(job)
assert job.transcript is not None
assert isinstance(job.transcript, Transcript)
assert job.transcript.text == "Transcribed text"
assert len(job.transcripts) == 2
assert all(isinstance(t, Transcript) for t in job.transcripts)
-1
View File
@@ -2,7 +2,6 @@
from pathlib import Path
PROMPT_PATH = Path("prompts/transcribe_document.md")
+51 -12
View File
@@ -10,38 +10,63 @@ from uuid import UUID
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlmodel import delete
from transcription.app import create_app
from transcription.config import Settings
from transcription.config import _settings
from transcription.db import create_all
from transcription.db import get_session
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
TranscriptSeed = tuple[int, str | None, str | None]
@pytest.fixture
def app_client(tmp_path: Path) -> tuple[FastAPI, TestClient]:
@pytest.fixture(scope="session")
def app_client(tmp_path_factory: pytest.TempPathFactory) -> tuple[FastAPI, TestClient]:
"""Provide a real application and test client backed by in-memory SQLite."""
tmp_path = tmp_path_factory.mktemp("ui")
settings = Settings(
openrouter_api_key="test-key",
database_url="sqlite:///:memory:",
environment="test",
bootstrap_schema_on_startup=True,
upload_dir=tmp_path / "uploads",
prompt_dir=tmp_path / "prompts",
)
_settings.set(settings)
app = create_app()
app.state.runtime = initialize_database_runtime(settings=settings)
asyncio.run(create_all(engine=app.state.runtime.engine))
with TestClient(app) as client:
yield app, client
@pytest.fixture(autouse=True)
def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
"""Reset UI-facing tables before each test for isolation."""
app, _ = app_client
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(Job))
await session.exec(delete(Document))
await session.commit()
asyncio.run(_clear())
@pytest.fixture
def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
"""Return a helper for inserting a document/job/transcript trio."""
app, _ = app_client
fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid"
def _seed(
*,
@@ -49,10 +74,17 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
status: JobStatus = JobStatus.TRANSCRIBED,
transcript_text: str | None = "Sample transcript text",
error_detail: str | None = None,
transcript_revisions: list[TranscriptSeed] | None = None,
source_file: Path | None = None,
) -> UUID:
async def _insert() -> UUID:
async with get_session(session_factory=app.state.runtime.session_factory) as session:
document = Document(filename=filename, file_path=f"uploads/{filename}")
stored_path = app.state.settings.upload_dir / filename
stored_path.parent.mkdir(parents=True, exist_ok=True)
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))
session.add(document)
await session.flush()
@@ -60,16 +92,23 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
session.add(job)
await session.flush()
if transcript_text is not None or error_detail is not None:
session.add(
Transcript(
job_id=job.id,
provider="openrouter",
prompt_name="transcribe_document",
text=transcript_text,
error_detail=error_detail,
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(
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,
)
)
)
await session.commit()
return job.id
+63 -39
View File
@@ -1,53 +1,77 @@
"""Tests for the jobs page route."""
from uuid import UUID
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from transcription.ui import register_pages
from transcription.ui.pages import jobs_page
from transcription.ui.pages.jobs_page import JobTableRow
@pytest.fixture
def client(monkeypatch):
"""Provide a minimal app client with jobs data patched for rendering."""
async def _fetch_jobs_stub():
return [
JobTableRow(
id=UUID("00000000-0000-0000-0000-000000000001"),
status="queued",
filename="sample.pdf",
retry_count=2,
created_at="2026-01-01T12:00:00+00:00",
updated_at="2026-01-01T12:01:00+00:00",
)
]
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_stub)
app = FastAPI()
register_pages(app)
with TestClient(app) as test_client:
yield test_client
from transcription.models import JobStatus
@pytest.mark.integration
class TestPageRendering:
"""Verify the jobs page is available and includes the main controls."""
"""Verify jobs routes render correctly with real app wiring."""
def test_jobs_page_renders_expected_controls(self, client, monkeypatch):
"""GET /ui/jobs returns the page shell and jobs controls."""
response = client.get("/ui/jobs")
async def _fetch_jobs_empty():
return []
monkeypatch.setattr(jobs_page, "fetch_jobs", _fetch_jobs_empty)
def test_jobs_page_renders_empty_state(self, app_client):
"""GET /ui/jobs renders the page and empty-state text when no jobs exist."""
_, client = app_client
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")
response = client.get("/ui/jobs")
assert response.status_code == 200
assert "sample.pdf" in response.text
assert "transcribed" in response.text
def test_job_detail_page_renders_seeded_job(self, app_client, seed_job):
"""GET /ui/jobs/{job_id} renders detail content for a real seeded job."""
_, client = app_client
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "single_page_pdf.pdf"
job_id = seed_job(
filename="detail.pdf",
status=JobStatus.TRANSCRIBED,
transcript_revisions=[
(0, None, "first attempt failed"),
(1, "hello", None),
],
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 "detail.pdf" in response.text
assert "Transcripts" in response.text
assert "Revision" in response.text
assert "first attempt failed" in response.text
assert "hello" in response.text
assert "Document preview" in response.text
assert "/uploads/detail.pdf" in response.text
def test_job_detail_page_rejects_invalid_id(self, app_client):
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
_, client = app_client
response = client.get("/ui/jobs/not-a-uuid")
assert response.status_code == 200
assert "Invalid job id" in response.text
def test_job_detail_page_handles_missing_job(self, app_client):
"""GET /ui/jobs/{job_id} shows not-found state for unknown IDs."""
_, client = app_client
missing_id = uuid4()
response = client.get(f"/ui/jobs/{missing_id}")
assert response.status_code == 200
assert "Job not found" in response.text
+8 -29
View File
@@ -1,39 +1,18 @@
"""Tests for UI page registration wiring."""
import pytest
from fastapi import FastAPI
from transcription.ui import register_pages
@pytest.mark.integration
class TestPageRegistration:
"""Verify page registration and route wiring."""
"""Verify page registration and mounted UI routes."""
def test_register_pages_wires_upload_jobs_and_mount(self, monkeypatch):
"""register_pages registers pages and mounts NiceGUI at /ui."""
calls: list[str] = []
def test_ui_mount_serves_registered_pages(self, app_client):
"""Mounted UI routes respond successfully when the full app is created."""
_, client = app_client
def _record_upload() -> None:
calls.append("upload")
upload_response = client.get("/ui/upload")
jobs_response = client.get("/ui/jobs")
def _record_jobs() -> None:
calls.append("jobs")
def _record_run_with(
_app: FastAPI,
*,
mount_path: str,
show_welcome_message: bool,
dark: bool,
) -> None:
calls.append(f"run_with:{mount_path}:{show_welcome_message}:{dark}")
monkeypatch.setattr("transcription.ui.register_upload_page", _record_upload)
monkeypatch.setattr("transcription.ui.register_jobs_page", _record_jobs)
monkeypatch.setattr("transcription.ui.ui.run_with", _record_run_with)
app = FastAPI()
register_pages(app)
assert calls == ["upload", "jobs", "run_with:/ui:False:True"]
assert upload_response.status_code == 200
assert jobs_response.status_code == 200
+9 -29
View File
@@ -1,55 +1,35 @@
"""Tests for the upload page route."""
from pathlib import Path
"""Tests for upload and entry-point routes."""
import pytest
from fastapi.testclient import TestClient
from transcription.app import create_app
from transcription.config import Settings
from transcription.config import _settings
@pytest.fixture
def client(tmp_path: Path):
"""Provide a real app client backed by in-memory SQLite."""
settings = Settings(
openrouter_api_key="test-key",
database_url="sqlite:///:memory:",
environment="test",
upload_dir=tmp_path / "uploads",
prompt_dir=tmp_path / "prompts",
)
_settings.set(settings)
app = create_app()
with TestClient(app) as test_client:
yield test_client
@pytest.mark.integration
class TestPageRendering:
"""Verify the upload page is available and includes the main controls."""
"""Verify upload-related routes return working pages."""
def test_root_redirects_to_ui(self, client):
def test_root_redirects_to_ui(self, app_client):
"""GET / redirects to the UI mount point."""
_, client = app_client
response = client.get("/", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "/ui"
def test_ui_redirects_to_upload(self, client):
def test_ui_redirects_to_upload(self, app_client):
"""GET /ui redirects to the upload page."""
_, client = app_client
response = client.get("/ui", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "/ui/upload"
def test_upload_page_renders_expected_controls(self, client):
def test_upload_page_renders_expected_controls(self, app_client):
"""GET /ui/upload returns the page shell and upload controls."""
_, client = app_client
response = client.get("/ui/upload")
assert response.status_code == 200
assert "Upload Document" in response.text
assert "Select document file" in response.text
assert "Upload" in response.text
assert "Jobs" in response.text