Files
cleveragents-core/features/steps/db_migration_lifecycle_steps.py
T
CoreRasurae f8233000cc
CI / push-validation (pull_request) Successful in 28s
CI / helm (pull_request) Successful in 36s
CI / build (pull_request) Successful in 4m1s
CI / lint (pull_request) Successful in 4m14s
CI / quality (pull_request) Successful in 4m33s
CI / typecheck (pull_request) Successful in 4m54s
CI / security (pull_request) Successful in 4m55s
CI / unit_tests (pull_request) Successful in 7m48s
CI / e2e_tests (pull_request) Successful in 7m51s
CI / integration_tests (pull_request) Successful in 7m59s
CI / docker (pull_request) Successful in 2m7s
CI / coverage (pull_request) Successful in 15m56s
CI / status-check (pull_request) Successful in 3s
CI / benchmark-publish (push) Waiting to run
CI / benchmark-regression (push) Waiting to run
CI / push-validation (push) Successful in 22s
CI / helm (push) Successful in 27s
CI / build (push) Successful in 3m47s
CI / lint (push) Successful in 3m56s
CI / quality (push) Successful in 4m21s
CI / typecheck (push) Successful in 4m34s
CI / security (push) Successful in 4m44s
CI / e2e_tests (push) Successful in 6m43s
CI / unit_tests (push) Successful in 7m19s
CI / integration_tests (push) Successful in 7m44s
CI / docker (push) Successful in 1m29s
CI / coverage (push) Successful in 15m57s
CI / status-check (push) Successful in 4s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Failing after 1h9m42s
fix(db): add missing link_type, FK constraints, and partial index per spec DDL
Align resource_links, checkpoint_metadata, and decisions schema behavior with the spec DDL. Add SQLite runtime trigger guards for checkpoint foreign-key semantics and migration-focused Behave/Robot checks that orphan checkpoint references are rejected.

ISSUES CLOSED: #922
2026-04-21 16:53:08 +00:00

346 lines
12 KiB
Python

