The Office (workspace D-64..D-72) needs a store for Notes, Tasks,
Decisions, Questions and Rejected options. D-68 puts it here, in
core-api's own database, behind its own auth.
Migration 005 lands the whole schema now, by hand (down_revision="004";
the chain has a hash-named revision between 003 and 004, confirmed
before trusting the numbering): book_records (kind, status validated
per kind in the service, scope work/personal, a generated tsvector
search column + GIN index), book_tags, book_record_links, book_attachments
(table only, no file handling yet), and book_events — append-only via a
database trigger that raises on UPDATE and DELETE for every role,
including the owning application.
Routes this round (D-70's core verbs), every one behind
require_caller("office", "user"), writes with identity_required=True,
every change writing a book_events row: add, show, list (the nine
views), find (full-text), change, close (task done/dropped; question
resolved via a `resolves` link into `settled`; decision superseded via
a `supersedes` link into `superseded`), link/unlink, tag/untag, history.
Append, remind, reschedule, undo, purge, redact, pin, check, snooze,
archive/trash/restore, pause/resume, due, next, batch calls and
idempotency keys are explicitly not this round — the schema already
carries what they need.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
102 lines
4.2 KiB
Python
102 lines
4.2 KiB
Python
"""
|
|
Tests for Book migration 005.
|
|
|
|
Runs against `core_api_test` only (see conftest.py — skip when
|
|
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
|
|
Self-contained: starts and ends at head regardless of what ran before it,
|
|
so test order never matters for the schema the rest of the suite needs.
|
|
"""
|
|
import pytest
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from tests.conftest import run_alembic
|
|
|
|
|
|
async def _table_exists(url: str, table: str) -> bool:
|
|
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
|
try:
|
|
async with engine.connect() as conn:
|
|
result = await conn.execute(
|
|
text("SELECT to_regclass(:name) IS NOT NULL AS present"),
|
|
{"name": table},
|
|
)
|
|
return bool(result.scalar())
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
class TestMigration005RoundTrip:
|
|
"""Migration 005 must upgrade and downgrade cleanly (dossier verification #2)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upgrade_creates_book_tables(self, test_database_url):
|
|
result = run_alembic(test_database_url, "upgrade", "head")
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
for table in ("book_records", "book_tags", "book_record_links", "book_attachments", "book_events"):
|
|
assert await _table_exists(test_database_url, table), f"{table} missing after upgrade"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
|
|
# Baseline: make sure we start at head.
|
|
baseline = run_alembic(test_database_url, "upgrade", "head")
|
|
assert baseline.returncode == 0, baseline.stderr
|
|
|
|
down = run_alembic(test_database_url, "downgrade", "004")
|
|
assert down.returncode == 0, down.stderr
|
|
assert not await _table_exists(test_database_url, "book_records")
|
|
assert not await _table_exists(test_database_url, "book_events")
|
|
|
|
# Leave the schema at head for the rest of the suite.
|
|
up = run_alembic(test_database_url, "upgrade", "005")
|
|
assert up.returncode == 0, up.stderr
|
|
assert await _table_exists(test_database_url, "book_records")
|
|
assert await _table_exists(test_database_url, "book_events")
|
|
|
|
|
|
class TestBookEventsAppendOnly:
|
|
"""The trigger must block UPDATE and DELETE on book_events, no exceptions."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_on_book_events_raises(self, clean_book_tables):
|
|
from src.domains.book.models import BookEvent, BookRecord
|
|
|
|
session = clean_book_tables
|
|
record = BookRecord(kind="note", body="trigger test", status="active", created_by="test-harness")
|
|
session.add(record)
|
|
await session.flush()
|
|
event = BookEvent(record_id=record.id, identity="test-harness", client=None, action="add", diff={})
|
|
session.add(event)
|
|
await session.commit()
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
await session.execute(
|
|
text("UPDATE book_events SET action = 'tampered' WHERE id = :id").bindparams(id=event.id)
|
|
)
|
|
await session.commit()
|
|
assert "append-only" in str(exc_info.value).lower() or "book_events" in str(exc_info.value)
|
|
await session.rollback()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_on_book_events_raises(self, clean_book_tables):
|
|
from src.domains.book.models import BookEvent, BookRecord
|
|
|
|
session = clean_book_tables
|
|
record = BookRecord(kind="note", body="trigger test", status="active", created_by="test-harness")
|
|
session.add(record)
|
|
await session.flush()
|
|
event = BookEvent(record_id=record.id, identity="test-harness", client=None, action="add", diff={})
|
|
session.add(event)
|
|
await session.commit()
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
await session.execute(
|
|
text("DELETE FROM book_events WHERE id = :id").bindparams(id=event.id)
|
|
)
|
|
await session.commit()
|
|
assert "append-only" in str(exc_info.value).lower() or "book_events" in str(exc_info.value)
|
|
await session.rollback()
|