V4.3 revision to Document Types

This commit is contained in:
Jim Lancaster
2026-08-15 13:29:53 -05:00
parent aed827babe
commit a78b58ff40
30 changed files with 1481 additions and 291 deletions
+6 -17
View File
@@ -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
+109
View File
@@ -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")) == []
+168
View File
@@ -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