"""Step definitions for db_migration_lifecycle.feature — core lifecycle.
Exercises the full Alembic migration lifecycle: forward application,
rollback, round-trip testing, CLI wrappers, stamp logic, and the
init_database function.
"""
from __future__ import annotations
import os
import tempfile
from typing import Any
from unittest.mock import patch
from behave import given, then, when
from sqlalchemy import inspect as sa_inspect
def _create_temp_db_path() -> str:
"""Create a temporary file path for a SQLite database.
Returns the path as a string; the caller is responsible for cleanup.
"""
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
return path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Tables that are created by the Alembic migration chain.
# NOTE: ``llm_traces`` and ``audit_log`` are defined as ORM models but
# do not have dedicated migration scripts yet — they were historically
# created only via ``Base.metadata.create_all()``. They are excluded
# from this set until their migration scripts are added.
_EXPECTED_TABLES = {
"projects",
"plans",
"contexts",
"changes",
"debug_attempts",
"actors",
"actions",
"action_invariants",
"action_arguments",
"v3_plans",
"plan_projects",
"plan_arguments",
"plan_invariants",
"ns_projects",
"project_resource_links",
"resource_types",
"resources",
"resource_edges",
"resource_links",
"tools",
"tool_resource_bindings",
"validation_attachments",
"sessions",
"session_messages",
"automation_profiles",
"skills",
"skill_items",
"locks",
"decisions",
"decision_dependencies",
"checkpoint_metadata",
"changeset_entries",
"tool_invocations",
"async_jobs",
"repo_indexes",
"indexed_files",
}
def _get_table_names(engine: Any) -> set[str]:
inspector = sa_inspect(engine)
return set(inspector.get_table_names())
def _make_engine(url: str = "sqlite:///:memory:") -> Any:
from sqlalchemy import create_engine
return create_engine(url, connect_args={"check_same_thread": False})
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a fresh in-memory database for migration lifecycle testing")
def step_fresh_in_memory_db(context: Any) -> None:
context.db_url = "sqlite:///:memory:"
context.engine = _make_engine(context.db_url)
# Ensure the migration runner module's engine cache is clean for this test
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
MEMORY_ENGINES.pop(context.db_url, None)
# ---------------------------------------------------------------------------
# Given
# ---------------------------------------------------------------------------
@given("all migrations have been applied")
def step_all_migrations_applied(context: Any) -> None:
from cleveragents.infrastructure.database.migration_runner import MigrationRunner
runner = MigrationRunner(context.db_url)
runner.run_migrations(engine=context.engine)
context.runner = runner
@given("a database with legacy tables but no alembic_version")
def step_legacy_db_no_alembic(context: Any) -> None:
from cleveragents.infrastructure.database.models import Base
# Create all tables via create_all (simulates pre-Alembic state)
Base.metadata.create_all(context.engine)
# Verify alembic_version does NOT exist yet
tables = _get_table_names(context.engine)
assert "alembic_version" not in tables
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when("I run all migrations forward to head")
def step_run_migrations_forward(context: Any) -> None:
from cleveragents.infrastructure.database.migration_runner import MigrationRunner
runner = MigrationRunner(context.db_url)
runner.run_migrations(engine=context.engine)
context.runner = runner
@when("I downgrade all migrations to base")
def step_downgrade_to_base(context: Any) -> None:
from alembic import command
runner = context.runner
with context.engine.connect() as conn:
runner.alembic_cfg.attributes["connection"] = conn
try:
command.downgrade(runner.alembic_cfg, "base")
conn.commit()
finally:
runner.alembic_cfg.attributes.pop("connection", None)
@when('I downgrade to the initial migration "{revision}"')
def step_downgrade_to_revision(context: Any, revision: str) -> None:
from alembic import command
runner = context.runner
with context.engine.connect() as conn:
runner.alembic_cfg.attributes["connection"] = conn
try:
command.downgrade(runner.alembic_cfg, revision)
conn.commit()
finally:
runner.alembic_cfg.attributes.pop("connection", None)
@when("I upgrade back to head")
def step_upgrade_to_head(context: Any) -> None:
runner = context.runner
runner.run_migrations(engine=context.engine)
@when("I invoke the db upgrade CLI command")
def step_invoke_db_upgrade(context: Any) -> None:
from cleveragents.cli.main import main
# CLI commands create their own engine, so we must use a file-based
# DB to persist across engine instances.
db_path = _create_temp_db_path()
context.cli_db_path = db_path
cli_url = f"sqlite:///{db_path}"
with patch.dict(os.environ, {"CLEVERAGENTS_DATABASE_URL": cli_url}):
context.cli_exit_code = main(["db", "upgrade"])
# Re-read the DB to verify tables
context.engine = _make_engine(cli_url)
@when("I invoke the db current CLI command")
def step_invoke_db_current(context: Any) -> None:
from cleveragents.cli.main import main
# Use a file-based DB so the CLI sees the migrated schema
db_path = _create_temp_db_path()
cli_url = f"sqlite:///{db_path}"
# First apply migrations so there's something to report
from cleveragents.infrastructure.database.migration_runner import MigrationRunner
MigrationRunner(cli_url).init_or_upgrade()
with patch.dict(os.environ, {"CLEVERAGENTS_DATABASE_URL": cli_url}):
context.cli_exit_code = main(["db", "current"])
@when('I invoke the db downgrade CLI command with revision "{revision}"')
def step_invoke_db_downgrade(context: Any, revision: str) -> None:
from cleveragents.cli.main import main
from cleveragents.infrastructure.database.migration_runner import MigrationRunner
# Use a file-based DB so the CLI sees the migrated schema
db_path = _create_temp_db_path()
cli_url = f"sqlite:///{db_path}"
MigrationRunner(cli_url).init_or_upgrade()
with patch.dict(os.environ, {"CLEVERAGENTS_DATABASE_URL": cli_url}):
context.cli_exit_code = main(["db", "downgrade", revision])
@when("I run init_or_upgrade on the legacy database")
def step_init_or_upgrade_legacy(context: Any) -> None:
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
from cleveragents.infrastructure.database.migration_runner import MigrationRunner
MEMORY_ENGINES[context.db_url] = context.engine
try:
runner = MigrationRunner(context.db_url)
runner.init_or_upgrade()
context.runner = runner
finally:
MEMORY_ENGINES.pop(context.db_url, None)
@when("I call init_database with an in-memory URL")
def step_call_init_database(context: Any) -> None:
from cleveragents.infrastructure.database.models import init_database
# Use a file-based temporary database so the engine can be reopened
# and the tables inspected after init_database returns.
db_path = _create_temp_db_path()
file_url = f"sqlite:///{db_path}"
context.result_engine = init_database(file_url)
# ---------------------------------------------------------------------------
# Then
# ---------------------------------------------------------------------------
@then("the database should have all expected tables")
def step_db_has_expected_tables(context: Any) -> None:
tables = _get_table_names(context.engine)
missing = _EXPECTED_TABLES - tables
assert not missing, f"Missing tables: {missing}"
@then("the current revision should be the head revision")
def step_current_is_head(context: Any) -> None:
from alembic.script import ScriptDirectory
runner = context.runner
head = ScriptDirectory.from_config(runner.alembic_cfg).get_current_head()
from alembic.runtime.migration import MigrationContext
with context.engine.connect() as conn:
current = MigrationContext.configure(conn).get_current_revision()
assert current == head, f"Expected {head}, got {current}"
@then("the current revision should be None")
def step_current_is_none(context: Any) -> None:
from alembic.runtime.migration import MigrationContext
with context.engine.connect() as conn:
current = MigrationContext.configure(conn).get_current_revision()
assert current is None, f"Expected None, got {current}"
@then("the database should have no application tables")
def step_db_has_no_app_tables(context: Any) -> None:
tables = _get_table_names(context.engine)
# Only alembic_version may remain after full downgrade
app_tables = tables - {"alembic_version"}
assert not app_tables, f"Unexpected tables remain: {app_tables}"
@then('the current revision should be "{expected}"')
def step_current_revision_is(context: Any, expected: str) -> None:
from alembic.runtime.migration import MigrationContext
with context.engine.connect() as conn:
current = MigrationContext.configure(conn).get_current_revision()
assert current == expected, f"Expected {expected}, got {current}"
@then("the CLI should report a successful upgrade")
def step_cli_upgrade_success(context: Any) -> None:
assert context.cli_exit_code == 0, f"CLI exited with code {context.cli_exit_code}"
@then("the CLI should display the current revision")
def step_cli_displays_revision(context: Any) -> None:
assert context.cli_exit_code == 0, f"CLI exited with code {context.cli_exit_code}"
@then("the CLI should report a successful downgrade")
def step_cli_downgrade_success(context: Any) -> None:
assert context.cli_exit_code == 0, f"CLI exited with code {context.cli_exit_code}"
@then("the database should be stamped with alembic_version")
def step_db_stamped_with_alembic(context: Any) -> None:
tables = _get_table_names(context.engine)
assert "alembic_version" in tables, f"alembic_version not found in {tables}"
@then("the database revision should be at head")
def step_db_revision_at_head(context: Any) -> None:
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
runner = context.runner
head = ScriptDirectory.from_config(runner.alembic_cfg).get_current_head()
with context.engine.connect() as conn:
current = MigrationContext.configure(conn).get_current_revision()
assert current == head, f"Expected head {head}, got {current}"
@then("the resulting engine should have a valid schema")
def step_engine_has_valid_schema(context: Any) -> None:
tables = _get_table_names(context.result_engine)
# At minimum, core tables should exist
assert "actors" in tables or "projects" in tables, f"Got tables: {tables}"
@then("the alembic_version table should exist")
def step_alembic_version_exists(context: Any) -> None:
tables = _get_table_names(context.result_engine)
assert "alembic_version" in tables, f"alembic_version not in {tables}"