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)
+8 -3
View File
@@ -71,13 +71,18 @@ class TestProviderSettings:
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."""
def test_optional_provider_header_fields_default_to_none(self):
"""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
def test_provider_model_accepts_env_default(self, monkeypatch):
"""provider_model is sourced when provided through environment configuration."""
monkeypatch.setenv("PROVIDER_MODEL", "google/gemini-2.5-flash")
settings = Settings(openrouter_api_key="test-key-abc123")
assert settings.provider_model == "google/gemini-2.5-flash"
class TestPathSettings:
"""Verify filesystem path field types."""
+5
View File
@@ -22,10 +22,12 @@ from transcription.db import create_all
from transcription.db import initialize_database_runtime
from transcription.db import session_scope
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 JobStatus
from transcription.db.models import Person
from transcription.db.models import Source
RevisionSeed = str
@@ -58,9 +60,12 @@ def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
async def _clear() -> None:
async with session_scope() as session:
await session.exec(delete(JobSource))
await session.exec(delete(DocumentPerson))
await session.exec(delete(Source))
await session.exec(delete(Job))
await session.exec(delete(Document))
await session.exec(delete(Person))
await session.commit()
asyncio.run(_clear())
+331
View File
@@ -0,0 +1,331 @@
"""Tests for the documents page routes."""
import asyncio
from datetime import UTC
from datetime import date
from datetime import datetime
from uuid import uuid4
import pytest
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Job
from transcription.db.models import Person
from transcription.db.models import Source
@pytest.mark.integration
class TestDocumentsPageRendering:
"""Verify document list/detail routes render expected read states."""
def test_documents_page_renders_empty_state(self, app_client):
"""GET /ui/documents renders empty-state text when no records exist."""
_, client = app_client
response = client.get("/ui/documents")
assert response.status_code == 200
assert "Documents" in response.text
assert "No documents yet." in response.text
def test_documents_page_lists_seeded_documents(self, app_client):
"""GET /ui/documents lists seeded document cards."""
_, client = app_client
async def _seed_document() -> None:
async with session_scope() as session:
session.add(Document(name="Seeded Document", document_type="letter"))
await session.commit()
asyncio.run(_seed_document())
response = client.get("/ui/documents")
assert response.status_code == 200
assert "Seeded Document" in response.text
assert "Type: letter" in response.text
def test_document_detail_page_renders_metadata_and_empty_related_sections(self, app_client):
"""GET /ui/documents/{document_id} shows metadata and related empty states."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(
name="Zenna Letter",
document_type="letter",
document_date=date(1885, 7, 13),
document_date_raw="c. 1885",
location_created="Ohio",
notes="Family archive",
archive_identifier="BOX-1-FOLDER-2",
)
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/documents/{document_id}")
assert response.status_code == 200
assert "Zenna Letter" in response.text
assert "Document type: letter" in response.text
assert "Exact date: 1885-07-13" in response.text
assert "Approximate date: c. 1885" in response.text
assert "Location created: Ohio" in response.text
assert "Archive identifier: BOX-1-FOLDER-2" in response.text
assert "Notes: Family archive" in response.text
assert "Created at (read-only):" in response.text
assert "Updated at (read-only):" in response.text
assert "No linked people yet." in response.text
assert "No sources added yet." in response.text
assert "No jobs created yet." in response.text
assert "Add sources" in response.text
assert "Create job" in response.text
assert "View sources" in response.text
assert "View jobs" in response.text
assert "Edit document" in response.text
assert "Delete document" in response.text
def test_document_detail_page_renders_related_people_sources_and_jobs(self, app_client):
"""GET /ui/documents/{document_id} shows related records when present."""
_, client = app_client
async def _seed_related() -> str:
async with session_scope() as session:
document = Document(name="Roster", document_type="record")
person = Person(full_name="Jane Doe")
session.add(document)
session.add(person)
await session.flush()
session.add(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="001_page.png",
filename="stored_001_page.png",
file_path="/tmp/stored_001_page.png",
)
)
session.add(
Job(
document_id=document.id,
)
)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_related())
response = client.get(f"/ui/documents/{document_id}")
assert response.status_code == 200
assert "Jane Doe (author)" in response.text
assert "Page 1: 001_page.png" in response.text
assert "queued -" in response.text
def test_document_jobs_page_filters_to_document_context(self, app_client):
_, client = app_client
async def _seed() -> str:
async with session_scope() as session:
target = Document(name="Target", document_type="letter")
other = Document(name="Other", document_type="record")
session.add(target)
session.add(other)
await session.flush()
session.add(Job(document_id=target.id))
session.add(Job(document_id=other.id))
await session.commit()
await session.refresh(target)
return str(target.id)
document_id = asyncio.run(_seed())
response = client.get(f"/ui/documents/{document_id}/jobs")
assert response.status_code == 200
assert "Jobs for Target" in response.text
assert "Jobs for Other" not in response.text
def test_document_sources_page_filters_to_document_context(self, app_client):
_, client = app_client
async def _seed() -> str:
async with session_scope() as session:
target = Document(name="Target", document_type="letter")
other = Document(name="Other", document_type="record")
session.add(target)
session.add(other)
await session.flush()
session.add(
Source(
document_id=target.id,
page_number=1,
upload_name="target_page.png",
filename="target_stored.png",
file_path="/tmp/target_stored.png",
)
)
session.add(
Source(
document_id=other.id,
page_number=1,
upload_name="other_page.png",
filename="other_stored.png",
file_path="/tmp/other_stored.png",
)
)
await session.commit()
await session.refresh(target)
return str(target.id)
document_id = asyncio.run(_seed())
response = client.get(f"/ui/documents/{document_id}/sources")
assert response.status_code == 200
assert "Sources for Target" in response.text
assert "target_page.png" in response.text
assert "other_page.png" not in response.text
def test_document_detail_page_rejects_invalid_id(self, app_client):
"""GET /ui/documents/{document_id} shows validation feedback for malformed IDs."""
_, client = app_client
response = client.get("/ui/documents/not-a-uuid")
assert response.status_code == 200
assert "Invalid document id" in response.text
def test_document_detail_page_handles_missing_document(self, app_client):
"""GET /ui/documents/{document_id} shows not-found state for unknown IDs."""
_, client = app_client
response = client.get(f"/ui/documents/{uuid4()}")
assert response.status_code == 200
assert "Document not found" in response.text
def test_document_edit_page_renders_expected_fields(self, app_client):
"""GET /ui/documents/{document_id}/edit renders editable fields and save controls."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(
name="Editable Document",
document_type="memo",
document_date_raw="c. 1900",
)
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/documents/{document_id}/edit")
assert response.status_code == 200
assert "Edit document" in response.text
assert "Document name and document type are required." in response.text
assert "Document name" in response.text
assert "Document type" in response.text
assert "Exact date (YYYY-MM-DD)" in response.text
assert "Approximate date" in response.text
assert "Document location" in response.text
assert "Archive identifier" in response.text
assert "Notes" in response.text
assert "Save changes" in response.text
def test_document_delete_page_shows_confirmation_when_unlinked(self, app_client):
"""GET /ui/documents/{document_id}/delete renders permanent-action confirmation if unlinked."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(name="Safe Delete", document_type="letter")
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/documents/{document_id}/delete")
assert response.status_code == 200
assert "Delete document" in response.text
assert "This action permanently deletes the document." in response.text
assert "Delete document permanently" in response.text
def test_document_delete_page_shows_blocked_state_when_dependencies_exist(self, app_client):
"""GET /ui/documents/{document_id}/delete explains blocked deletion with dependency categories."""
_, client = app_client
async def _seed_related() -> str:
async with session_scope() as session:
document = Document(name="Blocked Delete", document_type="record")
session.add(document)
await session.flush()
session.add(
Source(
document_id=document.id,
page_number=1,
upload_name="001_page.png",
filename="stored_001_page.png",
file_path="/tmp/stored_001_page.png",
)
)
session.add(Job(document_id=document.id))
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_related())
response = client.get(f"/ui/documents/{document_id}/delete")
assert response.status_code == 200
assert "Delete is blocked because related records exist." in response.text
assert "Dependencies present: Sources, Jobs" in response.text
assert "Go to Jobs" in response.text
def test_job_create_page_preselects_document_query_param(self, app_client):
"""GET /ui/jobs/new?document_id=... includes the selected document in rendered state."""
_, client = app_client
async def _seed_document() -> str:
async with session_scope() as session:
document = Document(
name="Preselected Document",
document_type="letter",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(document)
await session.commit()
await session.refresh(document)
return str(document.id)
document_id = asyncio.run(_seed_document())
response = client.get(f"/ui/jobs/new?document_id={document_id}")
assert response.status_code == 200
assert "Preselected Document" in response.text
+63
View File
@@ -1,10 +1,13 @@
"""Tests for the jobs page route."""
import asyncio
from pathlib import Path
from uuid import uuid4
import pytest
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import JobStatus
@@ -18,8 +21,39 @@ class TestPageRendering:
response = client.get("/ui/jobs")
assert response.status_code == 200
assert "Create job" in response.text
assert "No jobs yet." in response.text
def test_job_create_page_requires_existing_documents(self, app_client):
"""GET /ui/jobs/new shows guidance when no Documents exist."""
_, client = app_client
response = client.get("/ui/jobs/new")
assert response.status_code == 200
assert "Create job" in response.text
assert "No documents available. Create a Document before creating a Job." in response.text
def test_job_create_page_lists_available_documents(self, app_client):
"""GET /ui/jobs/new renders document choices when Documents exist."""
_, client = app_client
async def _seed_document() -> None:
async with session_scope() as session:
session.add(Document(name="Seeded Document"))
await session.commit()
asyncio.run(_seed_document())
response = client.get("/ui/jobs/new")
assert response.status_code == 200
assert "Create job" in response.text
assert "Seeded Document" in response.text
assert "Files are processed alphabetically by original filename." in response.text
assert "No files uploaded yet." in response.text
assert "Upload folder" in response.text
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
"""GET /ui/jobs lists seeded jobs from the in-memory database."""
_, client = app_client
@@ -54,6 +88,14 @@ class TestPageRendering:
assert "original text" in response.text
assert "Document preview" in response.text
assert "/uploads/detail.pdf" in response.text
assert "Provider:" in response.text
assert "Model:" in response.text
assert "Prompt:" in response.text
assert "Retry count:" in response.text
assert "Last updated:" in response.text
assert "Delete job" in response.text
assert "Delete source" in response.text
assert "This permanently deletes the source from this job context." in response.text
def test_job_detail_page_rejects_invalid_id(self, app_client):
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
@@ -105,3 +147,24 @@ class TestPageRendering:
assert "Revision Editor" in response.text
assert "Update revision" in response.text
assert "hello" in response.text
def test_job_delete_page_shows_confirmation_when_not_processing(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(filename="delete-ready.pdf", status=JobStatus.TRANSCRIBED)
response = client.get(f"/ui/jobs/{job_id}/delete")
assert response.status_code == 200
assert "Delete job" in response.text
assert "This action permanently deletes the job." in response.text
assert "Delete job permanently" in response.text
def test_job_delete_page_shows_blocked_state_when_processing(self, app_client, seed_job):
_, client = app_client
job_id = seed_job(filename="delete-blocked.pdf", status=JobStatus.PROCESSING)
response = client.get(f"/ui/jobs/{job_id}/delete")
assert response.status_code == 200
assert "Delete is blocked while the job is processing." in response.text
assert "Wait for processing to complete, then retry delete." in response.text
+6 -2
View File
@@ -11,8 +11,12 @@ class TestPageRegistration:
"""Mounted UI routes respond successfully when the full app is created."""
_, client = app_client
upload_response = client.get("/ui/upload")
upload_response = client.get("/ui/upload", follow_redirects=False)
documents_response = client.get("/ui/documents")
people_response = client.get("/ui/people")
jobs_response = client.get("/ui/jobs")
assert upload_response.status_code == 200
assert upload_response.status_code == 307
assert documents_response.status_code == 200
assert people_response.status_code == 200
assert jobs_response.status_code == 200
+214
View File
@@ -0,0 +1,214 @@
"""Tests for the people page routes."""
import asyncio
from datetime import date
from uuid import uuid4
import pytest
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Person
@pytest.mark.integration
class TestPeoplePageRendering:
"""Verify people routes render expected CRUD read states."""
def test_people_page_renders_empty_state(self, app_client):
_, client = app_client
response = client.get("/ui/people")
assert response.status_code == 200
assert "People" in response.text
assert "Create new person" in response.text
assert "No people yet." in response.text
def test_people_page_lists_seeded_people(self, app_client):
_, client = app_client
async def _seed_person() -> None:
async with session_scope() as session:
session.add(Person(full_name="Ada Lovelace", display_name="Ada"))
await session.commit()
asyncio.run(_seed_person())
response = client.get("/ui/people")
assert response.status_code == 200
assert "Ada Lovelace" in response.text
assert "Display name: Ada" in response.text
def test_person_create_page_renders_fields(self, app_client):
_, client = app_client
response = client.get("/ui/people/new")
assert response.status_code == 200
assert "Create person" in response.text
assert "Full name is required." in response.text
assert "Birth date (YYYY-MM-DD)" in response.text
assert "Death date (YYYY-MM-DD)" in response.text
assert "Biography" in response.text
assert "Save person" in response.text
def test_person_detail_page_renders_metadata_and_empty_links(self, app_client):
_, client = app_client
async def _seed_person() -> str:
async with session_scope() as session:
person = Person(
full_name="Grace Hopper",
display_name="Grace",
maiden_name="Murray",
birth_date=date(1906, 12, 9),
birth_date_raw="1906",
birth_place="New York",
death_date=date(1992, 1, 1),
death_date_raw="1992",
death_place="Arlington",
biography="Computer pioneer",
portrait_path="/images/grace.jpg",
)
session.add(person)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
assert "Grace Hopper" in response.text
assert "Display name: Grace" in response.text
assert "Maiden name: Murray" in response.text
assert "Birth date: 1906-12-09" in response.text
assert "Death date: 1992-01-01" in response.text
assert "Biography: Computer pioneer" in response.text
assert "Portrait path: /images/grace.jpg" in response.text
assert "Created at (read-only):" in response.text
assert "Updated at (read-only):" in response.text
assert "No linked documents yet." in response.text
assert "Link this person from a Document workflow." in response.text
def test_person_detail_page_renders_linked_documents(self, app_client):
_, client = app_client
async def _seed_links() -> str:
async with session_scope() as session:
person = Person(full_name="Linked Person")
document = Document(name="Linked Document", document_type="letter")
session.add(person)
session.add(document)
await session.flush()
session.add(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_links())
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
assert "Linked Document (author)" in response.text
def test_person_detail_page_handles_invalid_id(self, app_client):
_, client = app_client
response = client.get("/ui/people/not-a-uuid")
assert response.status_code == 200
assert "Invalid person id" in response.text
def test_person_detail_page_handles_missing_person(self, app_client):
_, client = app_client
response = client.get(f"/ui/people/{uuid4()}")
assert response.status_code == 200
assert "Person not found" in response.text
def test_person_edit_page_renders_expected_fields(self, app_client):
_, client = app_client
async def _seed_person() -> str:
async with session_scope() as session:
person = Person(full_name="Editable Person", display_name="EP")
session.add(person)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}/edit")
assert response.status_code == 200
assert "Edit person" in response.text
assert "Full name is required." in response.text
assert "Full name" in response.text
assert "Save changes" in response.text
def test_person_delete_page_shows_confirmation_when_unlinked(self, app_client):
_, client = app_client
async def _seed_person() -> str:
async with session_scope() as session:
person = Person(full_name="Safe Delete")
session.add(person)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_person())
response = client.get(f"/ui/people/{person_id}/delete")
assert response.status_code == 200
assert "Delete person" in response.text
assert "This action permanently deletes the person." in response.text
assert "Delete person permanently" in response.text
def test_person_delete_page_shows_blocked_state_when_linked_documents_exist(self, app_client):
_, client = app_client
async def _seed_links() -> str:
async with session_scope() as session:
person = Person(full_name="Blocked Delete")
document = Document(name="Linked Document", document_type="record")
session.add(person)
session.add(document)
await session.flush()
session.add(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
await session.commit()
await session.refresh(person)
return str(person.id)
person_id = asyncio.run(_seed_links())
response = client.get(f"/ui/people/{person_id}/delete")
assert response.status_code == 200
assert "Delete is blocked because linked documents exist." in response.text
assert "Linked documents: 1" in response.text
assert "Go to Documents" in response.text
+6 -10
View File
@@ -16,21 +16,17 @@ class TestPageRendering:
assert response.headers["location"] == "/ui"
def test_ui_redirects_to_upload(self, app_client):
"""GET /ui redirects to the upload page."""
"""GET /ui redirects to the jobs page."""
_, client = app_client
response = client.get("/ui", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "/ui/upload"
assert response.headers["location"] == "/ui/jobs"
def test_upload_page_renders_expected_controls(self, app_client):
"""GET /ui/upload returns the page shell and upload controls."""
"""GET /ui/upload redirects to the job-create flow."""
_, client = app_client
response = client.get("/ui/upload")
response = client.get("/ui/upload", follow_redirects=False)
assert response.status_code == 200
assert "VibeScribe" in response.text
assert "Upload Document" in response.text
assert "Select document file" in response.text
assert "Upload" in response.text
assert "Jobs" in response.text
assert response.status_code == 307
assert response.headers["location"] == "/ui/jobs/new"