Step 1 complete!

This commit is contained in:
Jim Lancaster
2026-06-24 11:49:00 -05:00
parent 5165fa64bc
commit cba4d890a1
4 changed files with 221 additions and 5 deletions
+2 -1
View File
@@ -6,6 +6,7 @@ Three models capture the MVP lifecycle:
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import StrEnum from enum import StrEnum
from typing import Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field, Relationship, SQLModel
@@ -47,7 +48,7 @@ class Job(SQLModel, table=True):
# --- relationships --- # --- relationships ---
document: Document = Relationship(back_populates="jobs") 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): class Transcript(SQLModel, table=True):
+32 -2
View File
@@ -1,16 +1,34 @@
"""Tests for transcription.config — settings loading, provider defaults, and paths.""" """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 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: class TestSettingsLoading:
"""Verify Settings construction and required-field validation.""" """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.""" """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.""" """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: class TestProviderSettings:
@@ -18,12 +36,21 @@ class TestProviderSettings:
def test_defaults_to_openrouter(self): def test_defaults_to_openrouter(self):
"""Default provider is openrouter when not explicitly set.""" """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): def test_rejects_invalid_value(self):
"""Setting PROVIDER to an invalid value raises ValidationError.""" """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): def test_optional_fields_default_to_none(self):
"""provider_model, openrouter_http_referer, and openrouter_app_title are None when unset.""" """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: class TestPathSettings:
@@ -31,3 +58,6 @@ class TestPathSettings:
def test_path_fields_are_path_objects(self): def test_path_fields_are_path_objects(self):
"""upload_dir and prompt_dir are Path instances.""" """upload_dir and prompt_dir are Path instances."""
settings = _make_settings()
assert isinstance(settings.upload_dir, Path)
assert isinstance(settings.prompt_dir, Path)
+61 -2
View File
@@ -1,18 +1,77 @@
"""Tests for transcription.db — schema bootstrap and session factory.""" """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: class TestSchemaBootstrap:
"""Verify create_all produces the expected table set.""" """Verify create_all produces the expected table set."""
def test_create_all_creates_expected_tables(self): def test_create_all_creates_expected_tables(self):
"""After create_all(), document, job, and transcript tables exist.""" """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: class TestSessionFactory:
"""Verify get_session yields and cleans up sessions.""" """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.""" """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.""" """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()
+126
View File
@@ -1,16 +1,54 @@
"""Tests for transcription.models — Document, Job, Transcript persistence and relationships.""" """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 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: class TestDocumentModel:
"""Verify Document creation and default field population.""" """Verify Document creation and default field population."""
def test_can_be_persisted(self, session): def test_can_be_persisted(self, session):
"""A Document round-trips through the database with correct fields.""" """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): def test_defaults_are_populated(self, session):
"""id is a UUID and uploaded_at is populated on creation.""" """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: class TestJobModel:
@@ -18,15 +56,53 @@ class TestJobModel:
def test_can_be_created_for_document(self, session): def test_can_be_created_for_document(self, session):
"""A Job linked to a Document via FK persists correctly.""" """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): def test_defaults_are_populated(self, session):
"""Default status is queued; created_at and updated_at are populated.""" """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): def test_transitions_to_transcribed(self, session):
"""Status updates from queued to processing to transcribed.""" """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): def test_transitions_to_failed(self, session):
"""Status updates from processing to failed.""" """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: class TestTranscriptModel:
@@ -34,12 +110,45 @@ class TestTranscriptModel:
def test_success_record_persists(self, session): def test_success_record_persists(self, session):
"""A Transcript with text set and error_detail None persists correctly.""" """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): def test_failure_record_persists(self, session):
"""A Transcript with text None and error_detail set persists correctly.""" """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): def test_job_id_is_unique(self, session):
"""Inserting two transcripts with the same job_id raises an integrity error.""" """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: class TestRelationships:
@@ -47,6 +156,23 @@ class TestRelationships:
def test_document_exposes_jobs(self, session): def test_document_exposes_jobs(self, session):
"""document.jobs returns the linked Job list.""" """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): def test_job_exposes_transcript(self, session):
"""job.transcript returns the linked Transcript.""" """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"