generated from john/python-template
V4.3 revision to Document Types
This commit is contained in:
@@ -10,6 +10,7 @@ from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import select
|
||||
|
||||
from transcription.api.errors import register_error_handlers
|
||||
from transcription.api.v4_documents import get_document_service
|
||||
@@ -21,6 +22,7 @@ from transcription.db import create_all
|
||||
from transcription.db.engine import get_database_url
|
||||
from transcription.db.engine import get_engine
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentType
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.session import dispose_session_factory
|
||||
from transcription.db.session import session_scope
|
||||
@@ -45,6 +47,12 @@ def _seed_document_and_person(
|
||||
return asyncio.run(_seed())
|
||||
|
||||
|
||||
async def _document_type_id(*, db_url: str, label: str) -> UUID:
|
||||
async with session_scope(database_url=db_url) as session:
|
||||
document_type = (await session.exec(select(DocumentType).where(DocumentType.label == label))).one()
|
||||
return document_type.id
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _v4_api_client(tmp_path: Path, *, db_filename: str) -> Generator[tuple[TestClient, str]]:
|
||||
settings = Settings(
|
||||
@@ -88,8 +96,8 @@ def test_list_document_types_returns_seeded_registry(tmp_path):
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
codes = {item["code"] for item in payload}
|
||||
assert {"letter", "record", "memo"}.issubset(codes)
|
||||
labels = {item["label"] for item in payload}
|
||||
assert {"Letter", "Record", "Memo"}.issubset(labels)
|
||||
|
||||
|
||||
def test_list_person_roles_returns_seeded_registry(tmp_path):
|
||||
@@ -102,37 +110,37 @@ def test_list_person_roles_returns_seeded_registry(tmp_path):
|
||||
assert {"author", "recipient", "mentioned"}.issubset(codes)
|
||||
|
||||
|
||||
def test_set_document_type_by_code_updates_canonical_fields(tmp_path):
|
||||
def test_set_document_type_by_id_updates_canonical_field(tmp_path):
|
||||
with _v4_api_client(tmp_path, db_filename="api-doc-type.db") as (client, db_url):
|
||||
document_id, _ = _seed_document_and_person(db_url=db_url)
|
||||
type_id = asyncio.run(_document_type_id(db_url=db_url, label="Record"))
|
||||
response = client.put(
|
||||
f"/api/v4/documents/{document_id}/type",
|
||||
json={"document_type_code": "record"},
|
||||
json={"document_type_id": str(type_id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["document_id"] == str(document_id)
|
||||
assert payload["document_type_id"] is not None
|
||||
assert payload["document_type_code"] == "record"
|
||||
assert payload["document_type_id"] == str(type_id)
|
||||
|
||||
|
||||
def test_document_type_payload_requires_exactly_one_selector(tmp_path):
|
||||
def test_document_type_payload_requires_uuid_only(tmp_path):
|
||||
with _v4_api_client(tmp_path, db_filename="api-doc-type-validation.db") as (client, db_url):
|
||||
document_id, _ = _seed_document_and_person(db_url=db_url)
|
||||
|
||||
missing = client.put(f"/api/v4/documents/{document_id}/type", json={})
|
||||
conflicting = client.put(
|
||||
invalid = client.put(
|
||||
f"/api/v4/documents/{document_id}/type",
|
||||
json={"document_type_id": str(UUID(int=1)), "document_type_code": "record"},
|
||||
json={"document_type_id": "record"},
|
||||
)
|
||||
unexpected = client.put(
|
||||
f"/api/v4/documents/{document_id}/type",
|
||||
json={"document_type_code": "record", "ignored": True},
|
||||
json={"document_type_id": str(UUID(int=1)), "ignored": True},
|
||||
)
|
||||
|
||||
assert missing.status_code == 422
|
||||
assert conflicting.status_code == 422
|
||||
assert invalid.status_code == 422
|
||||
assert unexpected.status_code == 422
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from transcription.config import Settings
|
||||
from transcription.db.models import Document
|
||||
from transcription.db.models import DocumentPerson
|
||||
from transcription.db.models import DocumentPersonRole
|
||||
from transcription.db.models import DocumentType
|
||||
from transcription.db.models import Job
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import PersonRole
|
||||
@@ -26,12 +25,13 @@ from transcription.services.people import PeopleService
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_document_detail_allows_missing_sources(default_session_factory):
|
||||
service = DocumentService(session_factory=default_session_factory)
|
||||
document_type = await service.create_document_type(label="Letter")
|
||||
|
||||
created = await service.create_document(
|
||||
Document(
|
||||
id=uuid4(),
|
||||
name="detail-doc",
|
||||
document_type="letter",
|
||||
document_type_id=document_type.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ async def test_read_document_detail_allows_missing_sources(default_session_facto
|
||||
assert detail.sources == []
|
||||
assert detail.document_type_id is not None
|
||||
assert detail.document_type_ref is not None
|
||||
assert detail.document_type_ref.code == "letter"
|
||||
assert detail.document_type_ref.label == "Letter"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -52,7 +52,6 @@ async def test_update_document_refreshes_updated_timestamp(default_session_facto
|
||||
Document(
|
||||
id=uuid4(),
|
||||
name="timestamp-doc",
|
||||
document_type="letter",
|
||||
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
)
|
||||
@@ -74,7 +73,6 @@ async def test_delete_document_blocks_when_dependencies_exist(default_session_fa
|
||||
Document(
|
||||
id=uuid4(),
|
||||
name="blocked-delete",
|
||||
document_type="record",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -106,7 +104,6 @@ async def test_delete_document_succeeds_when_unlinked(default_session_factory, t
|
||||
Document(
|
||||
id=uuid4(),
|
||||
name="free-delete",
|
||||
document_type="memo",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -132,7 +129,6 @@ async def test_delete_document_removes_person_links(default_session_factory, tmp
|
||||
Document(
|
||||
id=uuid4(),
|
||||
name="person-linked-delete",
|
||||
document_type="memo",
|
||||
)
|
||||
)
|
||||
person = await people_service.create_person(Person(full_name="Linked Person"))
|
||||
@@ -171,7 +167,6 @@ async def test_delete_document_removes_populated_storage_tree(default_session_fa
|
||||
Document(
|
||||
id=uuid4(),
|
||||
name="tree-delete",
|
||||
document_type="memo",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -198,7 +193,6 @@ async def test_read_person_detail_loads_document_links(default_session_factory):
|
||||
Document(
|
||||
id=uuid4(),
|
||||
name="linked-doc",
|
||||
document_type="letter",
|
||||
)
|
||||
)
|
||||
person = await people_service.create_person(Person(full_name="Linked Person"))
|
||||
@@ -246,7 +240,6 @@ async def test_delete_person_removes_links_when_linked_documents_exist(default_s
|
||||
Document(
|
||||
id=uuid4(),
|
||||
name="block-person-delete-doc",
|
||||
document_type="record",
|
||||
)
|
||||
)
|
||||
person = await service.create_person(Person(full_name="Blocked Person"))
|
||||
@@ -280,16 +273,12 @@ async def test_delete_person_succeeds_when_unlinked(default_session_factory):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_document_reuses_existing_document_type_registry(default_session_factory):
|
||||
async def test_create_document_uses_existing_document_type_registry(default_session_factory):
|
||||
service = DocumentService(session_factory=default_session_factory)
|
||||
|
||||
async with service._session_scope() as session:
|
||||
existing = DocumentType(code="record", label="Record")
|
||||
session.add(existing)
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
existing = await service.create_document_type(label="Record")
|
||||
|
||||
created = await service.create_document(Document(id=uuid4(), name="typed-doc", document_type="record"))
|
||||
created = await service.create_document(Document(id=uuid4(), name="typed-doc", document_type_id=existing.id))
|
||||
|
||||
assert created.document_type_id is not None
|
||||
assert created.document_type_id == existing.id
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from transcription.config import Settings
|
||||
from transcription.errors import ErrorCategory
|
||||
from transcription.services.prompts import PromptStore
|
||||
from transcription.services.prompts import PromptStoreError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prompt_store(tmp_path):
|
||||
prompt_dir = tmp_path / "prompts"
|
||||
prompt_dir.mkdir()
|
||||
(prompt_dir / "transcribe_document.md").write_text("Original prompt\n", encoding="utf-8")
|
||||
(prompt_dir / "notes.txt").write_text("Not a prompt\n", encoding="utf-8")
|
||||
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
|
||||
return PromptStore(settings=settings), prompt_dir
|
||||
|
||||
|
||||
def test_list_and_read_existing_markdown_prompts(prompt_store):
|
||||
store, _ = prompt_store
|
||||
|
||||
prompts = store.list_prompts()
|
||||
|
||||
assert [item.name for item in prompts] == ["transcribe_document.md"]
|
||||
assert prompts[0].is_default is True
|
||||
assert prompts[0].has_backup is False
|
||||
assert store.read_prompt("transcribe_document.md") == "Original prompt\n"
|
||||
|
||||
|
||||
def test_write_rotates_single_backup_and_recovery_swaps_versions(prompt_store):
|
||||
store, prompt_dir = prompt_store
|
||||
prompt_path = prompt_dir / "transcribe_document.md"
|
||||
backup_path = prompt_dir / "transcribe_document.md.bak"
|
||||
|
||||
store.write_prompt(prompt_path.name, "Second prompt")
|
||||
assert prompt_path.read_text(encoding="utf-8") == "Second prompt\n"
|
||||
assert backup_path.read_text(encoding="utf-8") == "Original prompt\n"
|
||||
|
||||
store.write_prompt(prompt_path.name, "Third prompt")
|
||||
assert prompt_path.read_text(encoding="utf-8") == "Third prompt\n"
|
||||
assert backup_path.read_text(encoding="utf-8") == "Second prompt\n"
|
||||
assert store.list_prompts()[0].has_backup is True
|
||||
|
||||
store.recover_prompt(prompt_path.name)
|
||||
assert prompt_path.read_text(encoding="utf-8") == "Second prompt\n"
|
||||
assert backup_path.read_text(encoding="utf-8") == "Third prompt\n"
|
||||
assert list(prompt_dir.glob("*.tmp")) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"../outside.md",
|
||||
"nested/prompt.md",
|
||||
r"nested\prompt.md",
|
||||
"prompt.txt",
|
||||
],
|
||||
)
|
||||
def test_prompt_names_are_constrained(prompt_store, name):
|
||||
store, _ = prompt_store
|
||||
|
||||
with pytest.raises(PromptStoreError) as caught:
|
||||
store.read_prompt(name)
|
||||
|
||||
assert caught.value.category == ErrorCategory.VALIDATION
|
||||
|
||||
|
||||
def test_prompt_creation_and_empty_content_are_rejected(prompt_store):
|
||||
store, _ = prompt_store
|
||||
|
||||
with pytest.raises(PromptStoreError) as missing:
|
||||
store.write_prompt("new_prompt.md", "content")
|
||||
with pytest.raises(PromptStoreError) as empty:
|
||||
store.write_prompt("transcribe_document.md", " \n")
|
||||
|
||||
assert missing.value.category == ErrorCategory.NOT_FOUND
|
||||
assert empty.value.category == ErrorCategory.VALIDATION
|
||||
|
||||
|
||||
def test_recovery_requires_a_backup(prompt_store):
|
||||
store, _ = prompt_store
|
||||
|
||||
with pytest.raises(PromptStoreError) as caught:
|
||||
store.recover_prompt("transcribe_document.md")
|
||||
|
||||
assert caught.value.category == ErrorCategory.NOT_FOUND
|
||||
|
||||
|
||||
def test_failed_active_replace_preserves_complete_prompt(prompt_store, monkeypatch):
|
||||
store, prompt_dir = prompt_store
|
||||
prompt_path = prompt_dir / "transcribe_document.md"
|
||||
original_replace = type(prompt_path).replace
|
||||
|
||||
def fail_active_replace(path, target):
|
||||
if target == prompt_path:
|
||||
raise OSError("simulated replace failure")
|
||||
return original_replace(path, target)
|
||||
|
||||
monkeypatch.setattr(type(prompt_path), "replace", fail_active_replace)
|
||||
|
||||
with pytest.raises(PromptStoreError) as caught:
|
||||
store.write_prompt(prompt_path.name, "Replacement prompt")
|
||||
|
||||
assert caught.value.category == ErrorCategory.INFRA_PERSISTENT
|
||||
assert prompt_path.read_text(encoding="utf-8") == "Original prompt\n"
|
||||
assert (prompt_dir / "transcribe_document.md.bak").read_text(encoding="utf-8") == "Original prompt\n"
|
||||
assert list(prompt_dir.glob(".*.tmp")) == []
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
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.errors import ErrorCategory
|
||||
from transcription.services.documents import DocumentService
|
||||
from transcription.services.documents import DocumentTypeError
|
||||
from transcription.services.people import PeopleService
|
||||
from transcription.services.people import PersonRoleError
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_type_maintenance_uses_alphabetical_labels(default_session_factory):
|
||||
service = DocumentService(session_factory=default_session_factory)
|
||||
|
||||
second = await service.create_document_type(label="Court Record")
|
||||
first = await service.create_document_type(label="Letter")
|
||||
updated = await service.update_document_type(
|
||||
second.id,
|
||||
label="Archive",
|
||||
is_active=False,
|
||||
)
|
||||
|
||||
assert updated.label == "Archive"
|
||||
assert updated.is_active is False
|
||||
assert [item.id for item in await service.list_document_types(active_only=False)] == [second.id, first.id]
|
||||
assert [item.id for item in await service.list_document_types()] == [first.id]
|
||||
|
||||
summaries = await service.list_document_type_summaries()
|
||||
assert [item.label for item in summaries] == ["Archive", "Letter"]
|
||||
assert [item.document_count for item in summaries] == [0, 0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_type_duplicate_normalized_label_is_conflict(default_session_factory):
|
||||
service = DocumentService(session_factory=default_session_factory)
|
||||
await service.create_document_type(label="Letter")
|
||||
|
||||
with pytest.raises(DocumentTypeError) as caught:
|
||||
await service.create_document_type(label=" letter ")
|
||||
|
||||
assert caught.value.category == ErrorCategory.CONFLICT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_type_delete_allows_unreferenced_and_blocks_referenced(default_session_factory):
|
||||
service = DocumentService(session_factory=default_session_factory)
|
||||
unused = await service.create_document_type(label="Unused")
|
||||
referenced = await service.create_document_type(label="Record")
|
||||
await service.create_document(Document(id=uuid4(), name="Typed document", document_type_id=referenced.id))
|
||||
|
||||
summaries = {item.id: item for item in await service.list_document_type_summaries()}
|
||||
assert summaries[referenced.id].document_count == 1
|
||||
|
||||
await service.delete_document_type(unused.id)
|
||||
with pytest.raises(DocumentTypeError) as caught:
|
||||
await service.delete_document_type(referenced.id)
|
||||
|
||||
assert caught.value.category == ErrorCategory.CONFLICT
|
||||
relabeled = await service.update_document_type(
|
||||
referenced.id,
|
||||
label="Referenced Record",
|
||||
is_active=False,
|
||||
)
|
||||
assert relabeled.label == "Referenced Record"
|
||||
assert relabeled.is_active is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_person_role_maintenance_orders_by_label_then_code(default_session_factory):
|
||||
service = PeopleService(session_factory=default_session_factory)
|
||||
second = await service.create_person_role(code="witness", label="Witness")
|
||||
first = await service.create_person_role(code="author", label="Author")
|
||||
|
||||
updated = await service.update_person_role(second.id, label="Attestor", is_active=False)
|
||||
|
||||
assert updated.code == "witness"
|
||||
assert [item.id for item in await service.list_person_roles(active_only=False)] == [second.id, first.id]
|
||||
assert [item.id for item in await service.list_person_roles()] == [first.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(default_session_factory):
|
||||
documents = DocumentService(session_factory=default_session_factory)
|
||||
people = PeopleService(session_factory=default_session_factory)
|
||||
unused = await people.create_person_role(code="witness", label="Witness")
|
||||
referenced = await people.create_person_role(code="author", label="Author")
|
||||
document = await documents.create_document(Document(name="Role document"))
|
||||
person = await people.create_person(Person(full_name="Role Person"))
|
||||
await people.create_document_person(
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role_id=referenced.id,
|
||||
role=DocumentPersonRole.AUTHOR,
|
||||
)
|
||||
)
|
||||
|
||||
await people.delete_person_role(unused.id)
|
||||
with pytest.raises(PersonRoleError) as caught:
|
||||
await people.delete_person_role(referenced.id)
|
||||
|
||||
assert caught.value.category == ErrorCategory.CONFLICT
|
||||
relabeled = await people.update_person_role(referenced.id, label="Creator", is_active=False)
|
||||
assert relabeled.label == "Creator"
|
||||
assert relabeled.is_active is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_person_role_duplicate_code_is_conflict(default_session_factory):
|
||||
service = PeopleService(session_factory=default_session_factory)
|
||||
await service.create_person_role(code="author", label="Author")
|
||||
|
||||
with pytest.raises(PersonRoleError) as caught:
|
||||
await service.create_person_role(code=" AUTHOR ", label="Duplicate")
|
||||
|
||||
assert caught.value.category == ErrorCategory.CONFLICT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_person_role_can_be_used_for_document_link(default_session_factory):
|
||||
documents = DocumentService(session_factory=default_session_factory)
|
||||
people = PeopleService(session_factory=default_session_factory)
|
||||
role = await people.create_person_role(code="witness", label="Witness")
|
||||
document = await documents.create_document(Document(name="Witnessed document"))
|
||||
person = await people.create_person(Person(full_name="Archive Witness"))
|
||||
|
||||
link = await people.add_document_person_link(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role_id=role.id,
|
||||
)
|
||||
loaded = await people.list_document_people(document_id=document.id)
|
||||
|
||||
assert link.role == "witness"
|
||||
assert loaded[0].role_ref is not None
|
||||
assert loaded[0].role_ref.code == "witness"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_person_role_delete_blocks_legacy_only_reference(default_session_factory):
|
||||
documents = DocumentService(session_factory=default_session_factory)
|
||||
people = PeopleService(session_factory=default_session_factory)
|
||||
role = await people.create_person_role(code="witness", label="Witness")
|
||||
document = await documents.create_document(Document(name="Legacy role document"))
|
||||
person = await people.create_person(Person(full_name="Legacy Witness"))
|
||||
async with people._session_scope() as session:
|
||||
session.add(
|
||||
DocumentPerson(
|
||||
document_id=document.id,
|
||||
person_id=person.id,
|
||||
role="witness",
|
||||
role_id=None,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
assert await people.is_person_role_referenced(role.id) is True
|
||||
with pytest.raises(PersonRoleError) as caught:
|
||||
await people.delete_person_role(role.id)
|
||||
|
||||
assert caught.value.category == ErrorCategory.CONFLICT
|
||||
+93
-8
@@ -1,5 +1,7 @@
|
||||
"""Tests for the database runtime and V2 schema bootstrap behavior."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy import text
|
||||
@@ -97,10 +99,10 @@ async def test_create_all_seeds_default_registry_rows(tmp_path):
|
||||
await create_all(engine=runtime.engine)
|
||||
async with AsyncSession(runtime.engine, expire_on_commit=False) as session:
|
||||
role_codes = set((await session.exec(select(PersonRole.code))).all())
|
||||
type_codes = set((await session.exec(select(DocumentType.code))).all())
|
||||
type_labels = set((await session.exec(select(DocumentType.label))).all())
|
||||
|
||||
assert {"author", "recipient", "mentioned"}.issubset(role_codes)
|
||||
assert {"letter", "record", "memo"}.issubset(type_codes)
|
||||
assert {"Letter", "Record", "Memo"}.issubset(type_labels)
|
||||
finally:
|
||||
await dispose_database_runtime()
|
||||
|
||||
@@ -130,9 +132,94 @@ async def test_create_all_upgrades_existing_person_table_for_family_search(tmp_p
|
||||
)
|
||||
|
||||
assert "family_search_id" in columns
|
||||
assert any(
|
||||
index["column_names"] == ["family_search_id"] and index["unique"] for index in indexes
|
||||
)
|
||||
assert any(index["column_names"] == ["family_search_id"] and index["unique"] for index in indexes)
|
||||
finally:
|
||||
await dispose_database_runtime()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_migrates_document_types_to_uuid_only_identity(tmp_path):
|
||||
settings = Settings(
|
||||
openrouter_api_key="test-key",
|
||||
database=SqliteSettings(path=str(tmp_path / "type-upgrade.db")),
|
||||
environment="test",
|
||||
)
|
||||
runtime = initialize_database_runtime(settings=settings)
|
||||
type_id = uuid4().hex
|
||||
document_id = uuid4().hex
|
||||
|
||||
try:
|
||||
async with runtime.engine.begin() as connection:
|
||||
await connection.execute(
|
||||
text(
|
||||
"CREATE TABLE document_type ("
|
||||
"id CHAR(32) PRIMARY KEY NOT NULL, "
|
||||
"code VARCHAR NOT NULL, "
|
||||
"label VARCHAR NOT NULL, "
|
||||
"is_active BOOLEAN NOT NULL, "
|
||||
"sort_order INTEGER NOT NULL, "
|
||||
"created_at DATETIME NOT NULL, "
|
||||
"updated_at DATETIME NOT NULL"
|
||||
")"
|
||||
)
|
||||
)
|
||||
await connection.execute(text("CREATE UNIQUE INDEX ix_document_type_code ON document_type (code)"))
|
||||
await connection.execute(
|
||||
text(
|
||||
"CREATE TABLE document ("
|
||||
"id CHAR(32) PRIMARY KEY NOT NULL, "
|
||||
"name VARCHAR NOT NULL, "
|
||||
"document_type_id CHAR(32), "
|
||||
"document_type VARCHAR, "
|
||||
"document_date DATE, "
|
||||
"document_date_raw VARCHAR, "
|
||||
"location_created VARCHAR, "
|
||||
"notes VARCHAR, "
|
||||
"archive_identifier VARCHAR, "
|
||||
"created_at DATETIME NOT NULL, "
|
||||
"updated_at DATETIME NOT NULL"
|
||||
")"
|
||||
)
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"INSERT INTO document_type "
|
||||
"(id, code, label, is_active, sort_order, created_at, updated_at) "
|
||||
"VALUES (:id, 'letter', 'Letter', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
|
||||
),
|
||||
{"id": type_id},
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"INSERT INTO document "
|
||||
"(id, name, document_type_id, document_type, created_at, updated_at) "
|
||||
"VALUES (:id, 'Legacy Letter', NULL, 'letter', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
|
||||
),
|
||||
{"id": document_id},
|
||||
)
|
||||
|
||||
await upgrade_schema(engine=runtime.engine)
|
||||
|
||||
async with runtime.engine.connect() as connection:
|
||||
type_columns, document_columns, migrated_type_id, normalized_label = await connection.run_sync(
|
||||
lambda sync_connection: (
|
||||
{column["name"] for column in inspect(sync_connection).get_columns("document_type")},
|
||||
{column["name"] for column in inspect(sync_connection).get_columns("document")},
|
||||
sync_connection.execute(
|
||||
text("SELECT document_type_id FROM document WHERE id = :id"),
|
||||
{"id": document_id},
|
||||
).scalar_one(),
|
||||
sync_connection.execute(
|
||||
text("SELECT normalized_label FROM document_type WHERE id = :id"),
|
||||
{"id": type_id},
|
||||
).scalar_one(),
|
||||
)
|
||||
)
|
||||
|
||||
assert {"code", "sort_order"}.isdisjoint(type_columns)
|
||||
assert "document_type" not in document_columns
|
||||
assert migrated_type_id == type_id
|
||||
assert normalized_label == "letter"
|
||||
finally:
|
||||
await dispose_database_runtime()
|
||||
|
||||
@@ -175,9 +262,7 @@ async def test_v42_upgrade_adds_evidence_tables_without_rewriting_legacy_snapsho
|
||||
async with runtime.engine.connect() as connection:
|
||||
table_names = set(await connection.run_sync(lambda c: inspect(c).get_table_names()))
|
||||
legacy_snapshot = (
|
||||
await connection.execute(
|
||||
text("SELECT raw_api_response FROM job_source WHERE id = 'link-1'")
|
||||
)
|
||||
await connection.execute(text("SELECT raw_api_response FROM job_source WHERE id = 'link-1'"))
|
||||
).scalar_one()
|
||||
|
||||
assert {"execution_attempt", "processing_artifact"}.issubset(table_names)
|
||||
|
||||
@@ -27,8 +27,8 @@ def _make_document(**overrides) -> Document:
|
||||
return Document(**defaults)
|
||||
|
||||
|
||||
def _persist_document_type(session, *, code: str = "letter", label: str = "Letter") -> DocumentType:
|
||||
document_type = DocumentType(code=code, label=label)
|
||||
def _persist_document_type(session, *, label: str = "Letter") -> DocumentType:
|
||||
document_type = DocumentType(label=label, normalized_label=label.strip().casefold())
|
||||
session.add(document_type)
|
||||
session.commit()
|
||||
session.refresh(document_type)
|
||||
@@ -45,7 +45,7 @@ def _persist_person_role(session, *, code: str = "author", label: str = "Author"
|
||||
|
||||
def _persist_document(session) -> Document:
|
||||
document_type = _persist_document_type(session)
|
||||
document = _make_document(document_type_id=document_type.id, document_type=document_type.code)
|
||||
document = _make_document(document_type_id=document_type.id)
|
||||
session.add(document)
|
||||
session.commit()
|
||||
session.refresh(document)
|
||||
@@ -117,8 +117,8 @@ class TestDocumentModel:
|
||||
assert document.updated_at is not None
|
||||
|
||||
def test_can_reference_document_type_registry(self, session):
|
||||
document_type = _persist_document_type(session, code="record", label="Record")
|
||||
document = _make_document(document_type_id=document_type.id, document_type=document_type.code)
|
||||
document_type = _persist_document_type(session, label="Record")
|
||||
document = _make_document(document_type_id=document_type.id)
|
||||
session.add(document)
|
||||
session.commit()
|
||||
session.refresh(document)
|
||||
@@ -264,9 +264,9 @@ class TestRegistryModels:
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
|
||||
def test_document_type_code_is_unique(self, session):
|
||||
_persist_document_type(session, code="journal", label="Journal")
|
||||
duplicate = DocumentType(code="journal", label="Journal Duplicate")
|
||||
def test_document_type_normalized_label_is_unique(self, session):
|
||||
_persist_document_type(session, label="Journal")
|
||||
duplicate = DocumentType(label=" journal ", normalized_label="journal")
|
||||
session.add(duplicate)
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
|
||||
@@ -4,11 +4,13 @@ from datetime import date
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
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 DocumentType
|
||||
from transcription.db.models import Job
|
||||
from transcription.db.models import Person
|
||||
from transcription.db.models import Source
|
||||
@@ -20,13 +22,14 @@ from transcription.db.models import Source
|
||||
async def seed_person_and_document():
|
||||
"""Seed a Person and Document linked by DocumentPerson role."""
|
||||
async with session_scope() as session:
|
||||
letter_type = (await session.exec(select(DocumentType).where(DocumentType.label == "Letter"))).one()
|
||||
person = Person(full_name="Zenna Cochran")
|
||||
session.add(person)
|
||||
await session.flush()
|
||||
|
||||
doc = Document(
|
||||
name="Letter from Hig",
|
||||
document_type="letter",
|
||||
document_type_id=letter_type.id,
|
||||
archive_identifier="ZC-1924-001",
|
||||
)
|
||||
session.add(doc)
|
||||
@@ -63,7 +66,12 @@ class TestDocumentsPageRendering:
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
doc = Document(name="1924 Postcard", document_type="postcard", archive_identifier="PC-001")
|
||||
postcard_type = (await session.exec(select(DocumentType).where(DocumentType.label == "Postcard"))).one()
|
||||
doc = Document(
|
||||
name="1924 Postcard",
|
||||
document_type_id=postcard_type.id,
|
||||
archive_identifier="PC-001",
|
||||
)
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
|
||||
@@ -71,7 +79,7 @@ class TestDocumentsPageRendering:
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "1924 Postcard" in response.text
|
||||
assert "postcard" in response.text
|
||||
assert "Postcard" in response.text
|
||||
assert "PC-001" in response.text
|
||||
assert "Document Date" in response.text
|
||||
assert "Author" in response.text
|
||||
@@ -106,9 +114,7 @@ class TestDocumentsPageRendering:
|
||||
assert "Hig - Albert Edward Higgins (1885)" in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_detail_page_renders_bento_grid_and_metadata(
|
||||
self, app_client, seed_person_and_document
|
||||
):
|
||||
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
|
||||
|
||||
@@ -143,9 +149,7 @@ class TestDocumentsPageRendering:
|
||||
assert f"Job ID: {job_id}" in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_edit_page_prefills_existing_values(
|
||||
self, app_client, seed_person_and_document
|
||||
):
|
||||
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
|
||||
|
||||
@@ -157,9 +161,7 @@ class TestDocumentsPageRendering:
|
||||
assert "ZC-1924-001" in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_delete_page_blocks_deletion_when_dependencies_exist(
|
||||
self, app_client
|
||||
):
|
||||
async def test_document_delete_page_blocks_deletion_when_dependencies_exist(self, app_client):
|
||||
_, client = app_client
|
||||
|
||||
async with session_scope() as session:
|
||||
|
||||
@@ -84,9 +84,7 @@ class TestJobsPageRendering:
|
||||
assert "Preselected Journal Entry" in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_detail_page_renders_logistics_and_links(
|
||||
self, app_client, seed_document_with_unlinked_job
|
||||
):
|
||||
async def test_job_detail_page_renders_logistics_and_links(self, app_client, seed_document_with_unlinked_job):
|
||||
_, client = app_client
|
||||
_, job_id = seed_document_with_unlinked_job
|
||||
|
||||
@@ -102,9 +100,7 @@ class TestJobsPageRendering:
|
||||
assert "updates automatically while the job is active" in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_cancel_page_renders_confirmation(
|
||||
self, app_client, seed_document_with_unlinked_job
|
||||
):
|
||||
async def test_job_cancel_page_renders_confirmation(self, app_client, seed_document_with_unlinked_job):
|
||||
_, client = app_client
|
||||
_, job_id = seed_document_with_unlinked_job
|
||||
|
||||
@@ -116,9 +112,7 @@ class TestJobsPageRendering:
|
||||
assert "Cancel job" in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_resubmit_page_renders_counts(
|
||||
self, app_client, seed_job
|
||||
):
|
||||
async def test_job_resubmit_page_renders_counts(self, app_client, seed_job):
|
||||
_, client = app_client
|
||||
job_id = await seed_job(
|
||||
filename="failed-resubmit.png",
|
||||
|
||||
@@ -31,6 +31,7 @@ class TestNavigationAndMounts:
|
||||
"/ui/people",
|
||||
"/ui/sources",
|
||||
"/ui/jobs",
|
||||
"/ui/settings",
|
||||
],
|
||||
)
|
||||
def test_registered_pages_render_successfully(self, app_client, route_path: str):
|
||||
@@ -39,4 +40,4 @@ class TestNavigationAndMounts:
|
||||
response = client.get(route_path)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "html" in response.headers.get("content-type", "").lower()
|
||||
assert "html" in response.headers.get("content-type", "").lower()
|
||||
|
||||
@@ -16,9 +16,11 @@ class TestPageRegistration:
|
||||
people_response = client.get("/ui/people")
|
||||
sources_response = client.get("/ui/sources")
|
||||
jobs_response = client.get("/ui/jobs")
|
||||
settings_response = client.get("/ui/settings")
|
||||
|
||||
assert homepage_response.status_code == 200
|
||||
assert documents_response.status_code == 200
|
||||
assert people_response.status_code == 200
|
||||
assert sources_response.status_code == 200
|
||||
assert jobs_response.status_code == 200
|
||||
assert settings_response.status_code == 200
|
||||
|
||||
@@ -48,9 +48,7 @@ class TestSourceModelProperties:
|
||||
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()
|
||||
source = (await session.exec(select(Source).where(Source.document_id == job.document_id))).first()
|
||||
assert source is not None
|
||||
|
||||
# Validate computed properties
|
||||
@@ -159,9 +157,7 @@ class TestSourcesPageRendering:
|
||||
assert "job-page.png" in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sources_page_job_context_shows_job_source_status_and_error_detail(
|
||||
self, app_client, seed_job
|
||||
):
|
||||
async def test_sources_page_job_context_shows_job_source_status_and_error_detail(self, app_client, seed_job):
|
||||
_, client = app_client
|
||||
job_id = await seed_job(
|
||||
filename="job-failed-page.png",
|
||||
@@ -178,17 +174,9 @@ class TestSourcesPageRendering:
|
||||
assert "Provider timed out" in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_detail_page_renders_preview_and_revision_box(
|
||||
self, app_client, seed_job
|
||||
):
|
||||
async def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
|
||||
_, client = app_client
|
||||
fixture_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "fixtures"
|
||||
/ "images"
|
||||
/ "valid"
|
||||
/ "small_png.png"
|
||||
)
|
||||
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "small_png.png"
|
||||
job_id = await seed_job(
|
||||
filename="detail-source.png",
|
||||
transcription_text="original transcription text",
|
||||
@@ -201,9 +189,7 @@ class TestSourcesPageRendering:
|
||||
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()
|
||||
source = (await session.exec(select(Source).where(Source.document_id == job.document_id))).first()
|
||||
assert source is not None
|
||||
source_id = str(source.id)
|
||||
|
||||
@@ -232,9 +218,7 @@ class TestSourcesPageRendering:
|
||||
async with session_scope(session_factory=app.state.runtime.session_factory) 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()
|
||||
source = (await session.exec(select(Source).where(Source.document_id == job.document_id))).first()
|
||||
assert source is not None
|
||||
source_id = source.id
|
||||
|
||||
@@ -268,18 +252,14 @@ class TestSourcesPageRendering:
|
||||
assert "Derived Artifacts" in response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_delete_page_blocks_when_source_is_job_linked(
|
||||
self, app_client, seed_job
|
||||
):
|
||||
async def test_source_delete_page_blocks_when_source_is_job_linked(self, app_client, seed_job):
|
||||
_, client = app_client
|
||||
job_id = await seed_job(filename="linked-source.png", transcription_text="linked 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()
|
||||
source = (await session.exec(select(Source).where(Source.document_id == job.document_id))).first()
|
||||
assert source is not None
|
||||
source_id = str(source.id)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user