feat: add checksummed database migrations
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""PostgreSQL-backed integration tests."""
|
||||
@@ -0,0 +1,36 @@
|
||||
"""PostgreSQL migration acceptance checks."""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
import pytest
|
||||
|
||||
from app.db.migrations import migrate
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(not os.getenv("TEST_DATABASE_URL"), reason="TEST_DATABASE_URL is required"),
|
||||
]
|
||||
|
||||
|
||||
async def test_migration_is_repeatable_and_constraints_hold() -> None:
|
||||
connection = await asyncpg.connect(os.environ["TEST_DATABASE_URL"])
|
||||
try:
|
||||
await migrate(connection)
|
||||
assert await migrate(connection) == 0
|
||||
node_id = uuid.uuid4()
|
||||
await connection.execute(
|
||||
"INSERT INTO nodes(id, name, status) VALUES($1, $2, 'online') ON CONFLICT DO NOTHING",
|
||||
node_id,
|
||||
f"test-{node_id}",
|
||||
)
|
||||
with pytest.raises(asyncpg.CheckViolationError):
|
||||
async with connection.transaction():
|
||||
await connection.execute(
|
||||
"INSERT INTO nodes(id, name, status) VALUES($1, $2, 'invalid')",
|
||||
uuid.uuid4(),
|
||||
f"invalid-{node_id}",
|
||||
)
|
||||
finally:
|
||||
await connection.close()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Database primitive behavior independent of PostgreSQL."""
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
import pytest
|
||||
|
||||
from app.db.primitives import (
|
||||
ConflictError,
|
||||
DatabaseOperationError,
|
||||
ReferenceError,
|
||||
RetryPolicy,
|
||||
TransientDatabaseError,
|
||||
map_database_error,
|
||||
require_affected,
|
||||
retry_transient,
|
||||
)
|
||||
|
||||
|
||||
def test_error_mapping_is_stable_and_safe() -> None:
|
||||
assert isinstance(map_database_error(asyncpg.UniqueViolationError("secret")), ConflictError)
|
||||
assert isinstance(
|
||||
map_database_error(asyncpg.ForeignKeyViolationError("secret")), ReferenceError
|
||||
)
|
||||
assert isinstance(
|
||||
map_database_error(asyncpg.SerializationError("secret")), TransientDatabaseError
|
||||
)
|
||||
assert "secret" not in str(map_database_error(asyncpg.PostgresError("secret")))
|
||||
|
||||
|
||||
def test_affected_row_checks() -> None:
|
||||
require_affected("UPDATE 1")
|
||||
with pytest.raises(DatabaseOperationError, match="expected 1"):
|
||||
require_affected("UPDATE 0")
|
||||
with pytest.raises(DatabaseOperationError, match="unrecognized"):
|
||||
require_affected("BROKEN")
|
||||
|
||||
|
||||
async def test_transient_retry_is_bounded() -> None:
|
||||
calls = 0
|
||||
|
||||
async def operation() -> str:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls < 3:
|
||||
raise TransientDatabaseError("retry")
|
||||
return "ok"
|
||||
|
||||
assert await retry_transient(operation, RetryPolicy(attempts=3, base_delay_seconds=0)) == "ok"
|
||||
assert calls == 3
|
||||
|
||||
|
||||
async def test_transient_retry_propagates_final_failure() -> None:
|
||||
async def operation() -> None:
|
||||
raise TransientDatabaseError("retry")
|
||||
|
||||
with pytest.raises(TransientDatabaseError):
|
||||
await retry_transient(operation, RetryPolicy(attempts=2, base_delay_seconds=0))
|
||||
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
await retry_transient(operation, RetryPolicy(attempts=0))
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Migration discovery and checksum tests."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.db.migrations import load_migrations
|
||||
|
||||
|
||||
def test_load_migrations_is_ordered_and_checksummed(tmp_path: Path) -> None:
|
||||
(tmp_path / "002_second.sql").write_text("SELECT 2;")
|
||||
(tmp_path / "001_first.sql").write_text("SELECT 1;")
|
||||
|
||||
migrations = load_migrations(tmp_path)
|
||||
|
||||
assert [migration.version for migration in migrations] == [1, 2]
|
||||
assert migrations[0].name == "001_first"
|
||||
assert len(migrations[0].checksum) == 64
|
||||
|
||||
|
||||
def test_repository_migration_defines_required_planes() -> None:
|
||||
migration = load_migrations()[0]
|
||||
|
||||
for table in (
|
||||
"contract_snapshots",
|
||||
"nodes",
|
||||
"resources",
|
||||
"principals",
|
||||
"acl_entries",
|
||||
"tasks",
|
||||
"scenarios",
|
||||
"audit_events",
|
||||
):
|
||||
assert f"CREATE TABLE {table}" in migration.sql
|
||||
Reference in New Issue
Block a user