generated from john/python-template
V4 implemented. Some tweaking left, but it is working
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user