Files
transcription/tests/test_models.py
T
zoltan57andCopilot App 66e2dce465 V4.6 Phase 7: drive ty check to zero and add a blocking quality gate [HIGH-06]
Baseline was 207 diagnostics. Two real bugs were hiding in the noise:

- tools/run_destructive_tests.py imported ctypes.wintypes at module scope,
  which raises on non-Windows, and called fcntl unconditionally. The Windows
  and POSIX implementations now live under a module-level sys.platform split.
- tests/ui/test_sources_page.py constructed Source(...) without document_id.

Structural fixes, not suppressions:

- New src/transcription/db/loading.py owns the SQLModel-field to
  QueryableAttribute reinterpretation via orm_attribute()/selectinload()/
  defer(). This removed 42 "# pyright: ignore[reportArgumentType]" comments
  across documents/jobs/people/sources. Its docstring records that
  selectinload(A.b, B.c) is NOT equivalent to the chained form: varargs
  applies the selectin strategy only to the last path element, which under
  lazy="raise" raises InvalidRequestError at render time.
- db/session.py transaction_scope no longer accepts or yields
  AsyncSessionTransaction. No caller ever passed one, sessionmaker.begin()
  yields an AsyncSession, and the dead branch was latently buggy because
  services call .exec(). Cleared 7 workflows.py diagnostics.
- services/registry.py RegistryService is bound by a new RegistryEntry
  Protocol instead of bare SQLModel, so the shared implementation can read
  id/label/normalized_label/is_active. Cleared 9 diagnostics.
- Column expressions in sources.py/jobs.py/test_store.py wrap in sqlmodel
  col(), the idiom already used in registry.py.
- read_source_navigation wraps its literal tuple bounds in literal().
- normalization.py narrows with isinstance(image, TiffImageFile) rather than
  comparing image.format, since tag_v2 is TIFF-only.
- linked_people.render uses @ui.refreshable_method, the NiceGUI API for bound
  methods.
- The OpenRouter capturing client re-raises ResponseNotRead when the response
  stream is not async rather than mis-wrapping it.

Tooling gate:

- New .pre-commit-config.yaml runs ruff check and ty check as blocking hooks.
  No pre-commit config previously existed. Negative-tested: injecting a type
  error fails both hooks.
- The last two "# pyright: ignore" comments (config.py) are removed; ty does
  not honor pyright directives. One "# ty: ignore" remains, in
  tests/test_prompts.py, where the test deliberately assigns to a frozen
  field to assert ValidationError.
- asyncio_default_fixture_loop_scope is pinned to "function" so
  pytest-asyncio behavior does not shift on upgrade.

Verification: ruff check clean, ty check reports 0 diagnostics, 292 passed
and 4 skipped, pre-commit passes and demonstrably fails on a regression, and
tools/run_destructive_tests.py runs on Windows.

Co-authored-by: Copilot App <[email protected]>
2026-08-17 19:57:23 -05:00

283 lines
9.3 KiB
Python

