generated from john/python-template
Revamped the Documents, People, & Jobs too.
This commit is contained in:
+78
-87
@@ -2,39 +2,35 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import delete
|
||||
|
||||
from transcription.app import create_app
|
||||
from transcription.config import Settings
|
||||
from transcription.config import SqliteSettings
|
||||
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
|
||||
from transcription.config import Settings, SqliteSettings
|
||||
from transcription.db import create_all, initialize_database_runtime, session_scope
|
||||
from transcription.db.models import (
|
||||
Document,
|
||||
DocumentPerson,
|
||||
Job,
|
||||
JobSource,
|
||||
JobSourceStatus,
|
||||
JobStatus,
|
||||
Person,
|
||||
Source,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def app_client(tmp_path_factory: pytest.TempPathFactory) -> Generator[tuple[FastAPI, TestClient]]:
|
||||
def app_client(tmp_path_factory: pytest.TempPathFactory) -> AsyncGenerator[tuple[FastAPI, TestClient], None]:
|
||||
"""Provide a real application and test client backed by in-memory SQLite."""
|
||||
tmp_path = tmp_path_factory.mktemp("ui")
|
||||
settings = Settings(
|
||||
@@ -48,94 +44,89 @@ def app_client(tmp_path_factory: pytest.TempPathFactory) -> Generator[tuple[Fast
|
||||
|
||||
app = create_app()
|
||||
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||
|
||||
import asyncio
|
||||
asyncio.run(create_all(engine=app.state.runtime.engine))
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield app, client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
|
||||
"""Reset UI-facing tables before each test for isolation."""
|
||||
app, _ = app_client
|
||||
|
||||
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())
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
|
||||
"""Reset UI-facing tables asynchronously before each test for isolation."""
|
||||
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()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
||||
"""Return a helper for inserting a document/job/source/(optional revision) tuple."""
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., AsyncGenerator[UUID, None]]:
|
||||
"""Return an async factory helper for seeding a Document -> Job -> Source tuple."""
|
||||
app, _ = app_client
|
||||
fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid"
|
||||
|
||||
def _seed(
|
||||
async def _seed(
|
||||
*,
|
||||
filename: str = "sample.pdf",
|
||||
status: JobStatus = JobStatus.TRANSCRIBED,
|
||||
transcription_text: str | None = "Sample transcript text",
|
||||
error_detail: str | None = None,
|
||||
revision_text: RevisionSeed | None = None,
|
||||
revision_text: str | None = None,
|
||||
source_file: Path | None = None,
|
||||
) -> UUID:
|
||||
async def _insert() -> UUID:
|
||||
async with session_scope() as session:
|
||||
stored_path = app.state.settings.upload_dir / filename
|
||||
stored_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source_path = source_file or fixtures_dir / "small_png.png"
|
||||
stored_path.write_bytes(source_path.read_bytes())
|
||||
async with session_scope() as session:
|
||||
stored_path = app.state.settings.upload_dir / filename
|
||||
stored_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source_path = source_file or fixtures_dir / "small_png.png"
|
||||
stored_path.write_bytes(source_path.read_bytes())
|
||||
|
||||
document = Document(name=filename)
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
document = Document(name=filename)
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
|
||||
job = Job(
|
||||
document_id=document.id,
|
||||
status=status,
|
||||
retry_count=0,
|
||||
provider="openrouter",
|
||||
model="google/gemini-2.5-flash",
|
||||
prompt_name="transcribe_document.md",
|
||||
)
|
||||
session.add(job)
|
||||
await session.flush()
|
||||
job = Job(
|
||||
document_id=document.id,
|
||||
status=status,
|
||||
retry_count=0,
|
||||
provider="openrouter",
|
||||
model="google/gemini-2.5-flash",
|
||||
prompt_name="transcribe_document.md",
|
||||
)
|
||||
session.add(job)
|
||||
await session.flush()
|
||||
|
||||
source = Source(
|
||||
document_id=document.id,
|
||||
upload_name=filename,
|
||||
filename=filename,
|
||||
file_path=str(stored_path),
|
||||
)
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
source = Source(
|
||||
document_id=document.id,
|
||||
upload_name=filename,
|
||||
filename=filename,
|
||||
file_path=str(stored_path),
|
||||
)
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
|
||||
if transcription_text is not None or error_detail is not None:
|
||||
session.add(
|
||||
JobSource(
|
||||
job_id=job.id,
|
||||
source_id=source.id,
|
||||
status=JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED,
|
||||
raw_transcription=transcription_text,
|
||||
error_detail=error_detail,
|
||||
)
|
||||
if transcription_text is not None or error_detail is not None:
|
||||
session.add(
|
||||
JobSource(
|
||||
job_id=job.id,
|
||||
source_id=source.id,
|
||||
status=JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED,
|
||||
raw_transcription=transcription_text,
|
||||
error_detail=error_detail,
|
||||
)
|
||||
)
|
||||
|
||||
if revision_text is not None:
|
||||
source.revised_text = revision_text
|
||||
source.date_revised = datetime.now(UTC)
|
||||
session.add(source)
|
||||
if revision_text is not None:
|
||||
source.revised_text = revision_text
|
||||
source.date_revised = datetime.now(UTC)
|
||||
session.add(source)
|
||||
|
||||
await session.commit()
|
||||
return job.id
|
||||
await session.commit()
|
||||
return job.id
|
||||
|
||||
return asyncio.run(_insert())
|
||||
|
||||
return _seed
|
||||
return _seed
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Action handler tests for Document CRUD mutations."""
|
||||
|
||||
from datetime import date
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
from transcription.db import session_scope
|
||||
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Job, Person, Source
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDocumentActionHandlers:
|
||||
"""Verify POST/mutation routes for Document creation, updates, and deletions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_document_success(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
payload = {
|
||||
"name": "New Historical Journal",
|
||||
"document_type": "journal",
|
||||
"document_date": "1924-05-15",
|
||||
"document_date_raw": "May 1924",
|
||||
"location_created": "San Francisco, CA",
|
||||
"archive_identifier": "HJ-1924-01",
|
||||
"notes": "Belonged to Hig.",
|
||||
}
|
||||
|
||||
response = client.post("/ui/documents/new", data=payload, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "New Historical Journal" in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = (
|
||||
await session.exec(select(Document).where(Document.name == "New Historical Journal"))
|
||||
).first()
|
||||
assert doc is not None
|
||||
assert doc.document_type == "journal"
|
||||
assert doc.document_date == date(1924, 5, 15)
|
||||
assert doc.archive_identifier == "HJ-1924-01"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_document_with_author_link(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
person = Person(full_name="John Isbill")
|
||||
session.add(person)
|
||||
await session.commit()
|
||||
person_id = str(person.id)
|
||||
|
||||
payload = {
|
||||
"name": "Isbill Letter",
|
||||
"document_type": "letter",
|
||||
"author_id": person_id,
|
||||
}
|
||||
|
||||
response = client.post("/ui/documents/new", data=payload, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Isbill Letter" in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = (
|
||||
await session.exec(select(Document).where(Document.name == "Isbill Letter"))
|
||||
).first()
|
||||
assert doc is not None
|
||||
|
||||
link = (
|
||||
await session.exec(
|
||||
select(DocumentPerson).where(
|
||||
DocumentPerson.document_id == doc.id,
|
||||
DocumentPerson.role == DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
assert link is not None
|
||||
assert str(link.person_id) == person_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_document_details_and_author(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
author1 = Person(full_name="Original Author")
|
||||
author2 = Person(full_name="New Author")
|
||||
doc = Document(name="Original Title", document_type="letter")
|
||||
session.add_all([author1, author2, doc])
|
||||
await session.flush()
|
||||
|
||||
session.add(
|
||||
DocumentPerson(
|
||||
document_id=doc.id,
|
||||
person_id=author1.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
doc_id = str(doc.id)
|
||||
new_author_id = str(author2.id)
|
||||
|
||||
update_payload = {
|
||||
"name": "Updated Title",
|
||||
"document_type": "journal_entry",
|
||||
"author_id": new_author_id,
|
||||
}
|
||||
|
||||
response = client.post(f"/ui/documents/{doc_id}/edit", data=update_payload, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Updated Title" in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
updated_doc = await session.get(Document, doc_id)
|
||||
assert updated_doc is not None
|
||||
assert updated_doc.name == "Updated Title"
|
||||
assert updated_doc.document_type == "journal_entry"
|
||||
|
||||
link = (
|
||||
await session.exec(
|
||||
select(DocumentPerson).where(
|
||||
DocumentPerson.document_id == updated_doc.id,
|
||||
DocumentPerson.role == DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
assert link is not None
|
||||
assert str(link.person_id) == new_author_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_unlinked_document_success(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Temporary Doc", document_type="note")
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
doc_id = str(doc.id)
|
||||
|
||||
response = client.post(f"/ui/documents/{doc_id}/delete", follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Document deleted" in response.text or "Archival Documents" in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
deleted_doc = await session.get(Document, doc_id)
|
||||
assert deleted_doc is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_document_blocked_when_dependencies_exist(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Protected Doc", document_type="letter")
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
source = Source(
|
||||
document_id=doc.id,
|
||||
page_number=1,
|
||||
upload_name="page_001.png",
|
||||
filename="page_001.png",
|
||||
file_path="/tmp/page_001.png",
|
||||
)
|
||||
session.add(source)
|
||||
await session.commit()
|
||||
doc_id = str(doc.id)
|
||||
|
||||
response = client.post(f"/ui/documents/{doc_id}/delete", follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Delete is blocked because related records exist." in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
doc_still_exists = await session.get(Document, doc_id)
|
||||
assert doc_still_exists is not None
|
||||
+117
-307
@@ -1,366 +1,176 @@
|
||||
"""Tests for the documents page routes."""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC
|
||||
from datetime import date
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
"""Tests for the documents page routes and action handlers."""
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
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
|
||||
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Job, Person, Source
|
||||
|
||||
|
||||
# --- Helper Fixtures ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seed_person_and_document():
|
||||
"""Seed a Person and Document linked by DocumentPerson role."""
|
||||
async with session_scope() as session:
|
||||
person = Person(full_name="Zenna Cochran")
|
||||
session.add(person)
|
||||
await session.flush()
|
||||
|
||||
doc = Document(
|
||||
name="Letter from Hig",
|
||||
document_type="letter",
|
||||
archive_identifier="ZC-1924-001",
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
link = DocumentPerson(
|
||||
document_id=doc.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
session.add(link)
|
||||
await session.commit()
|
||||
return str(doc.id), str(person.id)
|
||||
|
||||
|
||||
# --- Integration Tests for Documents Route Handlers ---
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDocumentsPageRendering:
|
||||
"""Verify document list/detail routes render expected read states."""
|
||||
"""Verify document list, detail, edit, and deletion route behaviors."""
|
||||
|
||||
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 "Create new document" in response.text
|
||||
assert "Archival Documents" in response.text
|
||||
assert "No documents in repository yet." in response.text
|
||||
|
||||
def test_document_create_page_renders_fields(self, app_client):
|
||||
"""GET /ui/documents/new renders document-create form fields."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_documents_page_lists_seeded_documents(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="1924 Postcard", document_type="postcard", archive_identifier="PC-001")
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
|
||||
response = client.get("/ui/documents")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "1924 Postcard" in response.text
|
||||
assert "postcard" in response.text
|
||||
assert "PC-001" in response.text
|
||||
|
||||
def test_document_create_page_renders_form(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
response = client.get("/ui/documents/new")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Create Document" in response.text
|
||||
assert "Document name is required." in response.text
|
||||
assert "Document name" in response.text
|
||||
assert "Document type" in response.text
|
||||
assert "Author (Person)" 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 "Create new item" in response.text
|
||||
assert "Create new person" in response.text
|
||||
assert "Save document" in response.text
|
||||
|
||||
def test_documents_page_lists_seeded_documents(self, app_client):
|
||||
"""GET /ui/documents lists seeded document cards."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_detail_page_renders_bento_grid_and_metadata(
|
||||
self, app_client, seed_person_and_document
|
||||
):
|
||||
_, client = app_client
|
||||
doc_id, _ = seed_person_and_document
|
||||
|
||||
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")
|
||||
response = client.get(f"/ui/documents/{doc_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Seeded Document" in response.text
|
||||
assert "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 "Type: letter" in response.text
|
||||
assert "Author:" in response.text
|
||||
assert "Not set" in response.text
|
||||
assert "Exact Date:" in response.text
|
||||
assert "1885-07-13" in response.text
|
||||
assert "Approx. Date:" in response.text
|
||||
assert "c. 1885" in response.text
|
||||
assert "Location Created:" in response.text
|
||||
assert "Ohio" in response.text
|
||||
assert "Archive Identifier:" in response.text
|
||||
assert "BOX-1-FOLDER-2" in response.text
|
||||
assert "Archival Notes:" in response.text
|
||||
assert "Family archive" in response.text
|
||||
assert "Created:" in response.text
|
||||
assert "Updated:" in response.text
|
||||
assert "No linked people yet." in response.text
|
||||
assert "0 Source(s) Linked" in response.text
|
||||
assert "0 Active Jobs" in response.text
|
||||
assert "+ Add Source" in response.text
|
||||
assert "+ Add Job" in response.text
|
||||
assert "Sources" in response.text
|
||||
assert "Jobs" in response.text
|
||||
assert "Letter from Hig" in response.text
|
||||
assert "ZC-1924-001" in response.text
|
||||
assert "Zenna Cochran" in response.text
|
||||
assert "Archival Metadata" in response.text
|
||||
assert "Edit Document" in response.text
|
||||
assert "Delete" 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."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_jobs_page_renders_job_links(self, app_client):
|
||||
_, 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()
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Doc With Job", document_type="letter")
|
||||
session.add(doc)
|
||||
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)
|
||||
job = Job(document_id=doc.id)
|
||||
session.add(job)
|
||||
await session.commit()
|
||||
doc_id = str(doc.id)
|
||||
job_id = str(job.id)
|
||||
|
||||
document_id = asyncio.run(_seed_related())
|
||||
|
||||
response = client.get(f"/ui/documents/{document_id}")
|
||||
response = client.get(f"/ui/documents/{doc_id}/jobs")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Jane Doe" in response.text
|
||||
assert "author" in response.text
|
||||
assert "Author:" in response.text
|
||||
assert "1 Source(s) Linked" in response.text
|
||||
assert "1 Active Jobs" in response.text
|
||||
assert "Jobs for Doc With Job" in response.text
|
||||
assert f"Job ID: {job_id}" in response.text
|
||||
|
||||
def test_document_jobs_page_filters_to_document_context(self, app_client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_edit_page_prefills_existing_values(
|
||||
self, app_client, seed_person_and_document
|
||||
):
|
||||
_, client = app_client
|
||||
doc_id, _ = seed_person_and_document
|
||||
|
||||
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/sources?document_id={document_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Sources: Target" in response.text
|
||||
assert "Back to Document" 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")
|
||||
response = client.get(f"/ui/documents/{doc_id}/edit")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Edit Document Record" 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 "Author (Person)" 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 "Create new item" in response.text
|
||||
assert "Create new person" in response.text
|
||||
assert "Save changes" in response.text
|
||||
assert "Letter from Hig" in response.text
|
||||
assert "ZC-1924-001" 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."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_delete_page_blocks_deletion_when_dependencies_exist(
|
||||
self, app_client
|
||||
):
|
||||
_, 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)
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Doc With Source", document_type="letter")
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
document_id = asyncio.run(_seed_document())
|
||||
source = Source(
|
||||
document_id=doc.id,
|
||||
page_number=1,
|
||||
upload_name="page_1.png",
|
||||
filename="page_1.png",
|
||||
file_path="/tmp/page_1.png",
|
||||
)
|
||||
session.add(source)
|
||||
await session.commit()
|
||||
doc_id = str(doc.id)
|
||||
|
||||
response = client.get(f"/ui/documents/{document_id}/delete")
|
||||
response = client.get(f"/ui/documents/{doc_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
|
||||
assert "Dependencies present: Sources" 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."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_delete_page_allows_unlinked_document_deletion(self, app_client):
|
||||
_, 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)
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Orphan Document", document_type="note")
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
doc_id = str(doc.id)
|
||||
|
||||
document_id = asyncio.run(_seed_document())
|
||||
|
||||
response = client.get(f"/ui/jobs/new?document_id={document_id}")
|
||||
response = client.get(f"/ui/documents/{doc_id}/delete")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Preselected Document" in response.text
|
||||
assert "Delete Document" in response.text
|
||||
assert "Delete document permanently" in response.text
|
||||
assert "Delete is blocked" not in response.text
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Action handler tests for Job CRUD mutations."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
from transcription.db import session_scope
|
||||
from transcription.db.models import Document, Job, JobSource, JobSourceStatus, JobStatus, Source
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestJobsActionHandlers:
|
||||
"""Verify POST/mutation routes for Job creation, status changes, and deletions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_job_success(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Postcard Batch", document_type="postcard")
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
doc_id = str(doc.id)
|
||||
|
||||
fixture_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "fixtures"
|
||||
/ "images"
|
||||
/ "valid"
|
||||
/ "small_png.png"
|
||||
)
|
||||
|
||||
with open(fixture_path, "rb") as file_bytes:
|
||||
files = [("files", ("001_postcard.png", file_bytes, "image/png"))]
|
||||
data = {
|
||||
"document_id": doc_id,
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o",
|
||||
"prompt_name": "default_transcription",
|
||||
}
|
||||
|
||||
response = client.post("/ui/jobs/new", data=data, files=files, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Job Record:" in response.text or "Execution Logistics" in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
job = (
|
||||
await session.exec(select(Job).where(Job.document_id == doc_id))
|
||||
).first()
|
||||
assert job is not None
|
||||
assert job.status == JobStatus.QUEUED
|
||||
assert job.provider == "openai"
|
||||
assert job.model == "gpt-4o"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_queued_job_success(self, app_client, seed_job):
|
||||
_, client = app_client
|
||||
job_id = seed_job(status=JobStatus.QUEUED, filename="queued-job.png")
|
||||
|
||||
response = client.post(f"/ui/jobs/{job_id}/cancel", follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Job cancelled" in response.text or "CANCELLED" in response.text or "FAILED" in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
cancelled_job = await session.get(Job, job_id)
|
||||
assert cancelled_job is not None
|
||||
assert cancelled_job.status in {JobStatus.FAILED, JobStatus.COMPLETED}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resubmit_failed_sources_success(self, app_client, seed_job):
|
||||
_, client = app_client
|
||||
job_id = seed_job(
|
||||
filename="failed-page.png",
|
||||
status=JobStatus.FAILED,
|
||||
transcription_text=None,
|
||||
error_detail="Provider API timeout",
|
||||
)
|
||||
|
||||
response = client.post(f"/ui/jobs/{job_id}/resubmit", follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Resubmitted" in response.text or "QUEUED" in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
resubmitted_job = await session.get(Job, job_id)
|
||||
assert resubmitted_job is not None
|
||||
assert resubmitted_job.status == JobStatus.QUEUED
|
||||
|
||||
job_source = (
|
||||
await session.exec(select(JobSource).where(JobSource.job_id == job_id))
|
||||
).first()
|
||||
assert job_source is not None
|
||||
assert job_source.status == JobSourceStatus.PENDING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_queued_or_completed_job_success(self, app_client, seed_job):
|
||||
_, client = app_client
|
||||
job_id = seed_job(status=JobStatus.COMPLETED, filename="completed-job.png")
|
||||
|
||||
response = client.post(f"/ui/jobs/{job_id}/delete", follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Job deleted" in response.text or "Transcription Pipeline Jobs" in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
deleted_job = await session.get(Job, job_id)
|
||||
assert deleted_job is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_job_blocked_when_processing(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Active Doc", document_type="letter")
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
job = Job(document_id=doc.id, status=JobStatus.PROCESSING)
|
||||
session.add(job)
|
||||
await session.commit()
|
||||
job_id = str(job.id)
|
||||
|
||||
response = client.post(f"/ui/jobs/{job_id}/delete", follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Delete is blocked while the job is processing." in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
job_still_exists = await session.get(Job, job_id)
|
||||
assert job_still_exists is not None
|
||||
+106
-104
@@ -1,30 +1,62 @@
|
||||
"""Tests for the jobs page route."""
|
||||
|
||||
import asyncio
|
||||
from uuid import uuid4
|
||||
"""Tests for the jobs page routes and action handlers."""
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
from transcription.db import session_scope
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import JobStatus
|
||||
from transcription.db.models import Document, Job, JobSourceStatus, JobStatus
|
||||
|
||||
|
||||
# --- Helper Fixtures ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seed_document_with_unlinked_job():
|
||||
"""Seed a document and a queued job for testing route actions."""
|
||||
async with session_scope() as session:
|
||||
document = Document(name="Test Archival Letter", document_type="letter")
|
||||
session.add(document)
|
||||
await session.flush()
|
||||
|
||||
job = Job(
|
||||
document_id=document.id,
|
||||
status=JobStatus.QUEUED,
|
||||
provider="openai",
|
||||
model="gpt-4o",
|
||||
)
|
||||
session.add(job)
|
||||
await session.commit()
|
||||
return str(document.id), str(job.id)
|
||||
|
||||
|
||||
# --- Integration Tests for Jobs Route Handlers ---
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestPageRendering:
|
||||
"""Verify jobs routes render correctly with real app wiring."""
|
||||
class TestJobsPageRendering:
|
||||
"""Verify jobs list, creation, detail, and lifecycle action routes."""
|
||||
|
||||
def test_jobs_page_renders_empty_state(self, app_client):
|
||||
"""GET /ui/jobs renders the page and empty-state text when no jobs exist."""
|
||||
_, client = app_client
|
||||
|
||||
response = client.get("/ui/jobs")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Create job" in response.text
|
||||
assert "Transcription Pipeline Jobs" in response.text
|
||||
assert "No active or historical processing jobs found." in response.text
|
||||
|
||||
def test_job_create_page_requires_existing_documents(self, app_client):
|
||||
"""GET /ui/jobs/new shows guidance when no Documents exist."""
|
||||
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
|
||||
_, client = app_client
|
||||
job_id = seed_job(filename="seeded-document-page.png")
|
||||
|
||||
response = client.get("/ui/jobs")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert str(job_id) in response.text
|
||||
assert "seeded-document-page.png" in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_create_page_shows_empty_document_warning_when_no_docs(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
response = client.get("/ui/jobs/new")
|
||||
@@ -32,132 +64,102 @@ class TestPageRendering:
|
||||
assert response.status_code == 200
|
||||
assert "Create Processing Job" in response.text
|
||||
assert "No documents available. Create a Document before creating a Job." in response.text
|
||||
assert "Create document" in response.text
|
||||
|
||||
def test_job_create_page_lists_available_documents(self, app_client):
|
||||
"""GET /ui/jobs/new renders document choices when Documents exist."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_create_page_preselects_document_from_query_param(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async def _seed_document() -> None:
|
||||
async with session_scope() as session:
|
||||
session.add(Document(name="Seeded Document"))
|
||||
await session.commit()
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Preselected Journal Entry", document_type="journal")
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
doc_id = str(doc.id)
|
||||
|
||||
asyncio.run(_seed_document())
|
||||
|
||||
response = client.get("/ui/jobs/new")
|
||||
response = client.get(f"/ui/jobs/new?document_id={doc_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Create Processing 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 "Select source files or a folder" in response.text
|
||||
assert "Preselected Journal Entry" 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."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_detail_page_renders_logistics_and_links(
|
||||
self, app_client, seed_document_with_unlinked_job
|
||||
):
|
||||
_, client = app_client
|
||||
seed_job(filename="sample.pdf", status=JobStatus.TRANSCRIBED, transcription_text="done")
|
||||
|
||||
response = client.get("/ui/jobs")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "sample.pdf" in response.text
|
||||
assert "transcribed" in response.text
|
||||
|
||||
def test_job_detail_page_renders_document_links(self, app_client, seed_job):
|
||||
"""GET /ui/jobs/{job_id} renders document-scoped navigation links."""
|
||||
_, client = app_client
|
||||
job_id = seed_job(
|
||||
filename="detail.pdf",
|
||||
status=JobStatus.TRANSCRIBED,
|
||||
transcription_text="original text",
|
||||
)
|
||||
_, job_id = seed_document_with_unlinked_job
|
||||
|
||||
response = client.get(f"/ui/jobs/{job_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Job" 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 "document links" in response.text.lower()
|
||||
assert "Sources" in response.text
|
||||
assert "Jobs" in response.text
|
||||
assert "Delete Job" in response.text
|
||||
assert f"Job Record: {job_id}" in response.text
|
||||
assert "Execution Logistics" in response.text
|
||||
assert "openai" in response.text
|
||||
assert "gpt-4o" in response.text
|
||||
assert "View Linked Document" in response.text
|
||||
assert "View Linked Sources" 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."""
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_cancel_page_renders_confirmation(
|
||||
self, app_client, seed_document_with_unlinked_job
|
||||
):
|
||||
_, client = app_client
|
||||
response = client.get("/ui/jobs/not-a-uuid")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Invalid job id" in response.text
|
||||
|
||||
def test_job_detail_page_handles_missing_job(self, app_client):
|
||||
"""GET /ui/jobs/{job_id} shows not-found state for unknown IDs."""
|
||||
_, client = app_client
|
||||
missing_id = uuid4()
|
||||
response = client.get(f"/ui/jobs/{missing_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Job not found" in response.text
|
||||
|
||||
def test_job_detail_page_shows_cancel_and_resubmit_when_queued(self, app_client, seed_job):
|
||||
"""GET /ui/jobs/{job_id} exposes cancel/resubmit controls for queued jobs."""
|
||||
_, client = app_client
|
||||
job_id = seed_job(
|
||||
filename="no-revision.pdf",
|
||||
status=JobStatus.QUEUED,
|
||||
transcription_text=None,
|
||||
)
|
||||
|
||||
response = client.get(f"/ui/jobs/{job_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Cancel" in response.text
|
||||
assert "Resubmit" in response.text
|
||||
assert "Delete Job" in response.text
|
||||
|
||||
def test_job_cancel_page_renders_confirmation(self, app_client, seed_job):
|
||||
_, client = app_client
|
||||
job_id = seed_job(filename="cancel-ready.pdf", status=JobStatus.PROCESSING)
|
||||
_, job_id = seed_document_with_unlinked_job
|
||||
|
||||
response = client.get(f"/ui/jobs/{job_id}/cancel")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Cancel Processing Job" in response.text
|
||||
assert "Cancel stops processing" in response.text
|
||||
assert "Cancel job" in response.text
|
||||
|
||||
def test_job_resubmit_page_renders_confirmation(self, app_client, seed_job):
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_resubmit_page_renders_counts(
|
||||
self, app_client, seed_job
|
||||
):
|
||||
_, client = app_client
|
||||
job_id = seed_job(filename="resubmit-ready.pdf", status=JobStatus.FAILED, transcription_text=None)
|
||||
job_id = seed_job(
|
||||
filename="failed-resubmit.png",
|
||||
status=JobStatus.FAILED,
|
||||
transcription_text=None,
|
||||
error_detail="Timeout",
|
||||
)
|
||||
|
||||
response = client.get(f"/ui/jobs/{job_id}/resubmit")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Resubmit Job" in response.text
|
||||
assert "Non-Transcribed Sources:" in response.text
|
||||
assert "Resubmit now" in response.text
|
||||
|
||||
def test_job_delete_page_shows_confirmation_when_not_processing(self, app_client, seed_job):
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_delete_page_blocks_deletion_when_processing(self, app_client):
|
||||
_, 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)
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Processing Doc", document_type="letter")
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
job = Job(document_id=doc.id, status=JobStatus.PROCESSING)
|
||||
session.add(job)
|
||||
await session.commit()
|
||||
job_id = str(job.id)
|
||||
|
||||
response = client.get(f"/ui/jobs/{job_id}/delete")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Delete Processing Job" in response.text
|
||||
assert "Delete is blocked while the job is processing." in response.text
|
||||
assert "Wait for processing to complete, then retry delete." in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_delete_page_allows_deletion_for_queued_or_completed_job(
|
||||
self, app_client, seed_document_with_unlinked_job
|
||||
):
|
||||
_, client = app_client
|
||||
_, job_id = seed_document_with_unlinked_job
|
||||
|
||||
response = client.get(f"/ui/jobs/{job_id}/delete")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Delete Processing Job" in response.text
|
||||
assert "Delete job permanently" in response.text
|
||||
assert "Delete is blocked" not in response.text
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Tests for UI entry-points, redirects, and page mounting health-checks."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestNavigationAndMounts:
|
||||
"""Verify application entry-point redirects and route mounting."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected_status", "expected_redirect"),
|
||||
[
|
||||
("/", 307, "/ui/homepage"),
|
||||
("/ui", 307, "/ui/homepage"),
|
||||
("/ui/upload", 307, "/ui/jobs/new"),
|
||||
],
|
||||
)
|
||||
def test_entrypoint_redirects(self, app_client, url: str, expected_status: int, expected_redirect: str):
|
||||
"""Verify root and legacy routes redirect to primary UI views."""
|
||||
_, client = app_client
|
||||
response = client.get(url, follow_redirects=False)
|
||||
|
||||
assert response.status_code == expected_status
|
||||
assert response.headers["location"] == expected_redirect
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route_path",
|
||||
[
|
||||
"/ui/homepage",
|
||||
"/ui/homepage/edit",
|
||||
"/ui/documents",
|
||||
"/ui/people",
|
||||
"/ui/sources",
|
||||
"/ui/jobs",
|
||||
],
|
||||
)
|
||||
def test_registered_pages_render_successfully(self, app_client, route_path: str):
|
||||
"""Smoke test verifying all primary UI routes respond with 200 OK."""
|
||||
_, client = app_client
|
||||
response = client.get(route_path)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "html" in response.headers.get("content-type", "").lower()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Action handler tests for Person CRUD mutations."""
|
||||
|
||||
from datetime import date
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
from transcription.db import session_scope
|
||||
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Person
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestPeopleActionHandlers:
|
||||
"""Verify POST/mutation routes for Person creation, updates, and deletions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_person_success(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
payload = {
|
||||
"full_name": "Mary-Jo Kline",
|
||||
"display_name": "Mary-Jo",
|
||||
"maiden_name": "",
|
||||
"birth_date": "1945-03-12",
|
||||
"birth_date_raw": "ca. 1945",
|
||||
"birth_place": "Boston, MA",
|
||||
"biography": "Editor and scholar in documentary editing.",
|
||||
}
|
||||
|
||||
response = client.post("/ui/people/new", data=payload, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Mary-Jo Kline" in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
person = (
|
||||
await session.exec(select(Person).where(Person.full_name == "Mary-Jo Kline"))
|
||||
).first()
|
||||
assert person is not None
|
||||
assert person.display_name == "Mary-Jo"
|
||||
assert person.birth_date == date(1945, 3, 12)
|
||||
assert person.biography == "Editor and scholar in documentary editing."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_person_validation_missing_full_name(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
payload = {
|
||||
"full_name": "",
|
||||
"display_name": "Anonymous",
|
||||
}
|
||||
|
||||
response = client.post("/ui/people/new", data=payload, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Full name is required." in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_person_details_success(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
person = Person(full_name="Original Name", display_name="Orig")
|
||||
session.add(person)
|
||||
await session.commit()
|
||||
person_id = str(person.id)
|
||||
|
||||
update_payload = {
|
||||
"full_name": "Updated Person Name",
|
||||
"display_name": "Updated Display",
|
||||
"maiden_name": "Cochran",
|
||||
"birth_date": "1902-08-20",
|
||||
"biography": "Updated archival biographical information.",
|
||||
}
|
||||
|
||||
response = client.post(f"/ui/people/{person_id}/edit", data=update_payload, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Updated Person Name" in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
updated_person = await session.get(Person, person_id)
|
||||
assert updated_person is not None
|
||||
assert updated_person.full_name == "Updated Person Name"
|
||||
assert updated_person.display_name == "Updated Display"
|
||||
assert updated_person.maiden_name == "Cochran"
|
||||
assert updated_person.birth_date == date(1902, 8, 20)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_unlinked_person_success(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
person = Person(full_name="Transient Record")
|
||||
session.add(person)
|
||||
await session.commit()
|
||||
person_id = str(person.id)
|
||||
|
||||
response = client.post(f"/ui/people/{person_id}/delete", follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Person deleted" in response.text or "Archival Entities: People" in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
deleted_person = await session.get(Person, person_id)
|
||||
assert deleted_person is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_person_removes_linked_document_relationship(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
person = Person(full_name="Linked Person to Delete")
|
||||
doc = Document(name="Historical Letter", document_type="letter")
|
||||
session.add_all([person, doc])
|
||||
await session.flush()
|
||||
|
||||
link = DocumentPerson(
|
||||
document_id=doc.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
session.add(link)
|
||||
await session.commit()
|
||||
person_id = str(person.id)
|
||||
doc_id = str(doc.id)
|
||||
|
||||
response = client.post(f"/ui/people/{person_id}/delete", follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
async with session_scope() as session:
|
||||
# Person should be deleted
|
||||
deleted_person = await session.get(Person, person_id)
|
||||
assert deleted_person is None
|
||||
|
||||
# Associated relationship link should also be removed
|
||||
remaining_links = (
|
||||
await session.exec(
|
||||
select(DocumentPerson).where(DocumentPerson.person_id == person_id)
|
||||
)
|
||||
).all()
|
||||
assert len(remaining_links) == 0
|
||||
|
||||
# Document itself should remain intact
|
||||
document = await session.get(Document, doc_id)
|
||||
assert document is not None
|
||||
+67
-119
@@ -1,16 +1,12 @@
|
||||
"""Tests for the people page routes."""
|
||||
"""Tests for the people page routes and action handlers."""
|
||||
|
||||
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
|
||||
from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Person
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -27,15 +23,13 @@ class TestPeoplePageRendering:
|
||||
assert "Create new person" in response.text
|
||||
assert "No person records found in repository." in response.text
|
||||
|
||||
def test_people_page_lists_seeded_people(self, app_client):
|
||||
@pytest.mark.asyncio
|
||||
async 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())
|
||||
async with session_scope() as session:
|
||||
session.add(Person(full_name="Ada Lovelace", display_name="Ada"))
|
||||
await session.commit()
|
||||
|
||||
response = client.get("/ui/people")
|
||||
|
||||
@@ -56,30 +50,27 @@ class TestPeoplePageRendering:
|
||||
assert "Biography" in response.text
|
||||
assert "Save person" in response.text
|
||||
|
||||
def test_person_detail_page_renders_metadata_and_empty_links(self, app_client):
|
||||
@pytest.mark.asyncio
|
||||
async 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())
|
||||
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()
|
||||
person_id = str(person.id)
|
||||
|
||||
response = client.get(f"/ui/people/{person_id}")
|
||||
|
||||
@@ -97,51 +88,44 @@ class TestPeoplePageRendering:
|
||||
assert "Created:" in response.text
|
||||
assert "Updated:" 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_resolves_relative_portrait_path_to_uploads_mount(self, app_client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_person_detail_page_resolves_relative_portrait_path(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async def _seed_person() -> str:
|
||||
async with session_scope() as session:
|
||||
person = Person(
|
||||
full_name="Portrait Person",
|
||||
portrait_path="portraits/person/seeded.png",
|
||||
)
|
||||
session.add(person)
|
||||
await session.commit()
|
||||
await session.refresh(person)
|
||||
return str(person.id)
|
||||
async with session_scope() as session:
|
||||
person = Person(
|
||||
full_name="Portrait Person",
|
||||
portrait_path="portraits/person/seeded.png",
|
||||
)
|
||||
session.add(person)
|
||||
await session.commit()
|
||||
person_id = str(person.id)
|
||||
|
||||
person_id = asyncio.run(_seed_person())
|
||||
response = client.get(f"/ui/people/{person_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "/uploads/portraits/person/seeded.png" in response.text
|
||||
|
||||
def test_person_detail_page_renders_linked_documents(self, app_client):
|
||||
@pytest.mark.asyncio
|
||||
async 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()
|
||||
async with session_scope() as session:
|
||||
person = Person(full_name="Linked Person")
|
||||
document = Document(name="Linked Document", document_type="letter")
|
||||
session.add_all([person, document])
|
||||
await session.flush()
|
||||
|
||||
session.add(
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
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())
|
||||
)
|
||||
await session.commit()
|
||||
person_id = str(person.id)
|
||||
|
||||
response = client.get(f"/ui/people/{person_id}")
|
||||
|
||||
@@ -165,73 +149,37 @@ class TestPeoplePageRendering:
|
||||
assert response.status_code == 200
|
||||
assert "Person not found" in response.text
|
||||
|
||||
def test_person_edit_page_renders_expected_fields(self, app_client):
|
||||
@pytest.mark.asyncio
|
||||
async 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())
|
||||
async with session_scope() as session:
|
||||
person = Person(full_name="Editable Person", display_name="EP")
|
||||
session.add(person)
|
||||
await session.commit()
|
||||
person_id = str(person.id)
|
||||
|
||||
response = client.get(f"/ui/people/{person_id}/edit")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Edit Person Record" in response.text
|
||||
assert "Full name is required." in response.text
|
||||
assert "Full name" in response.text
|
||||
assert "Editable Person" in response.text
|
||||
assert "Save changes" in response.text
|
||||
|
||||
def test_person_delete_page_shows_confirmation_when_unlinked(self, app_client):
|
||||
@pytest.mark.asyncio
|
||||
async 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())
|
||||
async with session_scope() as session:
|
||||
person = Person(full_name="Safe Delete")
|
||||
session.add(person)
|
||||
await session.commit()
|
||||
person_id = str(person.id)
|
||||
|
||||
response = client.get(f"/ui/people/{person_id}/delete")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Delete Person Record" in response.text
|
||||
assert "This action permanently deletes the person record." in response.text
|
||||
assert "Delete person permanently" in response.text
|
||||
|
||||
def test_person_delete_page_warns_links_will_be_removed_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 "This will also remove 1 linked document relationship(s)." in response.text
|
||||
assert "Delete person permanently" in response.text
|
||||
assert "Delete person permanently" in response.text
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Action handler tests for Source CRUD mutations."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
from transcription.db import session_scope
|
||||
from transcription.db.models import Document, Job, JobSource, Source
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestSourcesActionHandlers:
|
||||
"""Verify POST/mutation routes for Source revisions and deletions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_revision_for_source_success(self, app_client, seed_job):
|
||||
_, client = app_client
|
||||
job_id = seed_job(
|
||||
filename="revision-source.png",
|
||||
transcription_text="automated raw transcription text",
|
||||
)
|
||||
|
||||
async with session_scope() as session:
|
||||
job = await session.get(Job, job_id)
|
||||
assert job is not None
|
||||
source = (
|
||||
await session.exec(select(Source).where(Source.document_id == job.document_id))
|
||||
).first()
|
||||
assert source is not None
|
||||
source_id = str(source.id)
|
||||
|
||||
payload = {
|
||||
"revised_text": "Curated human transcription text by editor.",
|
||||
}
|
||||
|
||||
response = client.post(f"/ui/sources/{source_id}", data=payload, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Revision saved" in response.text or "Curated human transcription text by editor." in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
updated_source = await session.get(Source, source_id)
|
||||
assert updated_source is not None
|
||||
assert updated_source.revised_text == "Curated human transcription text by editor."
|
||||
assert updated_source.date_revised is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_unlinked_source_success(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="Unlinked Source Doc", document_type="memo")
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
source = Source(
|
||||
document_id=doc.id,
|
||||
page_number=1,
|
||||
upload_name="orphan_page.png",
|
||||
filename="orphan_page.png",
|
||||
file_path="/tmp/orphan_page.png",
|
||||
)
|
||||
session.add(source)
|
||||
await session.commit()
|
||||
source_id = str(source.id)
|
||||
|
||||
response = client.post(f"/ui/sources/{source_id}/delete", follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Source deleted" in response.text or "Archival Source Media" in response.text
|
||||
|
||||
async with session_scope() as session:
|
||||
deleted_source = await session.get(Source, source_id)
|
||||
assert deleted_source is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_source_blocked_when_job_linked(self, app_client, seed_job):
|
||||
_, client = app_client
|
||||
job_id = seed_job(filename="job-linked-source.png", transcription_text="job text")
|
||||
|
||||
async with session_scope() as session:
|
||||
job = await session.get(Job, job_id)
|
||||
assert job is not None
|
||||
source = (
|
||||
await session.exec(select(Source).where(Source.document_id == job.document_id))
|
||||
).first()
|
||||
assert source is not None
|
||||
source_id = str(source.id)
|
||||
|
||||
response = client.post(f"/ui/sources/{source_id}/delete", follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Delete is only available for unlinked sources." in response.text or "linked" in response.text.lower()
|
||||
|
||||
async with session_scope() as session:
|
||||
source_still_exists = await session.get(Source, source_id)
|
||||
assert source_still_exists is not None
|
||||
Reference in New Issue
Block a user