UI update complete?

This commit is contained in:
Jim Lancaster
2026-08-02 13:33:09 -05:00
parent ed6f9dfe25
commit 9653060c2a
26 changed files with 2523 additions and 72 deletions
+188
View File
@@ -0,0 +1,188 @@
from __future__ import annotations
from datetime import UTC
from datetime import datetime
from uuid import uuid4
import pytest
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Person
from transcription.db.models import Source
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
from transcription.services.documents import PersonDeleteBlockedError
from transcription.services.documents import DocumentService
@pytest.mark.asyncio
async def test_read_document_detail_allows_missing_sources(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
created = await service.create_document(
Document(
id=uuid4(),
name="detail-doc",
document_type="letter",
)
)
detail = await service.read_document_detail(created.id)
assert detail.id == created.id
assert detail.sources == []
@pytest.mark.asyncio
async def test_update_document_refreshes_updated_timestamp(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
created = await service.create_document(
Document(
id=uuid4(),
name="timestamp-doc",
document_type="letter",
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
)
)
original_updated_at = created.updated_at
created.notes = "updated"
updated = await service.update_document(created)
assert updated.notes == "updated"
assert updated.updated_at >= original_updated_at
@pytest.mark.asyncio
async def test_delete_document_blocks_when_dependencies_exist(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document = await service.create_document(
Document(
id=uuid4(),
name="blocked-delete",
document_type="record",
)
)
async with service._session_scope() as session:
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="001_page.png",
filename="001_page.png",
file_path="uploads/001_page.png",
)
)
session.add(Job(document_id=document.id))
await session.commit()
with pytest.raises(DocumentDeleteBlockedError):
await service.delete_document(document)
@pytest.mark.asyncio
async def test_delete_document_succeeds_when_unlinked(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document = await service.create_document(
Document(
id=uuid4(),
name="free-delete",
document_type="memo",
)
)
await service.delete_document(document)
with pytest.raises(DocumentError):
await service.read_document_detail(document.id)
@pytest.mark.asyncio
async def test_read_person_detail_loads_document_links(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document = await service.create_document(
Document(
id=uuid4(),
name="linked-doc",
document_type="letter",
)
)
person = await service.create_person(Person(full_name="Linked Person"))
await service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
detail = await service.read_person_detail(person.id)
assert detail.id == person.id
assert len(detail.document_people) == 1
assert detail.document_people[0].document is not None
assert detail.document_people[0].document.name == "linked-doc"
@pytest.mark.asyncio
async def test_update_person_refreshes_updated_timestamp(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
created = await service.create_person(
Person(
full_name="timestamp-person",
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
)
)
original_updated_at = created.updated_at
created.display_name = "updated"
updated = await service.update_person(created)
assert updated.display_name == "updated"
assert updated.updated_at >= original_updated_at
@pytest.mark.asyncio
async def test_delete_person_blocks_when_linked_documents_exist(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document = await service.create_document(
Document(
id=uuid4(),
name="block-person-delete-doc",
document_type="record",
)
)
person = await service.create_person(Person(full_name="Blocked Person"))
await service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
with pytest.raises(PersonDeleteBlockedError):
await service.delete_person(person)
@pytest.mark.asyncio
async def test_delete_person_succeeds_when_unlinked(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
person = await service.create_person(Person(full_name="Free Person"))
await service.delete_person(person)
with pytest.raises(DocumentError):
await service.read_person_detail(person.id)
+109
View File
@@ -9,6 +9,7 @@ from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
from transcription.db.models import Source
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobService
@@ -107,3 +108,111 @@ class TestJobService:
next_job = await job_service.read_next_queued_job()
assert next_job is not None
assert next_job.id == first.id
@pytest.mark.asyncio
async def test_create_job_persists_provider_model_prompt(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="provider-doc")
await document_service.create_document(document=document)
job = Job(
document_id=document.id,
provider="openrouter",
model="google/gemini-2.5-flash",
prompt_name="transcribe_document.md",
)
await job_service.create_job(job=job)
fetched = await job_service.read_job(job_id=job.id)
assert fetched.provider == "openrouter"
assert fetched.model == "google/gemini-2.5-flash"
assert fetched.prompt_name == "transcribe_document.md"
@pytest.mark.asyncio
async def test_read_job_resolves_filename_from_linked_source(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="filename-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id)
await job_service.create_job(job=job)
async with job_service._session_scope() as session:
source = Source(
document_id=document.id,
page_number=1,
upload_name="page_001.png",
filename="stored_page_001.png",
file_path="/uploads/stored_page_001.png",
)
session.add(source)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
fetched = await job_service.read_job(job_id=job.id)
assert fetched.filename == "stored_page_001.png"
@pytest.mark.asyncio
async def test_delete_job_with_guardrails_blocks_processing_jobs(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="processing-delete-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.PROCESSING)
await job_service.create_job(job=job)
with pytest.raises(JobDeleteBlockedError):
await job_service.delete_job_with_guardrails(job_id=job.id)
@pytest.mark.asyncio
async def test_delete_job_with_guardrails_removes_jobsource_links(
self,
job_service: JobService,
document_service: DocumentService,
):
document = Document(id=uuid4(), name="delete-job-doc")
await document_service.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.QUEUED)
await job_service.create_job(job=job)
async with job_service._session_scope() as session:
source = Source(
document_id=document.id,
page_number=1,
upload_name="delete-job-source.jpg",
filename="stored-delete-job-source.jpg",
file_path="/uploads/stored-delete-job-source.jpg",
)
session.add(source)
await session.flush()
session.add(
JobSource(
job_id=job.id,
source_id=source.id,
status=JobSourceStatus.PENDING,
)
)
await session.commit()
await job_service.delete_job_with_guardrails(job_id=job.id)
with pytest.raises(ValueError):
await job_service.read_job(job_id=job.id)
+72
View File
@@ -0,0 +1,72 @@
from uuid import uuid4
import pytest
from sqlmodel import select
from transcription.config import Settings
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import Source
from transcription.services.store import UploadError
from transcription.services.store import create_job_for_document
@pytest.mark.asyncio
async def test_create_job_for_document_requires_at_least_one_upload(async_session, tmp_path):
document = Document(id=uuid4(), name="needs-upload")
async_session.add(document)
await async_session.commit()
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
with pytest.raises(UploadError):
await create_job_for_document(
document_id=document.id,
uploads=[],
session=async_session,
settings=settings,
)
@pytest.mark.asyncio
async def test_create_job_for_document_sorts_uploads_and_creates_links(async_session, tmp_path):
document = Document(id=uuid4(), name="ordered-upload-doc")
async_session.add(document)
await async_session.commit()
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
result = await create_job_for_document(
document_id=document.id,
uploads=[
("folder/b_page.pdf", b"b"),
("folder/A_page.pdf", b"a"),
],
provider="openrouter",
model="test-model",
prompt_name="transcribe_document.md",
session=async_session,
settings=settings,
)
created_job = await async_session.get(Job, result.job_id)
assert created_job is not None
assert created_job.provider == "openrouter"
assert created_job.model == "test-model"
assert created_job.prompt_name == "transcribe_document.md"
sources = (
await async_session.exec(
select(Source)
.where(Source.document_id == document.id)
.order_by(Source.page_number) # pyright: ignore[reportArgumentType]
)
).all()
assert [source.upload_name for source in sources] == ["A_page.pdf", "b_page.pdf"]
assert all(source.filename.endswith(".pdf") for source in sources)
assert all("A_page" not in source.filename and "b_page" not in source.filename for source in sources)
job_sources = (await async_session.exec(select(JobSource).where(JobSource.job_id == result.job_id))).all()
assert len(job_sources) == 2
assert set(result.source_ids) == {job_source.source_id for job_source in job_sources}
@@ -12,6 +12,8 @@ from transcription.db.models import JobStatus
from transcription.db.models import Source
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
from transcription.services.transcription import SourceDeleteBlockedError
from transcription.services.transcription import TranscriptionNotFoundError
from transcription.services.transcription import TranscriptionService
@@ -89,3 +91,66 @@ class TestTranscriptionServiceRevisionUpsert:
assert len(revisions) == 1
assert revisions[0].id == first.id
assert revisions[0].revised_text == "Revision v2"
@pytest.mark.asyncio
async def test_delete_source_from_job_context_removes_source_and_single_link(self, default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
document = Document(id=uuid4(), name="delete-source-success")
await documents.create_document(document=document)
job = Job(document_id=document.id, status=JobStatus.QUEUED)
await jobs.create_job(job=job)
source = Source(
document_id=document.id,
page_number=1,
upload_name="delete.jpg",
filename="delete.jpg",
file_path="uploads/delete.jpg",
)
async with transcriptions._session_scope() as session:
session.add(source)
await session.flush()
session.add(JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING))
await session.commit()
await session.refresh(source)
await transcriptions.delete_source_from_job_context(job_id=job.id, source_id=source.id)
with pytest.raises(TranscriptionNotFoundError):
await transcriptions.read_source(source.id)
@pytest.mark.asyncio
async def test_delete_source_from_job_context_blocks_when_other_job_links_exist(self, default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
transcriptions = TranscriptionService(session_factory=default_session_factory)
document = Document(id=uuid4(), name="delete-source-blocked")
await documents.create_document(document=document)
job_one = Job(document_id=document.id, status=JobStatus.QUEUED)
job_two = Job(document_id=document.id, status=JobStatus.QUEUED)
await jobs.create_job(job=job_one)
await jobs.create_job(job=job_two)
source = Source(
document_id=document.id,
page_number=1,
upload_name="shared.jpg",
filename="shared.jpg",
file_path="uploads/shared.jpg",
)
async with transcriptions._session_scope() as session:
session.add(source)
await session.flush()
session.add(JobSource(job_id=job_one.id, source_id=source.id, status=JobSourceStatus.PENDING))
session.add(JobSource(job_id=job_two.id, source_id=source.id, status=JobSourceStatus.PENDING))
await session.commit()
await session.refresh(source)
with pytest.raises(SourceDeleteBlockedError):
await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id)
+88
View File
@@ -10,8 +10,10 @@ 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.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
from transcription.services.transcription import SourceDeleteBlockedError
from transcription.services.transcription import TranscriptionService
@@ -123,3 +125,89 @@ async def test_transcription_service_job_source_crud_uses_caller_session(default
await session.commit()
assert len(await transcriptions.list_job_sources(job_id=job.id)) == 0
@pytest.mark.asyncio
async def test_document_detail_loads_linked_person_relationship(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
document = await documents.create_document(Document(id=uuid4(), name="detail-person-doc"))
person = await documents.create_person(Person(full_name="Grace Hopper"))
await documents.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
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 == DocumentPersonRole.AUTHOR
@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 = TranscriptionService(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",
)
)
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 = TranscriptionService(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",
)
)
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)