"""Tests for the V2 SQLModel persistence layer and relationships."""
from typing import Any
from uuid import UUID
import pytest
from sqlalchemy.exc import IntegrityError
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentType
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.db.models import Source
def _make_document(**overrides: Any) -> Document:
defaults: dict[str, Any] = {
"name": "letter bundle",
"notes": "Family correspondence",
}
defaults.update(overrides)
return Document(**defaults)
def _persist_document_type(session, *, label: str = "Letter") -> DocumentType:
document_type = DocumentType(label=label, normalized_label=label.strip().casefold())
session.add(document_type)
session.commit()
session.refresh(document_type)
return document_type
def _persist_person_role(session, *, label: str = "Author") -> PersonRole:
role = PersonRole(label=label, normalized_label=label.strip().casefold())
session.add(role)
session.commit()
session.refresh(role)
return role
def _persist_document(session) -> Document:
document_type = _persist_document_type(session)
document = _make_document(document_type_id=document_type.id)
session.add(document)
session.commit()
session.refresh(document)
return document
def _persist_person(session, **overrides: Any) -> Person:
defaults: dict[str, Any] = {"full_name": "Ada Lovelace"}
defaults.update(overrides)
person = Person(**defaults)
session.add(person)
session.commit()
session.refresh(person)
return person
def _persist_job(session, document: Document) -> Job:
job = Job(document_id=document.id)
session.add(job)
session.commit()
session.refresh(job)
return job
def _persist_source(session, document: Document, *, page_number: int = 1, **overrides: Any) -> Source:
defaults: dict[str, Any] = {
"document_id": document.id,
"page_number": page_number,
"upload_name": "letter.jpg",
"filename": "stored-letter.jpg",
"file_path": "/uploads/stored-letter.jpg",
"raw_transcription": "Original machine text",
}
defaults.update(overrides)
defaults["file_hash"] = "a" * 64
defaults["file_size_bytes"] = 123
source = Source(**defaults)
session.add(source)
session.commit()
session.refresh(source)
return source
def _persist_job_source(session, job: Job, source: Source, **overrides: Any) -> JobSource:
defaults: dict[str, Any] = {
"job_id": job.id,
"source_id": source.id,
"status": JobSourceStatus.PENDING,
}
defaults.update(overrides)
job_source = JobSource(**defaults)
session.add(job_source)
session.commit()
session.refresh(job_source)
return job_source
class TestDocumentModel:
def test_can_be_persisted(self, session):
document = _persist_document(session)
fetched = session.get(Document, document.id)
assert fetched is not None
assert fetched.name == "letter bundle"
def test_defaults_are_populated(self, session):
document = _persist_document(session)
assert isinstance(document.id, UUID)
assert document.created_at is not None
assert document.updated_at is not None
def test_can_reference_document_type_registry(self, session):
document_type = _persist_document_type(session, label="Record")
document = _make_document(document_type_id=document_type.id)
session.add(document)
session.commit()
session.refresh(document)
assert document.document_type_id == document_type.id
class TestJobModel:
def test_can_be_created_for_document(self, session):
document = _persist_document(session)
job = _persist_job(session, document)
fetched = session.get(Job, job.id)
assert fetched is not None
assert fetched.document_id == document.id
def test_defaults_are_populated(self, session):
document = _persist_document(session)
job = _persist_job(session, document)
assert job.status == JobStatus.QUEUED
assert job.retry_count == 0
assert job.date_created is not None
assert job.date_updated is not None
def test_transitions_to_completed(self, session):
document = _persist_document(session)
job = _persist_job(session, document)
job.status = JobStatus.PROCESSING
session.add(job)
session.commit()
session.refresh(job)
job.status = JobStatus.COMPLETED
session.add(job)
session.commit()
session.refresh(job)
assert job.status == JobStatus.COMPLETED
class TestSourceModel:
def test_can_be_created_for_document(self, session):
document = _persist_document(session)
source = _persist_source(session, document)
fetched = session.get(Source, source.id)
assert fetched is not None
assert fetched.document_id == document.id
assert fetched.page_number == 1
assert fetched.date_uploaded is not None
def test_revised_text_is_supported(self, session):
document = _persist_document(session)
source = _persist_source(session, document, revised_text="Edited output")
fetched = session.get(Source, source.id)
assert fetched is not None
assert fetched.revised_text == "Edited output"
class TestPersonAndDocumentPersonModel:
def test_family_search_id_is_unique_when_present(self, session):
session.add(Person(full_name="First Person", family_search_id="G8T4-MDQ"))
session.commit()
session.add(Person(full_name="Second Person", family_search_id="G8T4-MDQ"))
with pytest.raises(IntegrityError):
session.commit()
def test_document_person_role_is_unique_per_document_person(self, session):
document = _persist_document(session)
person = _persist_person(session)
person_role = _persist_person_role(session)
first = DocumentPerson(
document_id=document.id,
person_id=person.id,
role_id=person_role.id,
)
session.add(first)
session.commit()
duplicate = DocumentPerson(
document_id=document.id,
person_id=person.id,
role_id=person_role.id,
)
session.add(duplicate)
with pytest.raises(IntegrityError):
session.commit()
class TestJobSourceModel:
def test_job_source_persists_json_payloads(self, session):
document = _persist_document(session)
job = _persist_job(session, document)
source = _persist_source(session, document)
job_source = _persist_job_source(
session,
job,
source,
raw_transcription="Page transcript",
ai_metadata={"confidence": 0.91, "boxes": [{"x": 1, "y": 2}]},
raw_api_response={"provider": "test"},
)
fetched = session.get(JobSource, job_source.id)
assert fetched is not None
assert fetched.status == JobSourceStatus.PENDING
assert fetched.ai_metadata == {"confidence": 0.91, "boxes": [{"x": 1, "y": 2}]}
assert fetched.raw_api_response == {"provider": "test"}
class TestRelationships:
def test_document_exposes_jobs_sources_and_people(self, session):
document = _persist_document(session)
_persist_job(session, document)
_persist_source(session, document)
person = _persist_person(session)
person_role = _persist_person_role(session)
link = DocumentPerson(
document_id=document.id,
person_id=person.id,
role_id=person_role.id,
)
session.add(link)
session.commit()
session.refresh(document, attribute_names=["jobs", "sources", "document_people"])
assert len(document.jobs) == 1
assert len(document.sources) == 1
assert len(document.document_people) == 1
class TestRegistryModels:
def test_person_role_normalized_label_is_unique(self, session):
_persist_person_role(session, label="Mentioned")
duplicate = PersonRole(label=" mentioned ", normalized_label="mentioned")
session.add(duplicate)
with pytest.raises(IntegrityError):
session.commit()
def test_document_type_normalized_label_is_unique(self, session):
_persist_document_type(session, label="Journal")
duplicate = DocumentType(label=" journal ", normalized_label="journal")
session.add(duplicate)
with pytest.raises(IntegrityError):
session.commit()
def test_no_relationship_declares_an_implicit_eager_load():
"""CRIT-02 guard: eager loading is a per-query decision, never a model default."""
from sqlmodel import SQLModel
offenders = {
f"{mapper.class_.__name__}.{relationship.key}": relationship.lazy
for mapper in SQLModel._sa_registry.mappers
for relationship in mapper.relationships
if relationship.lazy not in {"raise", "noload"}
}
assert offenders == {}, f"Relationships must not preload by default: {offenders}"