005 has never run in production (still at 004), so it is amended in place rather than patched later by a 007: book_records gains owner_tenant (the tatlock tenant, Caller.tenant from T-4) and number, a short outward id allocated per tenant via a new book_tenant_counters row locked FOR UPDATE in the same transaction as the insert — INSERT ... ON CONFLICT DO NOTHING bootstraps the counter row so a tenant's very first allocation doesn't race its own creation. Unique on (owner_tenant, number); composite index on (owner_tenant, kind, status). 006 applies on top unchanged. Every Book route now requires a tenant (BookService._require_tenant, mirroring the Scribe's own pattern) and filters on it: a record in another tenant answers exactly like one that does not exist, in both directions. link and add's parent convenience both route through _get_or_404, so a link can never reach another tenant — which is also what keeps show's links/backlinks free of their own tenant filter, since every link touching a tenant's record is created tenant-safe already. Responses include number; list accepts number= to find one. Verified: core_api_test downgraded to 004 (with the original migration, to match its already-applied pre-amendment schema), amended, upgraded back to head — clean round trip. 508 tests pass (489 baseline + 19 new: 3 migration-shape, 16 tenant/numbering). Mutation checks confirmed and reverted: dropping list's tenant filter, allocating numbers from a global counter, and dropping link's tenant check on its target each fail the test built to catch it. Raw REST against a dev server on :8777 against core_api_test confirms the same behaviour over the wire: two tenants' first records both land on number 1, and a cross-tenant read answers 404 not_found. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
158 lines
6.5 KiB
Python
158 lines
6.5 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()
|
|
|
|
|
|
async def _scalar(url: str, query: str, **params) -> object:
|
|
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(query), params)
|
|
return 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", "book_tenant_counters",
|
|
):
|
|
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")
|
|
assert not await _table_exists(test_database_url, "book_tenant_counters")
|
|
|
|
# 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")
|
|
assert await _table_exists(test_database_url, "book_tenant_counters")
|
|
|
|
|
|
class TestMigration005TenantAndNumber:
|
|
"""core-api T-7: owner_tenant/number amended onto 005 before its first release."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_book_records_owner_tenant_and_number_are_not_null(self, test_database_url):
|
|
run_alembic(test_database_url, "upgrade", "head")
|
|
for column in ("owner_tenant", "number"):
|
|
nullable = await _scalar(
|
|
test_database_url,
|
|
"SELECT is_nullable FROM information_schema.columns "
|
|
"WHERE table_name = 'book_records' AND column_name = :column",
|
|
column=column,
|
|
)
|
|
assert nullable == "NO", f"book_records.{column} must be NOT NULL, got {nullable!r}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_book_records_has_tenant_number_unique_constraint(self, test_database_url):
|
|
run_alembic(test_database_url, "upgrade", "head")
|
|
found = await _scalar(
|
|
test_database_url,
|
|
"SELECT conname FROM pg_constraint WHERE conname = 'uq_book_records_tenant_number'",
|
|
)
|
|
assert found == "uq_book_records_tenant_number"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_book_records_has_tenant_kind_status_index(self, test_database_url):
|
|
run_alembic(test_database_url, "upgrade", "head")
|
|
found = await _scalar(
|
|
test_database_url,
|
|
"SELECT indexname FROM pg_indexes WHERE indexname = 'ix_book_records_tenant_kind_status'",
|
|
)
|
|
assert found == "ix_book_records_tenant_kind_status"
|
|
|
|
|
|
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(
|
|
owner_tenant="jpmschweitzer", number=1,
|
|
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(
|
|
owner_tenant="jpmschweitzer", number=1,
|
|
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()
|