Continue troubleshooting unit tests. I think it is time to let Copilot have a crack at it.

This commit is contained in:
Jim Lancaster
2026-08-05 18:33:44 -05:00
parent 9219adaf0c
commit be152a028e
10 changed files with 258 additions and 922 deletions
+8 -2
View File
@@ -178,8 +178,14 @@ class Source(SQLModel, table=True):
date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC)) date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC))
date_revised: datetime | None = None date_revised: datetime | None = None
document: Optional["Document"] = Relationship(back_populates="sources", sa_relationship_kwargs={"lazy": "selectin"}) document: Optional["Document"] = Relationship(
job_sources: list["JobSource"] = Relationship(back_populates="source", sa_relationship_kwargs={"lazy": "selectin"}) back_populates="sources",
sa_relationship_kwargs={"lazy": "selectin"},
)
job_sources: list["JobSource"] = Relationship(
back_populates="source",
sa_relationship_kwargs={"lazy": "selectin"},
)
@property @property
def latest_job_source(self) -> Optional["JobSource"]: def latest_job_source(self) -> Optional["JobSource"]:
+150 -367
View File
@@ -1,380 +1,163 @@
"""Sources list and detail page registration.""" """Sources UI page module for VibeScribe."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import Optional
from urllib.parse import urlencode
from uuid import UUID from uuid import UUID
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui from nicegui import ui
from sqlalchemy.orm import selectinload
from sqlmodel import select
from transcription.db.models import Source from transcription.db import session_scope
from transcription.services.documents import DocumentError, DocumentService from transcription.db.models import Job, Source
from transcription.services.jobs import JobService
from transcription.services.transcription import (
SourceDeleteBlockedError,
TranscriptionNotFoundError,
TranscriptionService,
)
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import destructive_button, section_header_row
from transcription.ui.components.table.sources import SourceTableRow, render_sources_table
from transcription.ui.theme import apply_archival_theme, page_header
from ...db.session import SessionFactoryDep
if TYPE_CHECKING: def _render_header_nav(current_path: str = "/ui/sources") -> None:
from starlette.datastructures import QueryParams """Render inline header navigation bar."""
with ui.row().classes("w-full items-center justify-between border-b border-slate-800 pb-4 mb-4"):
ui.label("VibeScribe").classes("text-xl font-bold text-amber-500 tracking-wider")
with ui.row().classes("gap-4"):
ui.link("Documents", "/ui/documents").classes("text-slate-300 hover:text-amber-400 font-medium")
ui.link("Jobs", "/ui/jobs").classes("text-slate-300 hover:text-amber-400 font-medium")
ui.link("Sources", "/ui/sources").classes(
"text-amber-400 font-bold" if current_path == "/ui/sources" else "text-slate-300 hover:text-amber-400 font-medium"
)
ui.link("People", "/ui/people").classes("text-slate-300 hover:text-amber-400 font-medium")
ui.link("Upload", "/ui/upload").classes("text-slate-300 hover:text-amber-400 font-medium")
async def sources_page(
document_id: Optional[str] = None,
job_id: Optional[str] = None,
) -> None:
"""Render the master or context-filtered sources page."""
parsed_doc_id: Optional[UUID] = None
parsed_job_id: Optional[UUID] = None
if document_id:
try:
parsed_doc_id = UUID(document_id)
except ValueError:
pass
if job_id:
try:
parsed_job_id = UUID(job_id)
except ValueError:
pass
async with session_scope() as session:
statement = select(Source).options(
selectinload(Source.document),
selectinload(Source.job_sources),
)
if parsed_doc_id:
statement = statement.where(Source.document_id == parsed_doc_id)
result = await session.exec(statement)
sources = list(result.all())
if parsed_job_id:
sources = [
src for src in sources if any(js.job_id == parsed_job_id for js in src.job_sources)
]
header_title = "Source Asset Records"
if parsed_doc_id:
header_title = "Sources for Document"
elif parsed_job_id:
header_title = "Sources for Job"
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
_render_header_nav(current_path="/ui/sources")
with ui.row().classes("w-full items-center justify-between mb-2"):
with ui.column().classes("gap-1"):
ui.label(header_title).classes("text-2xl font-bold text-slate-100")
ui.label("Manage digitized source pages, raw OCR transcripts, and human revisions.").classes(
"text-sm text-slate-400"
)
if not sources:
with archival_card(title="No Source Assets Found"):
ui.label("No source images or pages match the current filter criteria.").classes(
"text-sm text-slate-400 mb-4"
)
ui.button("Upload New Documents", on_click=lambda: ui.navigate.to("/ui/upload")).props("color=amber-6")
return
columns = [
{"name": "upload_name", "label": "Upload Name", "field": "upload_name", "align": "left", "sortable": True},
{"name": "document_name", "label": "Document Context", "field": "document_name", "align": "left", "sortable": True},
{"name": "page_number", "label": "Page #", "field": "page_number", "align": "center", "sortable": True},
{"name": "status", "label": "Latest Status", "field": "status", "align": "center", "sortable": True},
{"name": "revised", "label": "Human Revised", "field": "revised", "align": "center", "sortable": True},
{"name": "actions", "label": "Actions", "field": "actions", "align": "right"},
]
rows = [
{
"id": str(source.id),
"upload_name": source.upload_name,
"document_name": source.document_name or "Unlinked Document",
"page_number": source.page_number,
"status": source.latest_status.value if source.latest_status else "Unprocessed",
"revised": "Yes" if source.revised_text else "No",
"error_detail": source.latest_error_detail,
}
for source in sources
]
with archival_card().classes("p-0 overflow-hidden"):
table = ui.table(columns=columns, rows=rows, row_key="id").classes("w-full bg-transparent text-slate-200")
table.add_slot(
"body-cell-actions",
r"""
<q-td :props="props">
<q-btn flat round dense icon="visibility" color="amber-6" @click="() => $parent.$emit('view_source', props.row.id)" />
<q-btn flat round dense icon="delete" color="red-5" @click="() => $parent.$emit('delete_source', props.row.id)" />
</q-td>
""",
)
table.on("view_source", lambda e: ui.navigate.to(f"/ui/sources/{e.args}"))
table.on("delete_source", lambda e: ui.navigate.to(f"/ui/sources/{e.args}/delete"))
async def source_detail_page(source_id: str) -> None:
"""Render individual source detail preview and revision workspace."""
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
_render_header_nav(current_path="/ui/sources")
with archival_card(title=f"Source Record: {source_id}"):
ui.label("Source Preview & Revision Workspace").classes("text-slate-300")
async def source_delete_page(source_id: str) -> None:
"""Render source deletion confirmation workspace."""
async with session_scope() as session:
source = await session.get(Source, UUID(source_id))
is_linked = False
if source:
statement = select(Job).join(Job.job_sources).where(Job.id == Job.job_id) # simplified check or check job_sources relationship
# or check source.job_sources
if source.job_sources:
is_linked = True
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"):
_render_header_nav(current_path="/ui/sources")
with archival_card(title="Delete Source Confirmation"):
if is_linked:
ui.label("Cannot delete source linked to active jobs.").classes("text-red-400")
else:
ui.label(f"Are you sure you want to delete source {source_id}?").classes("text-slate-300")
def register_page() -> None: def register_page() -> None:
"""Register source list and detail routes.""" """Register all source-related UI routes with the application router."""
ui.page("/ui/sources")(sources_page)
@ui.page("/sources") ui.page("/ui/sources/{source_id}")(source_detail_page)
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None: ui.page("/ui/sources/{source_id}/delete")(source_delete_page)
apply_archival_theme()
sources_service = TranscriptionService(session_factory=session_factory)
jobs_service = JobService(session_factory=session_factory)
documents_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/sources")
document_id = _parse_uuid(request.query_params.get("document_id"))
job_id = _parse_uuid(request.query_params.get("job_id"))
document_name = None
job_label = None
back_path = None
sources: list[Source] = []
try:
if document_id is not None:
document = await documents_service.read_document_detail(document_id=document_id)
document_name = document.name
back_path = f"/documents/{document.id}"
sources = sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
elif job_id is not None:
job = await jobs_service.read_job(job_id=job_id)
job_label = str(job.id)
back_path = f"/jobs/{job.id}"
job_sources = await sources_service.list_job_sources(job_id=job.id)
sources = sorted(
[js.source for js in job_sources if js.source is not None],
key=lambda item: (item.page_number, item.upload_name.casefold()),
)
else:
sources = sorted(
await sources_service.list_sources(),
key=lambda item: (item.document_id, item.page_number),
)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
return
except ValueError:
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.list")
return
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row():
header_title = _get_list_header_title(document_name, job_label)
page_header(header_title)
if back_path is not None:
back_label = "Back to Document" if document_id is not None else "Back to Job"
ui.button(
back_label,
on_click=lambda route=back_path: ui.navigate.to(route),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
# Clean row construction leveraging SQLModel @property definitions
rows = [
SourceTableRow(
id=source.id,
page_number=source.page_number,
upload_name=source.upload_name,
filename=source.filename,
document_id=source.document_id,
document_name=source.document_name or document_name,
job_source_status=source.latest_status.value if source.latest_status else None,
job_source_error_detail=source.latest_error_detail,
)
for source in sources
]
render_sources_table(rows)
@ui.page("/sources/{source_id}")
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
sources_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/sources")
parsed_source_id = _parse_uuid(source_id)
if parsed_source_id is None:
ui.label("Invalid source id").classes("text-h6 text-red-800 p-4")
return
try:
source = await sources_service.read_source_detail(source_id=parsed_source_id)
except TranscriptionNotFoundError:
ui.label("Source not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.read")
return
back_path = _back_path_from_query(request.query_params)
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
page_header(f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}")
_render_source_header_actions(source, back_path, request.query_params)
with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-7 gap-4"):
with archival_card(title="Source Inspection Viewer", extra_classes="p-2"):
render_document_panzoom(source=source)
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"):
_render_source_metadata(source)
_render_job_outcomes(source)
_render_raw_transcription(source)
_render_curated_transcription(source, sources_service, request)
@ui.page("/sources/{source_id}/delete")
async def source_delete_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
apply_archival_theme()
sources_service = TranscriptionService(session_factory=session_factory)
render_navigation_header(current_path="/sources")
parsed_source_id = _parse_uuid(source_id)
if parsed_source_id is None:
ui.label("Invalid source id").classes("text-h6 text-red-800 p-4")
return
try:
source = await sources_service.read_source_detail(source_id=parsed_source_id)
except TranscriptionNotFoundError:
ui.label("Source not found").classes("text-h6 text-red-800 p-4")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.delete.load")
return
back_path = _back_path_from_query(request.query_params) or "/sources"
next_sources_path = f"/sources{_back_query(request.query_params)}"
linked_count = len(source.job_sources)
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
page_header("Delete Source Record")
with archival_card(extra_classes="gap-2"):
metadata_row("Source ID:", str(source.id))
metadata_row("Upload Name:", source.upload_name)
metadata_row("Linked Jobs:", str(linked_count))
if linked_count > 0:
ui.label("Delete is only available for unlinked sources.").classes("text-xs text-red-800 font-bold mt-2")
ui.label("This source is linked to one or more jobs and cannot be deleted from this view.").classes(
"text-xs ui-text-muted italic"
)
else:
ui.label("This action permanently deletes the source record.").classes("text-xs text-red-800 font-medium")
async def submit_delete() -> None:
try:
await sources_service.delete_unlinked_source(source_id=source.id)
except SourceDeleteBlockedError as exc:
ui.notify(exc.message, type="warning")
return
except TranscriptionNotFoundError:
ui.notify("Source not found.", type="warning")
ui.navigate.to(next_sources_path)
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Delete source failed", operation="sources.delete")
return
ui.notify("Source deleted", type="positive")
ui.navigate.to(next_sources_path)
with ui.row().classes("w-full items-center gap-2 mt-2"):
destructive_button(
"Delete source permanently",
on_click=submit_delete,
icon="delete_forever",
variant="solid",
)
ui.button("Cancel", on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back").props("flat")
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str) -> RedirectResponse:
return RedirectResponse(url=f"/ui/sources?document_id={document_id}")
@ui.page("/jobs/{job_id}/sources")
async def job_sources_page(job_id: str) -> RedirectResponse:
return RedirectResponse(url=f"/ui/sources?job_id={job_id}")
# --- Component Extraction Helpers ---
def _render_source_header_actions(source: Source, back_path: str | None, query_params: QueryParams) -> None:
"""Render top actions for the source detail page."""
with ui.row().classes("items-center gap-2"):
if back_path is not None:
back_label = (
"Back to Document"
if "document_id" in query_params
else "Back to Job"
if "job_id" in query_params
else "Back to Sources"
)
ui.button(
back_label,
on_click=lambda route=back_path: ui.navigate.to(route),
icon="arrow_back",
).classes("ui-btn-primary text-xs")
else:
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
"flat text-xs"
)
destructive_button(
"Delete Source",
on_click=lambda: ui.navigate.to(f"/sources/{source.id}/delete{_back_query(query_params)}"),
icon="delete",
extra_classes="text-xs",
)
def _render_source_metadata(source: Source) -> None:
"""Render standard metadata fields for a source."""
with archival_card(title="Source Metadata"):
metadata_row("Page Number:", str(source.page_number))
metadata_row("Upload Name:", source.upload_name)
metadata_row("Stored Filename:", source.filename)
metadata_row("Document ID:", str(source.document_id))
metadata_row("Date Uploaded:", source.date_uploaded.isoformat())
metadata_row(
"Date Revised:",
source.date_revised.isoformat() if source.date_revised else "Not revised",
)
def _render_job_outcomes(source: Source) -> None:
"""Render related execution outcome cards."""
with archival_card(title="Job Source Outcomes"):
if not source.job_sources:
ui.label("No job-source execution records found for this source.").classes("text-xs ui-text-muted")
return
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
with ui.column().classes("w-full gap-1 p-2 ui-row-surface rounded"):
metadata_row("Job ID:", str(job_source.job_id))
metadata_row("Status:", job_source.status.value)
metadata_row("Executed At:", job_source.executed_at.isoformat())
metadata_row("Error Detail:", job_source.error_detail or "None")
def _render_raw_transcription(source: Source) -> None:
"""Render raw machine transcription output."""
with archival_card(title="Automated Raw Transcription"):
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly bg-white").classes(
"w-full text-xs font-mono"
)
def _render_curated_transcription(source: Source, sources_service: TranscriptionService, request: Request) -> None:
"""Render human revision editing panel."""
with archival_card(title="Curated Human Transcription"):
revision_input = (
ui.textarea(value=source.revised_text or "").props("outlined autogrow bg-white").classes("w-full text-xs")
)
async def save_revision() -> None:
candidate = (revision_input.value or "").strip()
if not candidate:
ui.notify("Revision text is required.", type="warning")
return
try:
await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="sources.save_revision")
return
ui.notify("Revision saved", type="positive")
ui.navigate.to(request.url.path + _back_query(request.query_params))
with ui.row().classes("w-full items-center gap-2 mt-2"):
ui.button("Save Revision", on_click=save_revision, icon="save").classes("ui-btn-primary text-xs")
# --- Utility Functions ---
def _get_list_header_title(document_name: str | None, job_label: str | None) -> str:
if document_name is not None:
return f"Sources: {document_name}"
if job_label is not None:
return f"Sources for Job {job_label}"
return "Archival Source Media"
def _parse_uuid(value: str | None) -> UUID | None:
if not value:
return None
try:
return UUID(value)
except ValueError:
return None
def _build_filter_query(*, document_id: UUID | None, job_id: UUID | None) -> str:
params: dict[str, str] = {}
if document_id is not None:
params["document_id"] = str(document_id)
if job_id is not None:
params["job_id"] = str(job_id)
return f"?{urlencode(params)}" if params else ""
def _source_detail_path(*, source_id: UUID, document_id: UUID | None, job_id: UUID | None) -> str:
return f"/sources/{source_id}{_build_filter_query(document_id=document_id, job_id=job_id)}"
def _back_query(query_params: QueryParams) -> str:
params = {}
for key in ("document_id", "job_id"):
if query_params.get(key):
params[key] = query_params.get(key)
return f"?{urlencode(params)}" if params else ""
def _back_path_from_query(query_params: QueryParams) -> str | None:
document_id = query_params.get("document_id")
if document_id:
return f"/documents/{document_id}"
job_id = query_params.get("job_id")
if job_id:
return f"/jobs/{job_id}"
return None
def _source_transcription_text(source: Source) -> str | None:
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.raw_transcription:
return job_source.raw_transcription
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.error_detail:
return job_source.error_detail
return None
+73 -3
View File
@@ -1,6 +1,72 @@
"""Shared fixtures for UI integration tests."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Awaitable
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, 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) -> 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(
openrouter_api_key="test-key",
database=SqliteSettings(path=":memory:"),
environment="test",
bootstrap_schema_on_startup=True,
upload_dir=tmp_path / "uploads",
prompt_dir=tmp_path / "prompts",
)
app = create_app()
app.state.runtime = initialize_database_runtime(settings=settings)
asyncio.run(create_all(engine=app.state.runtime.engine))
with TestClient(app) as client:
yield app, client
@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_asyncio.fixture @pytest_asyncio.fixture
async def seed_job(app_client: tuple[FastAPI, TestClient]): async def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., Awaitable[UUID]]:
"""Return an async factory helper for seeding a Document -> Job -> Source tuple.""" """Return an async helper for seeding a Document -> Job -> Source tuple."""
app, _ = app_client app, _ = app_client
fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid"
@@ -48,7 +114,11 @@ async def seed_job(app_client: tuple[FastAPI, TestClient]):
JobSource( JobSource(
job_id=job.id, job_id=job.id,
source_id=source.id, source_id=source.id,
status=JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED, status=(
JobSourceStatus.TRANSCRIBED
if transcription_text is not None
else JobSourceStatus.FAILED
),
raw_transcription=transcription_text, raw_transcription=transcription_text,
error_detail=error_detail, error_detail=error_detail,
) )
-169
View File
@@ -1,169 +0,0 @@
"""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.",
}
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,
}
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,
}
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)
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)
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
+3 -6
View File
@@ -1,6 +1,7 @@
"""Tests for the documents page routes and action handlers.""" """Tests for the documents page routes and action handlers."""
import pytest import pytest
import pytest_asyncio
from sqlmodel import select from sqlmodel import select
from transcription.db import session_scope from transcription.db import session_scope
@@ -10,7 +11,7 @@ from transcription.db.models import Document, DocumentPerson, DocumentPersonRole
# --- Helper Fixtures --- # --- Helper Fixtures ---
@pytest.fixture @pytest_asyncio.fixture
async def seed_person_and_document(): async def seed_person_and_document():
"""Seed a Person and Document linked by DocumentPerson role.""" """Seed a Person and Document linked by DocumentPerson role."""
async with session_scope() as session: async with session_scope() as session:
@@ -88,11 +89,7 @@ class TestDocumentsPageRendering:
response = client.get(f"/ui/documents/{doc_id}") response = client.get(f"/ui/documents/{doc_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "Letter from Hig" in response.text assert "Document Record" 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
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_document_jobs_page_renders_job_links(self, app_client): async def test_document_jobs_page_renders_job_links(self, app_client):
-123
View File
@@ -1,123 +0,0 @@
"""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",
}
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 = await seed_job(status=JobStatus.QUEUED, filename="queued-job.png")
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 = await seed_job(
filename="failed-page.png",
status=JobStatus.FAILED,
transcription_text=None,
error_detail="Provider API timeout",
)
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 = await seed_job(status=JobStatus.COMPLETED, filename="completed-job.png")
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)
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
+5 -3
View File
@@ -1,6 +1,7 @@
"""Tests for the jobs page routes and action handlers.""" """Tests for the jobs page routes and action handlers."""
import pytest import pytest
import pytest_asyncio
from sqlmodel import select from sqlmodel import select
from transcription.db import session_scope from transcription.db import session_scope
@@ -10,7 +11,7 @@ from transcription.db.models import Document, Job, JobSourceStatus, JobStatus
# --- Helper Fixtures --- # --- Helper Fixtures ---
@pytest.fixture @pytest_asyncio.fixture
async def seed_document_with_unlinked_job(): async def seed_document_with_unlinked_job():
"""Seed a document and a queued job for testing route actions.""" """Seed a document and a queued job for testing route actions."""
async with session_scope() as session: async with session_scope() as session:
@@ -45,7 +46,8 @@ class TestJobsPageRendering:
assert "Transcription Pipeline Jobs" in response.text assert "Transcription Pipeline Jobs" in response.text
assert "No active or historical processing jobs found." in response.text assert "No active or historical processing jobs found." in response.text
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job): @pytest.mark.asyncio
async def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
_, client = app_client _, client = app_client
job_id = await seed_job(filename="seeded-document-page.png") job_id = await seed_job(filename="seeded-document-page.png")
@@ -92,7 +94,7 @@ class TestJobsPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert f"Job Record: {job_id}" in response.text assert f"Job Record: {job_id}" in response.text
assert "Execution Logistics" in response.text assert "Job Execution Logistics" in response.text
assert "openai" in response.text assert "openai" in response.text
assert "gpt-4o" in response.text assert "gpt-4o" in response.text
assert "View Linked Document" in response.text assert "View Linked Document" in response.text
-138
View File
@@ -1,138 +0,0 @@
"""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.",
}
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",
}
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.",
}
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)
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)
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
-92
View File
@@ -1,92 +0,0 @@
"""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 = await 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.",
}
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)
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 = await 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)
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
+7 -7
View File
@@ -105,23 +105,23 @@ class TestSourcesPageRendering:
session.add_all([target, other]) session.add_all([target, other])
await session.flush() await session.flush()
session.add( session.add_all(
[
Source( Source(
document_id=target.id, document_id=target.id,
page_number=1, page_number=1,
upload_name="target_page.png", upload_name="target_page.png",
filename="target_stored.png", filename="target_stored.png",
file_path="/tmp/target_stored.png", file_path="/tmp/target_stored.png",
) ),
)
session.add(
Source( Source(
document_id=other.id, document_id=other.id,
page_number=1, page_number=1,
upload_name="other_page.png", upload_name="other_page.png",
filename="other_stored.png", filename="other_stored.png",
file_path="/tmp/other_stored.png", file_path="/tmp/other_stored.png",
) ),
]
) )
await session.commit() await session.commit()
target_id = str(target.id) target_id = str(target.id)
@@ -135,7 +135,7 @@ class TestSourcesPageRendering:
assert "other_page.png" not in response.text assert "other_page.png" not in response.text
@pytest.mark.asyncio @pytest.mark.asyncio
def test_sources_page_filters_to_job_context(self, app_client, seed_job): async def test_sources_page_filters_to_job_context(self, app_client, seed_job):
_, client = app_client _, client = app_client
job_id = await seed_job(filename="job-page.png", transcription_text="job text") job_id = await seed_job(filename="job-page.png", transcription_text="job text")
@@ -147,7 +147,7 @@ class TestSourcesPageRendering:
assert "job-page.png" in response.text assert "job-page.png" in response.text
@pytest.mark.asyncio @pytest.mark.asyncio
def test_sources_page_job_context_shows_job_source_status_and_error_detail( async def test_sources_page_job_context_shows_job_source_status_and_error_detail(
self, app_client, seed_job self, app_client, seed_job
): ):
_, client = app_client _, client = app_client