Files
transcription/tests/services/test_v2_crud.py
T
2026-08-23 12:23:57 -05:00

366 lines
14 KiB
Python

from uuid import uuid4
import pytest
from pydantic import JsonValue
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import Person
from transcription.db.models import Source
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentService
from transcription.services.errors import SourceDeleteBlockedError
from transcription.services.errors import TranscriptionError
from transcription.services.evidence import EvidenceService
from transcription.services.jobs import JobService
from transcription.services.people import PeopleError
from transcription.services.people import PeopleService
from transcription.services.sources import SourceService
@pytest.mark.asyncio
async def test_people_service_handles_person_and_document_person_crud(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
people_service = PeopleService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="person-doc"))
person = await people_service.create_person(Person(given_names="Ada", last_name="Lovelace"))
author_role = await people_service.create_person_role(label="Author")
recipient_role = await people_service.create_person_role(label="Recipient")
assert document.document_type_id is None
link = await people_service.create_document_person(
DocumentPerson(document_id=document.id, person_id=person.id, role_id=author_role.id)
)
fetched = await people_service.read_document_person(link.id)
assert fetched.id == link.id
assert fetched.role_id == author_role.id
updated_link = await people_service.update_document_person(
DocumentPerson(id=link.id, document_id=document.id, person_id=person.id, role_id=recipient_role.id)
)
assert updated_link.role_id == recipient_role.id
listed = await people_service.list_document_people(document_id=document.id)
assert len(listed) == 1
people = await people_service.list_people()
assert len(people) == 1
await people_service.delete_document_person(updated_link)
assert len(await people_service.list_document_people(document_id=document.id)) == 0
@pytest.mark.asyncio
async def test_people_service_normalizes_and_rejects_duplicate_family_search_ids(default_session_factory):
people_service = PeopleService(session_factory=default_session_factory)
created = await people_service.create_person(Person(given_names="Hig", last_name="Higgins", family_search_id=" g8t4-mdq "))
assert created.family_search_id == "G8T4-MDQ"
with pytest.raises(PeopleError) as duplicate:
await people_service.create_person(Person(given_names="Duplicate", last_name="Hig", family_search_id="G8T4-MDQ"))
assert duplicate.value.category == ErrorCategory.CONFLICT
with pytest.raises(PeopleError) as malformed:
await people_service.create_person(Person(given_names="Malformed", last_name="Person", family_search_id="not-an-id"))
assert malformed.value.category == ErrorCategory.VALIDATION
@pytest.mark.asyncio
async def test_transcription_service_manages_source_crud(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
transcriptions = SourceService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="source-doc"))
source = await transcriptions.create_source(
Source(
document_id=document.id,
page_number=1,
upload_name="page-1.jpg",
filename="page-1.jpg",
file_path="uploads/page-1.jpg",
file_hash="7" * 64,
file_size_bytes=1,
)
)
fetched = await transcriptions.read_source(source.id)
assert fetched.id == source.id
source.page_number = 2
updated = await transcriptions.update_source(source)
assert updated.page_number == 2
listed = await transcriptions.list_sources(document_id=document.id)
assert len(listed) == 1
filtered = await transcriptions.query_sources(document_id=document.id, page_number=2)
assert len(filtered) == 1
await transcriptions.delete_source(updated)
assert len(await transcriptions.list_sources(document_id=document.id)) == 0
@pytest.mark.asyncio
async def test_source_navigation_is_bounded_to_ordered_document(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
sources = SourceService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="ordered"))
other = await documents.create_document(Document(id=uuid4(), name="other"))
first, second, third, _foreign = [
await sources.create_source(
Source(
document_id=document_id,
page_number=page_number,
upload_name=f"page-{page_number}.jpg",
filename=f"page-{page_number}.jpg",
file_path=f"uploads/page-{page_number}.jpg",
file_hash=str(page_number) * 64,
file_size_bytes=1,
)
)
for document_id, page_number in [
(document.id, 1),
(document.id, 2),
(document.id, 3),
(other.id, 2),
]
]
first_navigation = await sources.read_source_navigation(first.id)
middle_navigation = await sources.read_source_navigation(second.id)
last_navigation = await sources.read_source_navigation(third.id)
assert (first_navigation.previous_id, first_navigation.next_id) == (None, second.id)
assert (middle_navigation.previous_id, middle_navigation.next_id) == (first.id, third.id)
assert (last_navigation.previous_id, last_navigation.next_id) == (second.id, None)
@pytest.mark.asyncio
async def test_transcription_service_job_source_crud_uses_caller_session(default_session_factory):
transcriptions = SourceService(session_factory=default_session_factory)
async with transcriptions._session_scope() as session:
document = Document(id=uuid4(), name="job-source-doc")
session.add(document)
await session.flush()
job = Job(document_id=document.id)
session.add(job)
await session.flush()
source = Source(
document_id=document.id,
page_number=1,
upload_name="job-source.jpg",
filename="job-source.jpg",
file_path="uploads/job-source.jpg",
file_hash="8" * 64,
file_size_bytes=1,
)
session.add(source)
await session.flush()
job_source = await transcriptions.create_job_source(
JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING),
session=session,
)
assert job_source.status == JobSourceStatus.PENDING
job_source.status = JobSourceStatus.TRANSCRIBED
updated = await transcriptions.update_job_source(job_source, session=session)
assert updated.status == JobSourceStatus.TRANSCRIBED
fetched = await transcriptions.read_job_source(job_source.id, session=session)
assert fetched.id == job_source.id
listed = await transcriptions.list_job_sources(job_id=job.id, session=session)
assert len(listed) == 1
await transcriptions.delete_job_source(updated, session=session)
await session.commit()
assert len(await transcriptions.list_job_sources(job_id=job.id)) == 0
@pytest.mark.asyncio
async def test_create_job_source_rejects_duplicate_job_source_membership(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = SourceService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="duplicate-job-source-doc"))
job = await jobs.create_job(Job(document_id=document.id))
source = await transcriptions.create_source(
Source(
document_id=document.id,
page_number=1,
upload_name="duplicate.jpg",
filename="duplicate.jpg",
file_path="uploads/duplicate.jpg",
file_hash="d" * 64,
file_size_bytes=1,
)
)
await transcriptions.create_job_source(
JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING)
)
with pytest.raises(TranscriptionError) as duplicate:
await transcriptions.create_job_source(
JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING)
)
assert duplicate.value.category == ErrorCategory.CONFLICT
@pytest.mark.asyncio
async def test_document_detail_loads_linked_person_relationship(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
people_service = PeopleService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="detail-person-doc"))
person = await people_service.create_person(Person(given_names="Grace", last_name="Hopper"))
author_role = await people_service.create_person_role(label="Author")
await people_service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role_id=author_role.id,
)
)
detail = await documents.read_document_detail(document.id)
assert len(detail.document_people) == 1
link = detail.document_people[0]
assert link.person is not None
assert link.person.full_name == "Grace Hopper"
assert link.role_id == author_role.id
@pytest.mark.asyncio
async def test_document_delete_is_blocked_with_source_and_job_dependencies(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = SourceService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="blocked-by-deps"))
job = await jobs.create_job(Job(document_id=document.id))
source = await transcriptions.create_source(
Source(
document_id=document.id,
page_number=1,
upload_name="blocked.jpg",
filename="blocked.jpg",
file_path="uploads/blocked.jpg",
file_hash="9" * 64,
file_size_bytes=1,
)
)
await transcriptions.create_job_source(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
with pytest.raises(DocumentDeleteBlockedError) as exc_info:
await documents.delete_document(document)
message = exc_info.value.message
assert "Sources" in message
assert "Jobs" in message
@pytest.mark.asyncio
async def test_source_delete_blocks_when_linked_to_multiple_jobs(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = SourceService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="multi-job-source-doc"))
job_one = await jobs.create_job(Job(document_id=document.id))
job_two = await jobs.create_job(Job(document_id=document.id))
source = await transcriptions.create_source(
Source(
document_id=document.id,
page_number=1,
upload_name="shared-page.jpg",
filename="shared-page.jpg",
file_path="uploads/shared-page.jpg",
file_hash="a" * 64,
file_size_bytes=1,
)
)
await transcriptions.create_job_source(
JobSource(job_id=job_one.id, source_id=source.id, status=JobSourceStatus.PENDING)
)
await transcriptions.create_job_source(
JobSource(job_id=job_two.id, source_id=source.id, status=JobSourceStatus.PENDING)
)
with pytest.raises(SourceDeleteBlockedError):
await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id)
@pytest.mark.asyncio
async def test_update_job_source_transcription_persists_provider_json_payloads(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = SourceService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="provider-payloads-doc"))
job = await jobs.create_job(Job(document_id=document.id))
source = await transcriptions.create_source(
Source(
document_id=document.id,
page_number=1,
upload_name="provider.jpg",
filename="provider.jpg",
file_path="uploads/provider.jpg",
file_hash="b" * 64,
file_size_bytes=1,
)
)
await transcriptions.create_job_source(
JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING)
)
metadata: dict[str, JsonValue] = {
"finish_reason": "stop",
"usage": {"input_tokens": 11, "output_tokens": 22, "total_tokens": 33},
}
raw_payload: dict[str, JsonValue] = {"id": "resp_xyz", "choices": [{"message": {"content": "provider transcript"}}]}
await transcriptions.update_job_source_transcription(
job_id=job.id,
source_id=source.id,
text="provider transcript",
ai_metadata=metadata,
raw_api_response=raw_payload,
provider="openrouter",
model="test-model",
)
stored_rows = await transcriptions.list_job_sources(job_id=job.id)
assert len(stored_rows) == 1
assert stored_rows[0].status == JobSourceStatus.TRANSCRIBED
evidence = EvidenceService(session_factory=transcriptions.session_factory)
attempt = await evidence.read_latest_execution_attempt(job_source_id=stored_rows[0].id)
assert attempt is not None
assert attempt.attempt.raw_transcription == "provider transcript"
assert attempt.attempt.normalized_metadata == metadata
assert attempt.attempt.sdk_response_snapshot == raw_payload