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
+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
async def seed_job(app_client: tuple[FastAPI, TestClient]):
"""Return an async factory helper for seeding a Document -> Job -> Source tuple."""
async def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., Awaitable[UUID]]:
"""Return an async helper for seeding a Document -> Job -> Source tuple."""
app, _ = app_client
fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid"
@@ -48,7 +114,11 @@ async def seed_job(app_client: tuple[FastAPI, TestClient]):
JobSource(
job_id=job.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,
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."""
import pytest
import pytest_asyncio
from sqlmodel import select
from transcription.db import session_scope
@@ -10,7 +11,7 @@ from transcription.db.models import Document, DocumentPerson, DocumentPersonRole
# --- Helper Fixtures ---
@pytest.fixture
@pytest_asyncio.fixture
async def seed_person_and_document():
"""Seed a Person and Document linked by DocumentPerson role."""
async with session_scope() as session:
@@ -88,11 +89,7 @@ class TestDocumentsPageRendering:
response = client.get(f"/ui/documents/{doc_id}")
assert response.status_code == 200
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 "Document Record" in response.text
@pytest.mark.asyncio
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."""
import pytest
import pytest_asyncio
from sqlmodel import select
from transcription.db import session_scope
@@ -10,7 +11,7 @@ from transcription.db.models import Document, Job, JobSourceStatus, JobStatus
# --- Helper Fixtures ---
@pytest.fixture
@pytest_asyncio.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:
@@ -45,7 +46,8 @@ class TestJobsPageRendering:
assert "Transcription Pipeline Jobs" 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
job_id = await seed_job(filename="seeded-document-page.png")
@@ -92,7 +94,7 @@ class TestJobsPageRendering:
assert response.status_code == 200
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 "gpt-4o" 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
+19 -19
View File
@@ -105,23 +105,23 @@ class TestSourcesPageRendering:
session.add_all([target, 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",
)
session.add_all(
[
Source(
document_id=target.id,
page_number=1,
upload_name="target_page.png",
filename="target_stored.png",
file_path="/tmp/target_stored.png",
),
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()
target_id = str(target.id)
@@ -135,7 +135,7 @@ class TestSourcesPageRendering:
assert "other_page.png" not in response.text
@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
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
@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
):
_, client = app_client