V4 implemented. Some tweaking left, but it is working

This commit is contained in:
Jim Lancaster
2026-08-11 12:07:19 -05:00
parent ccf2c78ff4
commit 0ace10269f
19 changed files with 1379 additions and 79 deletions
+169
View File
@@ -0,0 +1,169 @@
"""Integration tests for additive V4 document and relationship API routes."""
from __future__ import annotations
import asyncio
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from uuid import UUID
from fastapi import FastAPI
from fastapi.testclient import TestClient
from transcription.api.errors import register_error_handlers
from transcription.api.v4_documents import get_document_service
from transcription.api.v4_documents import router
from transcription.config import Settings
from transcription.config import SqliteSettings
from transcription.db import create_all
from transcription.db.engine import get_database_url
from transcription.db.engine import get_engine
from transcription.db.session import dispose_session_factory
from transcription.db.session import session_scope
from transcription.db.models import Document
from transcription.db.models import Person
from transcription.services.documents import DocumentService
def _seed_document_and_person(*, db_url: str, document_name: str = "API Doc", person_name: str = "API Person") -> tuple[UUID, UUID]:
async def _seed() -> tuple[UUID, UUID]:
async with session_scope(database_url=db_url) as session:
document = Document(name=document_name)
person = Person(full_name=person_name)
session.add(document)
session.add(person)
await session.commit()
await session.refresh(document)
await session.refresh(person)
return document.id, person.id
return asyncio.run(_seed())
@contextmanager
def _v4_api_client(tmp_path: Path, *, db_filename: str) -> Generator[tuple[TestClient, str], None, None]:
settings = Settings(
openrouter_api_key="test-key",
database=SqliteSettings(path=str(tmp_path / db_filename)),
environment="test",
bootstrap_schema_on_startup=True,
upload_dir=tmp_path / "uploads",
prompt_dir=tmp_path / "prompts",
)
db_url = get_database_url(settings)
session_factory = None
async def _bootstrap() -> None:
nonlocal session_factory
from transcription.db.session import get_session_factory
session_factory = get_session_factory(database_url=db_url)
await create_all(engine=get_engine(db_url))
asyncio.run(_bootstrap())
service = DocumentService(session_factory=session_factory)
app = FastAPI()
register_error_handlers(app)
app.include_router(router)
app.dependency_overrides[get_document_service] = lambda: service
try:
with TestClient(app) as client:
yield client, db_url
finally:
asyncio.run(dispose_session_factory(db_url))
def test_list_document_types_returns_seeded_registry(tmp_path):
with _v4_api_client(tmp_path, db_filename="api-types.db") as (client, _db_url):
response = client.get("/api/v4/document-types")
assert response.status_code == 200
payload = response.json()
codes = {item["code"] for item in payload}
assert {"letter", "record", "memo"}.issubset(codes)
def test_list_person_roles_returns_seeded_registry(tmp_path):
with _v4_api_client(tmp_path, db_filename="api-roles.db") as (client, _db_url):
response = client.get("/api/v4/person-roles")
assert response.status_code == 200
payload = response.json()
codes = {item["code"] for item in payload}
assert {"author", "recipient", "mentioned"}.issubset(codes)
def test_set_document_type_by_code_updates_canonical_fields(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)
response = client.put(
f"/api/v4/documents/{document_id}/type",
json={"document_type_code": "record"},
)
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"
def test_document_people_role_aware_write_read_and_delete(tmp_path):
with _v4_api_client(tmp_path, db_filename="api-links.db") as (client, db_url):
document_id, person_id = _seed_document_and_person(db_url=db_url)
create_response = client.post(
f"/api/v4/documents/{document_id}/people",
json={"person_id": str(person_id), "role_code": "author"},
)
assert create_response.status_code == 200
created = create_response.json()
assert created["document_id"] == str(document_id)
assert created["person_id"] == str(person_id)
assert created["role_code"] == "author"
assert created["role_id"] is not None
link_id = created["id"]
update_response = client.patch(
f"/api/v4/document-people/{link_id}",
json={"role_code": "recipient"},
)
assert update_response.status_code == 200
updated = update_response.json()
assert updated["role_code"] == "recipient"
list_response = client.get(f"/api/v4/documents/{document_id}/people")
assert list_response.status_code == 200
links = list_response.json()["links"]
assert len(links) == 1
assert links[0]["role_code"] == "recipient"
delete_response = client.delete(f"/api/v4/document-people/{link_id}")
assert delete_response.status_code == 204
list_after_delete = client.get(f"/api/v4/documents/{document_id}/people")
assert list_after_delete.status_code == 200
assert list_after_delete.json()["links"] == []
def test_duplicate_document_person_link_returns_conflict_envelope(tmp_path):
with _v4_api_client(tmp_path, db_filename="api-dup.db") as (client, db_url):
document_id, person_id = _seed_document_and_person(db_url=db_url)
first = client.post(
f"/api/v4/documents/{document_id}/people",
json={"person_id": str(person_id), "role_code": "author"},
)
assert first.status_code == 200
second = client.post(
f"/api/v4/documents/{document_id}/people",
json={"person_id": str(person_id), "role_code": "author"},
)
assert second.status_code == 409
payload = second.json()
assert payload["category"] == "conflict_error"
+61
View File
@@ -6,12 +6,15 @@ from pathlib import Path
from uuid import uuid4
import pytest
from sqlmodel import select
from transcription.db.models import Document
from transcription.db.models import Job
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import DocumentType
from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.db.models import Source
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
@@ -34,6 +37,9 @@ async def test_read_document_detail_allows_missing_sources(default_session_facto
assert detail.id == created.id
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"
@pytest.mark.asyncio
@@ -135,6 +141,12 @@ async def test_delete_document_removes_person_links(default_session_factory, tmp
)
)
links_before_delete = await service.list_document_people(document_id=document.id)
assert len(links_before_delete) == 1
assert links_before_delete[0].role_id is not None
assert links_before_delete[0].role_ref is not None
assert links_before_delete[0].role_ref.code == "author"
document_dir = service.settings.upload_dir / "documents" / str(document.id)
document_dir.mkdir(parents=True, exist_ok=True)
@@ -260,3 +272,52 @@ async def test_delete_person_succeeds_when_unlinked(default_session_factory):
with pytest.raises(DocumentError):
await service.read_person_detail(person.id)
@pytest.mark.asyncio
async def test_create_document_reuses_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)
created = await service.create_document(Document(id=uuid4(), name="typed-doc", document_type="record"))
assert created.document_type_id is not None
assert created.document_type_id == existing.id
@pytest.mark.asyncio
async def test_update_document_person_sets_role_id_from_legacy_role(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document = await service.create_document(Document(id=uuid4(), name="role-sync-doc", document_type="letter"))
person = await service.create_person(Person(full_name="Role Sync Person"))
link = await service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
)
)
updated = await service.update_document_person(
DocumentPerson(
id=link.id,
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.RECIPIENT,
role_id=None,
)
)
assert updated.role == DocumentPersonRole.RECIPIENT
assert updated.role_id is not None
async with service._session_scope() as session:
recipient_role = (await session.exec(select(PersonRole).where(PersonRole.code == "recipient"))).first()
assert recipient_role is not None
assert updated.role_id == recipient_role.id
+4
View File
@@ -24,6 +24,8 @@ async def test_document_service_handles_person_and_document_person_crud(default_
document = await documents.create_document(Document(id=uuid4(), name="person-doc"))
person = await documents.create_person(Person(full_name="Ada Lovelace"))
assert document.document_type_id is None
link = await documents.create_document_person(
DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
)
@@ -31,11 +33,13 @@ async def test_document_service_handles_person_and_document_person_crud(default_
fetched = await documents.read_document_person(link.id)
assert fetched.id == link.id
assert fetched.role == DocumentPersonRole.AUTHOR
assert fetched.role_id is not None
updated_link = await documents.update_document_person(
DocumentPerson(id=link.id, document_id=document.id, person_id=person.id, role=DocumentPersonRole.RECIPIENT)
)
assert updated_link.role == DocumentPersonRole.RECIPIENT
assert updated_link.role_id is not None
listed = await documents.list_document_people(document_id=document.id)
assert len(listed) == 1
+27
View File
@@ -2,6 +2,8 @@
import pytest
from sqlalchemy import inspect
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from transcription.config import Settings
from transcription.config import SqliteSettings
@@ -9,6 +11,8 @@ from transcription.db import create_all
from transcription.db import dispose_database_runtime
from transcription.db import initialize_database_runtime
from transcription.db import session_scope
from transcription.db.models import DocumentType
from transcription.db.models import PersonRole
@pytest.mark.asyncio
@@ -26,7 +30,9 @@ async def test_create_all_creates_expected_tables(tmp_path):
table_names = set(await conn.run_sync(lambda c: inspect(c).get_table_names()))
assert "document" in table_names
assert "document_type" in table_names
assert "person" in table_names
assert "person_role" in table_names
assert "document_person" in table_names
assert "job" in table_names
assert "source" in table_names
@@ -52,6 +58,27 @@ async def test_get_session_yields_async_session(tmp_path):
await dispose_database_runtime()
@pytest.mark.asyncio
async def test_create_all_seeds_default_registry_rows(tmp_path):
settings = Settings(
openrouter_api_key="test-key",
database=SqliteSettings(path=str(tmp_path / "seed.db")),
environment="test",
)
runtime = initialize_database_runtime(settings=settings)
try:
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())
assert {"author", "recipient", "mentioned"}.issubset(role_codes)
assert {"letter", "record", "memo"}.issubset(type_codes)
finally:
await dispose_database_runtime()
def test_bootstrap_policy_production_defaults_false():
settings = Settings(openrouter_api_key="test-key", environment="production")
assert settings.should_bootstrap_schema is False
+64 -5
View File
@@ -8,6 +8,7 @@ import pytest
from sqlalchemy.exc import IntegrityError
from transcription.db.models import Document
from transcription.db.models import DocumentType
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Job
@@ -15,21 +16,38 @@ from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.db.models import Source
def _make_document(**overrides) -> Document:
defaults = {
"name": "letter bundle",
"document_type": "letter",
"notes": "Family correspondence",
}
defaults.update(overrides)
return Document(**defaults)
def _persist_document_type(session, *, code: str = "letter", label: str = "Letter") -> DocumentType:
document_type = DocumentType(code=code, label=label)
session.add(document_type)
session.commit()
session.refresh(document_type)
return document_type
def _persist_person_role(session, *, code: str = "author", label: str = "Author") -> PersonRole:
role = PersonRole(code=code, label=label)
session.add(role)
session.commit()
session.refresh(role)
return role
def _persist_document(session) -> Document:
document = _make_document()
document_type = _persist_document_type(session)
document = _make_document(document_type_id=document_type.id, document_type=document_type.code)
session.add(document)
session.commit()
session.refresh(document)
@@ -100,6 +118,14 @@ class TestDocumentModel:
assert document.created_at is not None
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)
session.add(document)
session.commit()
session.refresh(document)
assert document.document_type_id == document_type.id
class TestJobModel:
def test_can_be_created_for_document(self, session):
@@ -158,12 +184,23 @@ class TestPersonAndDocumentPersonModel:
def test_document_person_role_is_unique_per_document_person(self, session):
document = _persist_document(session)
person = _persist_person(session)
person_role = _persist_person_role(session)
first = DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
first = DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
role_id=person_role.id,
)
session.add(first)
session.commit()
duplicate = DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
duplicate = DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
role_id=person_role.id,
)
session.add(duplicate)
with pytest.raises(IntegrityError):
session.commit()
@@ -196,8 +233,14 @@ class TestRelationships:
_persist_job(session, document)
_persist_source(session, document)
person = _persist_person(session)
person_role = _persist_person_role(session)
link = DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
link = DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
role_id=person_role.id,
)
session.add(link)
session.commit()
@@ -205,3 +248,19 @@ class TestRelationships:
assert len(document.jobs) == 1
assert len(document.sources) == 1
assert len(document.document_people) == 1
class TestRegistryModels:
def test_person_role_code_is_unique(self, session):
_persist_person_role(session, code="mentioned", label="Mentioned")
duplicate = PersonRole(code="mentioned", label="Mentioned Again")
session.add(duplicate)
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")
session.add(duplicate)
with pytest.raises(IntegrityError):
session.commit()
+2 -1
View File
@@ -77,7 +77,8 @@ class TestDocumentsPageRendering:
assert response.status_code == 200
assert "Create Document" in response.text
assert "Document name" in response.text
assert "Author (Person)" in response.text
assert "Linked People by Role" in response.text
assert "Document type" in response.text
@pytest.mark.asyncio
async def test_document_detail_page_renders_bento_grid_and_metadata(