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:
2026-10-19 11:14:46 +02:00
co-authored by Claude Sonnet 5
parent 3f77cc2926
commit 1166f18b53
11 changed files with 2034 additions and 0 deletions
+120
View File
@@ -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
+373
View File
@@ -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"
+101
View File
@@ -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()