134 lines
5.9 KiB
Python
134 lines
5.9 KiB
Python
# #region Test.Alembic.TaskRecordsUserId [C:3] [TYPE Module] [SEMANTICS test,alembic,migration,task,postgres]
|
|
# @BRIEF Verifies the baseline schema includes task_records.user_id.
|
|
# @RELATION BINDS_TO -> [EXT:Alembic:MigrationChain]
|
|
# @TEST_FIXTURE: migration_revisions -> INLINE_JSON
|
|
# @TEST_EDGE: missing_field -> Baseline schema must expose task_records.user_id.
|
|
# @TEST_EDGE: invalid_type -> Baseline schema is created by Alembic, not ORM startup.
|
|
# @TEST_INVARIANT: task_records_user_id_baseline -> VERIFIED_BY: [Test.Alembic.TestUserIdMigrationAddsColumn]
|
|
# @RATIONALE SQLite and metadata.create_all() cannot expose an unapplied PostgreSQL migration;
|
|
# an isolated Testcontainers database exercises Alembic's actual DDL path.
|
|
# @REJECTED Stamping the revision or applying raw ALTER TABLE was rejected because each can
|
|
# conceal a migration chain failure while falsely marking the schema current.
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import pytest
|
|
|
|
from sqlalchemy import create_engine, inspect, text
|
|
|
|
|
|
# #region Test.Alembic.MigrationDatabaseUrl [C:2] [TYPE Function]
|
|
# @BRIEF Provide a pristine isolated database created via db_factory in the shared PG container.
|
|
# @POST Returns a host-accessible connection URL for an empty database.
|
|
# @RATIONALE Uses db_factory from the shared Postgres container instead of starting
|
|
# a separate PostgreSQL container. This is faster and uses fewer Docker resources.
|
|
# The database is created as a fresh, empty database — no tables exist, no Alembic
|
|
# has been run. This preserves the pristine-schema semantics required for testing
|
|
# Alembic migrations from a clean state.
|
|
# @REJECTED Starting a separate PostgresContainer was rejected — it adds ~5s per test
|
|
# and unnecessary Docker overhead when the shared container is already running.
|
|
@pytest.fixture
|
|
def migration_database_url(db_factory) -> str:
|
|
"""Provide a pristine PostgreSQL database from the shared container via db_factory.
|
|
|
|
Returns a host-accessible Postgres URL. The database is empty (no tables).
|
|
"""
|
|
result = db_factory["create_db"]("_alembic_test")
|
|
return result["host_url"]
|
|
|
|
|
|
# #endregion Test.Alembic.MigrationDatabaseUrl
|
|
|
|
|
|
# #region Test.Alembic.TestUserIdMigrationAddsColumn [C:2] [TYPE Function] [SEMANTICS test,alembic,migration,task,postgres]
|
|
# @BRIEF Upgrades a pristine PostgreSQL schema through the current baseline.
|
|
def test_user_id_migration_adds_column(monkeypatch: pytest.MonkeyPatch, migration_database_url: str) -> None:
|
|
"""Alembic baseline creates task_records.user_id without runtime schema creation."""
|
|
monkeypatch.setenv("DATABASE_URL", migration_database_url)
|
|
_run_alembic_upgrade("head")
|
|
|
|
engine = create_engine(migration_database_url)
|
|
try:
|
|
columns = {column["name"] for column in inspect(engine).get_columns("task_records")}
|
|
assert "user_id" in columns
|
|
|
|
_run_alembic_upgrade("head")
|
|
assert "user_id" in {column["name"] for column in inspect(engine).get_columns("task_records")}
|
|
finally:
|
|
engine.dispose()
|
|
# #endregion Test.Alembic.TestUserIdMigrationAddsColumn
|
|
|
|
|
|
def test_prepare_database_resets_any_legacy_revision(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
migration_database_url: str,
|
|
) -> None:
|
|
"""An explicit reset replaces any old Alembic chain with the current baseline."""
|
|
engine = create_engine(migration_database_url)
|
|
try:
|
|
with engine.begin() as connection:
|
|
connection.execute(text("CREATE TABLE alembic_version (version_num VARCHAR(64) NOT NULL)"))
|
|
connection.execute(
|
|
text("INSERT INTO alembic_version (version_num) VALUES ('y4z5a6b7c8d9')")
|
|
)
|
|
connection.execute(text("CREATE TABLE legacy_probe (id INTEGER PRIMARY KEY)"))
|
|
|
|
monkeypatch.setenv("DATABASE_URL", migration_database_url)
|
|
# Orphaned revisions are reset independently of stale Compose defaults.
|
|
monkeypatch.setenv("RESET_DATABASE_SCHEMA", "false")
|
|
monkeypatch.delenv("RESET_DATABASE_FROM_REVISION", raising=False)
|
|
monkeypatch.delenv("RESET_DATABASE_NAME", raising=False)
|
|
|
|
from src.scripts.prepare_database import prepare_database
|
|
|
|
prepare_database()
|
|
|
|
tables = set(inspect(engine).get_table_names())
|
|
assert "legacy_probe" not in tables
|
|
assert "task_records" in tables
|
|
with engine.connect() as connection:
|
|
revision = connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one()
|
|
assert revision == "0001_baseline"
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_prepare_database_refuses_unversioned_nonempty_schema(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
migration_database_url: str,
|
|
) -> None:
|
|
"""The universal reset must not erase an unrelated unversioned database."""
|
|
engine = create_engine(migration_database_url)
|
|
try:
|
|
with engine.begin() as connection:
|
|
connection.execute(text("CREATE TABLE unrelated_data (id INTEGER PRIMARY KEY)"))
|
|
|
|
monkeypatch.setenv("DATABASE_URL", migration_database_url)
|
|
monkeypatch.setenv("RESET_DATABASE_SCHEMA", "true")
|
|
|
|
from src.scripts.prepare_database import prepare_database
|
|
|
|
with pytest.raises(RuntimeError, match="non-empty but has no Alembic revision"):
|
|
prepare_database()
|
|
assert "unrelated_data" in inspect(engine).get_table_names()
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
# #region _run_alembic_upgrade [C:1] [TYPE Function]
|
|
def _run_alembic_upgrade(revision: str) -> None:
|
|
"""Run a named revision against the DATABASE_URL injected for this test."""
|
|
from alembic.config import Config
|
|
|
|
from alembic import command
|
|
|
|
backend_dir = Path(__file__).parents[2]
|
|
config = Config(str(backend_dir / "alembic.ini"))
|
|
config.set_main_option("script_location", str(backend_dir / "alembic"))
|
|
config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])
|
|
command.upgrade(config, revision)
|
|
# #endregion _run_alembic_upgrade
|
|
|
|
|
|
# #endregion Test.Alembic.TaskRecordsUserId
|