feat(book): add the Book domain — notes, tasks, decisions, questions, rejected (T-1)
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>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Shared test fixtures.
|
||||
|
||||
Database-backed tests need `TEST_DATABASE_URL` (core-api's gitignored .env).
|
||||
They **skip** when it is unset and **refuse** (hard failure, not a skip)
|
||||
when it points at `core_api` — that is production, and the dev `.env`'s
|
||||
other `POSTGRES_*` values point there too, so this is the one guard that
|
||||
stands between a test run and the production database.
|
||||
|
||||
Running the actual migration against the test database is done through the
|
||||
`alembic` CLI in a subprocess rather than in-process, because
|
||||
`alembic/env.py` always builds its connection URL from `get_settings()`
|
||||
(composed from `POSTGRES_*`), with no override hook of its own — and
|
||||
`get_settings()` is `lru_cache`d, so by the time a test runs, other test
|
||||
modules have almost certainly already imported `src.main` and cached a
|
||||
Settings instance pointed at production. A subprocess gets a clean
|
||||
environment and a clean cache; overriding `POSTGRES_HOST`/`USER`/
|
||||
`PASSWORD`/`DATABASE` in that child's environment, derived from
|
||||
`TEST_DATABASE_URL`, is what actually redirects the migration.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
ALEMBIC_BIN = REPO_ROOT / ".venv" / "bin" / "alembic"
|
||||
|
||||
load_dotenv(REPO_ROOT / ".env")
|
||||
|
||||
|
||||
def _components(url: str) -> dict:
|
||||
"""POSTGRES_* overrides for a subprocess, derived from a database URL."""
|
||||
parts = urlsplit(url)
|
||||
host = parts.hostname or ""
|
||||
if parts.port:
|
||||
host = f"{host}:{parts.port}"
|
||||
return {
|
||||
"POSTGRES_HOST": host,
|
||||
"POSTGRES_USER": parts.username or "",
|
||||
"POSTGRES_PASSWORD": parts.password or "",
|
||||
"POSTGRES_DATABASE": parts.path.lstrip("/"),
|
||||
}
|
||||
|
||||
|
||||
def run_alembic(url: str, *args: str) -> subprocess.CompletedProcess:
|
||||
"""Run `alembic <args>` against `url`, never against the repo's own .env database."""
|
||||
env = os.environ.copy()
|
||||
env.update(_components(url))
|
||||
result = subprocess.run(
|
||||
[str(ALEMBIC_BIN), *args],
|
||||
cwd=str(REPO_ROOT),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_database_url() -> str:
|
||||
"""The core_api_test database URL, or skip/refuse per the rules above."""
|
||||
url = os.environ.get("TEST_DATABASE_URL")
|
||||
if not url:
|
||||
pytest.skip("TEST_DATABASE_URL is not set — database tests skipped")
|
||||
|
||||
db_name = urlsplit(url).path.lstrip("/")
|
||||
if db_name == "core_api":
|
||||
pytest.fail(
|
||||
"TEST_DATABASE_URL resolves to 'core_api' (production) — refusing to use it for tests"
|
||||
)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def book_schema(test_database_url: str) -> str:
|
||||
"""Ensure migration 005 (and everything before it) is applied to core_api_test."""
|
||||
result = run_alembic(test_database_url, "upgrade", "head")
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
|
||||
return test_database_url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def book_session(book_schema: str):
|
||||
"""An AsyncSession bound to core_api_test, rolled back after each test."""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
asyncpg_url = book_schema.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def clean_book_tables(book_session):
|
||||
"""
|
||||
Book service methods commit internally (same pattern as the Dashboard
|
||||
service), so `book_session`'s post-test rollback never runs against
|
||||
anything — the data is already durable. TRUNCATE before each test
|
||||
instead, which the append-only trigger does not block: it is a
|
||||
row-level BEFORE UPDATE OR DELETE trigger, and TRUNCATE fires neither.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
await book_session.execute(
|
||||
text("TRUNCATE book_events, book_attachments, book_record_links, book_tags, book_records")
|
||||
)
|
||||
await book_session.commit()
|
||||
yield book_session
|
||||
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Tests for the Book domain (T-1 / D-68, D-70) against `core_api_test`.
|
||||
|
||||
`get_async_session` is overridden to the test database for every test here
|
||||
— the Book routes list `caller` before `session` in their dependencies, but
|
||||
overriding is what actually guarantees the suite never opens a connection
|
||||
to production regardless of FastAPI's dependency resolution order.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.domains.auth.oidc import oidc_config
|
||||
from src.main import app
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.database import get_async_session
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
OFFICE_KEY = "test-office-key"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def office_key():
|
||||
"""Every Book test authenticates as the office caller unless it says otherwise."""
|
||||
original = settings.office_service_key
|
||||
settings.office_service_key = OFFICE_KEY
|
||||
yield
|
||||
settings.office_service_key = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(clean_book_tables, test_database_url):
|
||||
"""
|
||||
A TestClient whose DB dependency is overridden to core_api_test.
|
||||
|
||||
The override must build its own engine/session per call rather than
|
||||
reuse `clean_book_tables`'s already-open one: TestClient drives the
|
||||
ASGI app from a different event loop than the one pytest-asyncio ran
|
||||
the fixture's setup in, and an asyncpg connection is not portable
|
||||
across loops — reusing it here raised "Future ... attached to a
|
||||
different loop" and then hung. Building fresh per call keeps
|
||||
everything inside whichever loop is actually running the request.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
async def _override():
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
app.dependency_overrides[get_async_session] = _override
|
||||
yield TestClient(app)
|
||||
app.dependency_overrides.pop(get_async_session, None)
|
||||
|
||||
|
||||
def office_headers(identity: str = "jeroen", client_name: str | None = None) -> dict:
|
||||
headers = {"Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Identity": identity}
|
||||
if client_name:
|
||||
headers["X-Office-Client"] = client_name
|
||||
return headers
|
||||
|
||||
|
||||
class TestCallerMatrixOnBookRoutes:
|
||||
"""Dossier verification #3, the Book-specific half."""
|
||||
|
||||
def test_no_auth_is_401_on_book_routes(self, client):
|
||||
oidc_config.configure(enabled=True, issuers=[], audiences=[])
|
||||
try:
|
||||
response = client.post("/book/records", json={"body": "hi"})
|
||||
finally:
|
||||
oidc_config.configure(enabled=False, issuers=[], audiences=[])
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_office_write_without_identity_is_400(self, client):
|
||||
response = client.post(
|
||||
"/book/records",
|
||||
json={"body": "hi"},
|
||||
headers={"Authorization": f"Bearer {OFFICE_KEY}"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "identity_required"
|
||||
|
||||
def test_scheduler_key_is_403_on_book_records(self, client):
|
||||
settings.scheduler_service_key = "test-scheduler-key"
|
||||
try:
|
||||
response = client.post(
|
||||
"/book/records",
|
||||
json={"body": "hi"},
|
||||
headers={"Authorization": "Bearer test-scheduler-key"},
|
||||
)
|
||||
finally:
|
||||
settings.scheduler_service_key = ""
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "forbidden"
|
||||
|
||||
def test_malformed_identity_is_400(self, client):
|
||||
response = client.post(
|
||||
"/book/records",
|
||||
json={"body": "hi"},
|
||||
headers={"Authorization": f"Bearer {OFFICE_KEY}", "X-Office-Identity": "Not Valid"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "identity_invalid"
|
||||
|
||||
|
||||
class TestAddShowListFind:
|
||||
def test_add_note_defaults_to_personal_scope_and_inbox_free(self, client):
|
||||
response = client.post(
|
||||
"/book/records", json={"body": "buy milk"}, headers=office_headers()
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["kind"] == "note"
|
||||
assert data["status"] == "active"
|
||||
assert data["scope"] == "personal"
|
||||
assert data["created_by"] == "jeroen"
|
||||
|
||||
def test_add_rejects_bad_status_for_kind(self, client):
|
||||
response = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "x", "status": "settled"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "invalid_status"
|
||||
|
||||
def test_every_record_carries_exactly_one_of_work_or_personal(self, client):
|
||||
default_scope = client.post(
|
||||
"/book/records", json={"body": "default scope"}, headers=office_headers()
|
||||
).json()["scope"]
|
||||
assert default_scope in ("work", "personal")
|
||||
|
||||
work_scope = client.post(
|
||||
"/book/records",
|
||||
json={"body": "work item", "scope": "work"},
|
||||
headers=office_headers(),
|
||||
).json()["scope"]
|
||||
assert work_scope == "work"
|
||||
|
||||
rejected = client.post(
|
||||
"/book/records",
|
||||
json={"body": "bad scope", "scope": "both"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert rejected.status_code == 422 # pydantic Literal rejects a third value
|
||||
|
||||
def test_show_returns_record_tags_links_and_backlinks(self, client):
|
||||
parent = client.post(
|
||||
"/book/records", json={"kind": "task", "body": "project"}, headers=office_headers()
|
||||
).json()
|
||||
child = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "subtask", "parent": parent["id"], "tags": ["chore"]},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
|
||||
child_shown = client.get(f"/book/records/{child['id']}", headers=office_headers()).json()
|
||||
assert child_shown["tags"] == ["chore"]
|
||||
assert len(child_shown["links"]) == 1
|
||||
assert child_shown["links"][0]["relation"] == "parent"
|
||||
assert child_shown["links"][0]["to_id"] == parent["id"]
|
||||
|
||||
parent_shown = client.get(f"/book/records/{parent['id']}", headers=office_headers()).json()
|
||||
assert len(parent_shown["backlinks"]) == 1
|
||||
assert parent_shown["backlinks"][0]["from_id"] == child["id"]
|
||||
|
||||
def test_show_missing_record_is_404(self, client):
|
||||
response = client.get(f"/book/records/{uuid.uuid4()}", headers=office_headers())
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
def test_list_filters_by_scope(self, client):
|
||||
client.post("/book/records", json={"body": "a", "scope": "work"}, headers=office_headers())
|
||||
client.post("/book/records", json={"body": "b", "scope": "personal"}, headers=office_headers())
|
||||
|
||||
work_only = client.get("/book/records", params={"scope": "work"}, headers=office_headers()).json()
|
||||
assert work_only["total"] == 1
|
||||
assert work_only["records"][0]["scope"] == "work"
|
||||
|
||||
def test_find_full_text_matches_title_and_body(self, client):
|
||||
client.post(
|
||||
"/book/records",
|
||||
json={"kind": "note", "title": "Weekend plans", "body": "visit the garden centre"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "renew the car insurance"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
|
||||
found = client.get("/book/records/find", params={"q": "garden"}, headers=office_headers()).json()
|
||||
assert found["total"] == 1
|
||||
assert "garden" in found["records"][0]["body"]
|
||||
|
||||
found_title = client.get("/book/records/find", params={"q": "weekend"}, headers=office_headers()).json()
|
||||
assert found_title["total"] == 1
|
||||
|
||||
|
||||
class TestChange:
|
||||
def test_change_updates_only_given_fields(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"kind": "task", "body": "water the plants"}, headers=office_headers()
|
||||
).json()
|
||||
|
||||
changed = client.patch(
|
||||
f"/book/records/{record['id']}",
|
||||
json={"priority": 1},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
assert changed["priority"] == 1
|
||||
assert changed["body"] == "water the plants"
|
||||
|
||||
def test_change_without_identity_is_400(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"body": "x"}, headers=office_headers()
|
||||
).json()
|
||||
response = client.patch(
|
||||
f"/book/records/{record['id']}",
|
||||
json={"priority": 1},
|
||||
headers={"Authorization": f"Bearer {OFFICE_KEY}"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "identity_required"
|
||||
|
||||
|
||||
class TestClose:
|
||||
def test_close_task_done(self, client):
|
||||
task = client.post(
|
||||
"/book/records", json={"kind": "task", "body": "mow the lawn"}, headers=office_headers()
|
||||
).json()
|
||||
closed = client.post(
|
||||
f"/book/records/{task['id']}/close", json={"outcome": "done"}, headers=office_headers()
|
||||
).json()
|
||||
assert closed["status"] == "done"
|
||||
|
||||
def test_close_question_via_resolves_sets_settled(self, client):
|
||||
question = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "question", "body": "what colour for the fence?"},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
decision = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "decision", "body": "fence will be green"},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
|
||||
closed = client.post(
|
||||
f"/book/records/{question['id']}/close",
|
||||
json={"resolved_by": decision["id"]},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
assert closed["status"] == "settled"
|
||||
|
||||
shown = client.get(f"/book/records/{question['id']}", headers=office_headers()).json()
|
||||
assert any(b["relation"] == "resolves" and b["from_id"] == decision["id"] for b in shown["backlinks"])
|
||||
|
||||
def test_close_decision_via_supersedes_sets_superseded(self, client):
|
||||
old_decision = client.post(
|
||||
"/book/records", json={"kind": "decision", "body": "fence will be green"}, headers=office_headers()
|
||||
).json()
|
||||
new_decision = client.post(
|
||||
"/book/records", json={"kind": "decision", "body": "fence will be red"}, headers=office_headers()
|
||||
).json()
|
||||
|
||||
closed = client.post(
|
||||
f"/book/records/{old_decision['id']}/close",
|
||||
json={"superseded_by": new_decision["id"]},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
assert closed["status"] == "superseded"
|
||||
|
||||
def test_close_note_has_no_transition(self, client):
|
||||
note = client.post("/book/records", json={"body": "just a note"}, headers=office_headers()).json()
|
||||
response = client.post(
|
||||
f"/book/records/{note['id']}/close", json={}, headers=office_headers()
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "invalid_status"
|
||||
|
||||
|
||||
class TestLinkUnlink:
|
||||
def test_link_then_unlink(self, client):
|
||||
a = client.post("/book/records", json={"body": "a"}, headers=office_headers()).json()
|
||||
b = client.post("/book/records", json={"body": "b"}, headers=office_headers()).json()
|
||||
|
||||
client.post(
|
||||
f"/book/records/{a['id']}/link",
|
||||
json={"to_id": b["id"], "relation": "related"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
shown = client.get(f"/book/records/{a['id']}", headers=office_headers()).json()
|
||||
assert len(shown["links"]) == 1
|
||||
|
||||
unlinked = client.post(
|
||||
f"/book/records/{a['id']}/unlink",
|
||||
json={"to_id": b["id"], "relation": "related"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert unlinked.status_code == 200
|
||||
shown_after = client.get(f"/book/records/{a['id']}", headers=office_headers()).json()
|
||||
assert len(shown_after["links"]) == 0
|
||||
|
||||
def test_unlink_nonexistent_link_is_404(self, client):
|
||||
a = client.post("/book/records", json={"body": "a"}, headers=office_headers()).json()
|
||||
b = client.post("/book/records", json={"body": "b"}, headers=office_headers()).json()
|
||||
response = client.post(
|
||||
f"/book/records/{a['id']}/unlink",
|
||||
json={"to_id": b["id"], "relation": "blocks"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestTagUntag:
|
||||
def test_tag_then_untag(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
|
||||
tagged = client.post(
|
||||
f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers()
|
||||
).json()
|
||||
assert tagged["tags"] == ["chore"]
|
||||
|
||||
untagged = client.post(
|
||||
f"/book/records/{record['id']}/untag", json={"tag": "chore"}, headers=office_headers()
|
||||
).json()
|
||||
assert untagged["tags"] == []
|
||||
|
||||
def test_tagging_twice_is_idempotent(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
client.post(f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers())
|
||||
second = client.post(
|
||||
f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers()
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["tags"] == ["chore"]
|
||||
|
||||
|
||||
class TestHistory:
|
||||
def test_every_write_appends_an_event_carrying_identity(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"body": "x"}, headers=office_headers(identity="jeroen")
|
||||
).json()
|
||||
client.patch(
|
||||
f"/book/records/{record['id']}",
|
||||
json={"priority": 2},
|
||||
headers=office_headers(identity="tower-control"),
|
||||
)
|
||||
|
||||
history = client.get(f"/book/records/{record['id']}/history", headers=office_headers()).json()
|
||||
actions = [e["action"] for e in history["events"]]
|
||||
identities = {e["identity"] for e in history["events"]}
|
||||
|
||||
assert "add" in actions
|
||||
assert "change" in actions
|
||||
assert identities == {"jeroen", "tower-control"}
|
||||
|
||||
def test_history_events_are_immutable_at_the_api(self, client):
|
||||
"""There is no update/delete verb for events — only `add` and `change`
|
||||
write them, and the append-only trigger (tested in
|
||||
test_book_migration.py) is what actually enforces it."""
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
history = client.get(f"/book/records/{record['id']}/history", headers=office_headers()).json()
|
||||
assert len(history["events"]) == 1
|
||||
assert history["events"][0]["action"] == "add"
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user