diff --git a/src/transcription/models.py b/src/transcription/models.py index adbbc9d..41cd55f 100644 --- a/src/transcription/models.py +++ b/src/transcription/models.py @@ -6,6 +6,7 @@ Three models capture the MVP lifecycle: from datetime import datetime, timezone from enum import StrEnum +from typing import Optional from uuid import UUID, uuid4 from sqlmodel import Field, Relationship, SQLModel @@ -47,7 +48,7 @@ class Job(SQLModel, table=True): # --- relationships --- document: Document = Relationship(back_populates="jobs") - transcript: "Transcript | None" = Relationship(back_populates="job") + transcript: Optional["Transcript"] = Relationship(back_populates="job") class Transcript(SQLModel, table=True): diff --git a/tests/test_config.py b/tests/test_config.py index a1ab5d2..5066df6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,16 +1,34 @@ """Tests for transcription.config — settings loading, provider defaults, and paths.""" +from pathlib import Path + +import pytest +from pydantic import ValidationError + from transcription.config import Provider, Settings +def _make_settings(**overrides) -> Settings: + """Build a Settings instance with a dummy API key unless overridden.""" + defaults = {"openrouter_api_key": "test-key-abc123"} + defaults.update(overrides) + return Settings(**defaults) + + class TestSettingsLoading: """Verify Settings construction and required-field validation.""" - def test_loads_from_env(self): + def test_loads_from_env(self, monkeypatch): """Settings constructs when OPENROUTER_API_KEY is provided.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "test-key-xyz") + settings = Settings() + assert settings.openrouter_api_key == "test-key-xyz" - def test_requires_api_key(self): + def test_requires_api_key(self, monkeypatch): """Settings raises ValidationError when OPENROUTER_API_KEY is missing.""" + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + with pytest.raises(ValidationError): + Settings(_env_file=None) class TestProviderSettings: @@ -18,12 +36,21 @@ class TestProviderSettings: def test_defaults_to_openrouter(self): """Default provider is openrouter when not explicitly set.""" + settings = _make_settings() + assert settings.provider == Provider.OPENROUTER + assert settings.provider == "openrouter" def test_rejects_invalid_value(self): """Setting PROVIDER to an invalid value raises ValidationError.""" + with pytest.raises(ValidationError): + _make_settings(provider="not-a-provider") def test_optional_fields_default_to_none(self): """provider_model, openrouter_http_referer, and openrouter_app_title are None when unset.""" + settings = _make_settings() + assert settings.provider_model is None + assert settings.openrouter_http_referer is None + assert settings.openrouter_app_title is None class TestPathSettings: @@ -31,3 +58,6 @@ class TestPathSettings: def test_path_fields_are_path_objects(self): """upload_dir and prompt_dir are Path instances.""" + settings = _make_settings() + assert isinstance(settings.upload_dir, Path) + assert isinstance(settings.prompt_dir, Path) diff --git a/tests/test_db.py b/tests/test_db.py index ca6c365..f243c43 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,18 +1,77 @@ """Tests for transcription.db — schema bootstrap and session factory.""" +from unittest.mock import patch + +from sqlalchemy import inspect, text +from sqlmodel import Session, SQLModel, create_engine +from sqlmodel.pool import StaticPool + + +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, + ) + class TestSchemaBootstrap: """Verify create_all produces the expected table set.""" 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 + + SQLModel.metadata.create_all(engine) + + inspector = inspect(engine) + table_names = set(inspector.get_table_names()) + assert "document" in table_names + assert "job" in table_names + assert "transcript" in table_names class TestSessionFactory: """Verify get_session yields and cleans up sessions.""" - def test_get_session_yields_session(self): + def test_get_session_yields_session(self, monkeypatch): """get_session() yields a usable Session object.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "test-key-for-db") + # Clear the lru_cache so Settings is re-created with our env var + from transcription.config import get_settings + get_settings.cache_clear() - def test_session_is_closed_after_generator_exit(self): + engine = _in_memory_engine() + SQLModel.metadata.create_all(engine) + + import transcription.db as db_module + with patch.object(db_module, "engine", engine): + with db_module.get_session() as session: + assert isinstance(session, Session) + + get_settings.cache_clear() + + def test_session_is_closed_after_generator_exit(self, monkeypatch): """After the context manager exits, the session is closed.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "test-key-for-db") + from transcription.config import get_settings + get_settings.cache_clear() + + engine = _in_memory_engine() + SQLModel.metadata.create_all(engine) + + import transcription.db as db_module + with patch.object(db_module, "engine", engine): + with db_module.get_session() 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 + + get_settings.cache_clear() diff --git a/tests/test_models.py b/tests/test_models.py index dc9886d..fae1e5e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,16 +1,54 @@ """Tests for transcription.models — Document, Job, Transcript persistence and relationships.""" +from uuid import UUID + +import pytest +from sqlalchemy.exc import IntegrityError + from transcription.models import Document, Job, JobStatus, Transcript +def _make_document(**overrides) -> Document: + """Create a Document with sensible defaults.""" + defaults = {"filename": "letter.jpg", "file_path": "/uploads/letter.jpg"} + defaults.update(overrides) + return Document(**defaults) + + +def _persist_document(session) -> Document: + """Create, persist, and return a Document.""" + doc = _make_document() + session.add(doc) + session.commit() + session.refresh(doc) + return doc + + +def _persist_job(session, document: Document) -> Job: + """Create, persist, and return a Job linked to a Document.""" + job = Job(document_id=document.id) + session.add(job) + session.commit() + session.refresh(job) + return job + + class TestDocumentModel: """Verify Document creation and default field population.""" def test_can_be_persisted(self, session): """A Document round-trips through the database with correct fields.""" + doc = _persist_document(session) + fetched = session.get(Document, doc.id) + assert fetched is not None + assert fetched.filename == "letter.jpg" + assert fetched.file_path == "/uploads/letter.jpg" def test_defaults_are_populated(self, session): """id is a UUID and uploaded_at is populated on creation.""" + doc = _persist_document(session) + assert isinstance(doc.id, UUID) + assert doc.uploaded_at is not None class TestJobModel: @@ -18,15 +56,53 @@ class TestJobModel: def test_can_be_created_for_document(self, session): """A Job linked to a Document via FK persists correctly.""" + doc = _persist_document(session) + job = _persist_job(session, doc) + fetched = session.get(Job, job.id) + assert fetched is not None + assert fetched.document_id == doc.id def test_defaults_are_populated(self, session): """Default status is queued; created_at and updated_at are populated.""" + doc = _persist_document(session) + job = _persist_job(session, doc) + assert job.status == JobStatus.QUEUED + assert job.created_at is not None + assert job.updated_at is not None def test_transitions_to_transcribed(self, session): """Status updates from queued to processing to transcribed.""" + doc = _persist_document(session) + job = _persist_job(session, doc) + assert job.status == JobStatus.QUEUED + + job.status = JobStatus.PROCESSING + session.add(job) + session.commit() + session.refresh(job) + assert job.status == JobStatus.PROCESSING + + job.status = JobStatus.TRANSCRIBED + session.add(job) + session.commit() + session.refresh(job) + assert job.status == JobStatus.TRANSCRIBED def test_transitions_to_failed(self, session): """Status updates from processing to failed.""" + doc = _persist_document(session) + job = _persist_job(session, doc) + + job.status = JobStatus.PROCESSING + session.add(job) + session.commit() + session.refresh(job) + + job.status = JobStatus.FAILED + session.add(job) + session.commit() + session.refresh(job) + assert job.status == JobStatus.FAILED class TestTranscriptModel: @@ -34,12 +110,45 @@ class TestTranscriptModel: def test_success_record_persists(self, session): """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, ...") + session.add(transcript) + session.commit() + session.refresh(transcript) + + fetched = session.get(Transcript, transcript.id) + assert fetched is not None + assert fetched.text == "Dear Sir, ..." + assert fetched.error_detail is None def test_failure_record_persists(self, session): """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") + session.add(transcript) + session.commit() + session.refresh(transcript) + + fetched = session.get(Transcript, transcript.id) + assert fetched is not None + 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.""" + doc = _persist_document(session) + job = _persist_job(session, doc) + + t1 = Transcript(job_id=job.id, text="First") + session.add(t1) + session.commit() + + t2 = Transcript(job_id=job.id, text="Duplicate") + session.add(t2) + with pytest.raises(IntegrityError): + session.commit() class TestRelationships: @@ -47,6 +156,23 @@ class TestRelationships: def test_document_exposes_jobs(self, session): """document.jobs returns the linked Job list.""" + doc = _persist_document(session) + _persist_job(session, doc) + _persist_job(session, doc) + + session.refresh(doc) + 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.""" + doc = _persist_document(session) + job = _persist_job(session, doc) + transcript = Transcript(job_id=job.id, text="Transcribed text") + session.add(transcript) + session.commit() + + session.refresh(job) + assert job.transcript is not None + assert isinstance(job.transcript, Transcript) + assert job.transcript.text == "Transcribed text"