Files
core-api/tests/conftest.py
T
jpmschweitzerandClaude Sonnet 5 ccdbc44d67 feat(scribe): add the Scribe domain — addresses, send, mailbox, history, find, mark, tag (T-5)
The Office (workspace D-66, D-68, D-73) needs a message bus: an address is
a topic named for an identity, owned by a tenant and a consumer. D-68 puts
it here, in core-api's own database, behind its own auth, following
src/domains/book/.

Migration 006 lands the whole schema by hand (down_revision="005"):
scribe_addresses (owner_tenant + name primary key — no surrogate id, since
no route ever references an address except by name), scribe_messages
(retained; a generated tsvector search column + GIN index on subject/body,
and a GIN index on the topics array for history's lookup), and
scribe_mailbox_entries (one row per recipient address per message, unique
on address+message_id). No data: seeding `jeroen` and `tower-control` is a
deploy step through `register`, not this migration.

Routes, every one behind require_caller("office", "user"), tenant from the
caller (T-4):

- register/list addresses — register is idempotent for the same consumer,
  409 address_taken for another consumer in the tenant; list marks which
  addresses the caller's consumer owns.
- send — `from` is always the caller's own identity, stamped server-side,
  never read from the body; 403 identity_not_owned if that identity is not
  a registered address owned by the caller's consumer; 404 unknown_address
  (naming the missing ones) if any recipient does not exist in the tenant;
  writes one mailbox entry per recipient.
- mailbox / find — the caller's own identity's entries (mailbox: unread
  first; find: full text via websearch_to_tsquery), both requiring the
  identity be owned by the caller's consumer.
- history — the retained messages sent to an address, oldest first;
  reading never consumes or marks, and reads scribe_messages directly
  rather than joining through mailbox entries.
- mark / tag / untag — share one PATCH route; an entry outside the
  caller's tenant, or addressed to an identity the caller isn't reading
  as, answers 404 not_found — indistinguishable from a nonexistent entry.

Every query filters on the caller's tenant (D-73): an address or message
in another tenant answers exactly like one that does not exist, so no id
is ever confirmed across that boundary (test_scribe.py::TestTenantIsolation,
including two tests proving the tenant filter is load-bearing even when
the same address *name* legitimately exists, differently owned, in two
tenants — not just when the caller's own identity is unregistered there).

Messaging only this round: groups, subscriptions, the notification matrix
and delivery are explicitly not built (workspace T-176) — the schema
already carries what `topics`/`tags` need for them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 15:18:52 +02:00

164 lines
5.9 KiB
Python

"""
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
@pytest.fixture(scope="session")
def scribe_schema(test_database_url: str) -> str:
"""Ensure migration 006 (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 scribe_session(scribe_schema: str):
"""An AsyncSession bound to core_api_test — same per-test engine pattern
as book_session (see its docstring for why: TestClient drives the app
on a different event loop than pytest-asyncio's fixture setup)."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
asyncpg_url = scribe_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_scribe_tables(scribe_session):
"""Scribe service methods commit internally (same reason as Book's
clean_book_tables). TRUNCATE all three tables together so Postgres
resolves the FK order (mailbox_entries -> messages, messages'
self-referencing reply_to) across the one statement."""
from sqlalchemy import text
await scribe_session.execute(
text("TRUNCATE scribe_mailbox_entries, scribe_messages, scribe_addresses")
)
await scribe_session.commit()
yield scribe_session