generated from john/python-template
model used being carried thru
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROMPT_PATH = Path("prompts/transcribe_document.md")
|
||||
|
||||
|
||||
|
||||
+51
-12
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user