dotb_ai-platform/old_repo/tests/test_migrations.py

766 lines
29 KiB
Python

import asyncio
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
def get_alembic_config(database_url: str) -> Config:
root_dir = Path(__file__).resolve().parent.parent
ini_path = root_dir / "alembic.ini"
alembic_cfg = Config(str(ini_path))
# Pass via attributes to avoid '%' escaping issues with passwords
alembic_cfg.attributes["sqlalchemy_url"] = database_url
return alembic_cfg
@pytest.mark.asyncio
async def test_alembic_migration_upgrade_and_downgrade_lifecycle(temp_postgres_db: str):
alembic_cfg = get_alembic_config(temp_postgres_db)
engine = create_async_engine(temp_postgres_db)
# 1. Run upgrade to head
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
# 2. Verify alembic_version has the current head and tables exist
async with engine.connect() as conn:
result = await conn.execute(text("SELECT version_num FROM alembic_version;"))
row = result.fetchone()
assert row is not None
assert row[0] == "0010_dotb_headers"
table_check = await conn.execute(
text(
"SELECT EXISTS ("
" SELECT FROM information_schema.tables "
" WHERE table_schema = 'public' AND table_name = 'scoring_runs'"
");"
)
)
assert table_check.scalar() is True
audit_table_check = await conn.execute(
text(
"SELECT EXISTS ("
" SELECT FROM information_schema.tables "
" WHERE table_schema = 'public' AND table_name = 'review_audits'"
");"
)
)
assert audit_table_check.scalar() is True
# Check new columns exist
cols_result = await conn.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'scoring_runs';"
)
)
cols = {r[0] for r in cols_result.fetchall()}
assert {
"ai_score",
"final_score",
"scoring_mode",
"degraded",
"ai_metadata",
"version",
"effective_score",
"review_decision",
"reviewed_by",
"reviewed_at",
"review_note",
"parent_run_id",
"redacted_at",
"execution_trace",
"dotb_app",
"dotb_feature",
}.issubset(cols)
audit_cols_result = await conn.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'review_audits';"
)
)
audit_cols = {r[0] for r in audit_cols_result.fetchall()}
assert {
"child_run_id",
"effective_score",
"redacted_at",
}.issubset(audit_cols)
outbox_cols_result = await conn.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'outbox_events';"
)
)
outbox_cols = {r[0] for r in outbox_cols_result.fetchall()}
assert {
"delivery_status",
"attempt_count",
"next_attempt_at",
"last_attempt_at",
"delivered_at",
"lease_expires_at",
"claim_token",
"last_error_code",
"last_error_detail",
"last_http_status",
}.issubset(outbox_cols)
# 3. Run downgrade to base
await asyncio.to_thread(command.downgrade, alembic_cfg, "base")
# 4. Verify alembic_version is empty and scoring_runs table is dropped
async with engine.connect() as conn:
result_after_downgrade = await conn.execute(
text("SELECT version_num FROM alembic_version;")
)
row_after = result_after_downgrade.fetchone()
assert row_after is None
table_check_after = await conn.execute(
text(
"SELECT EXISTS ("
" SELECT FROM information_schema.tables "
" WHERE table_schema = 'public' AND table_name = 'scoring_runs'"
");"
)
)
assert table_check_after.scalar() is False
await engine.dispose()
@pytest.mark.asyncio
async def test_alembic_migration_repeated_upgrade_idempotence(temp_postgres_db: str):
alembic_cfg = get_alembic_config(temp_postgres_db)
engine = create_async_engine(temp_postgres_db)
# Initial upgrade
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
# Second upgrade to head should be a no-op and succeed cleanly
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
async with engine.connect() as conn:
result = await conn.execute(text("SELECT version_num FROM alembic_version;"))
row = result.fetchone()
assert row is not None
assert row[0] == "0010_dotb_headers"
# Downgrade back to base to keep disposable container clean
await asyncio.to_thread(command.downgrade, alembic_cfg, "base")
await engine.dispose()
@pytest.mark.asyncio
async def test_alembic_migration_downgrade_with_populated_review_and_outbox_data(
temp_postgres_db: str,
):
alembic_cfg = get_alembic_config(temp_postgres_db)
engine = create_async_engine(temp_postgres_db)
# 1. Upgrade to head (0005)
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
run_id = "00000000-0000-0000-0000-000000000001"
terminal_event_id = "00000000-0000-0000-0000-000000000002"
rev1_event_id = "00000000-0000-0000-0000-000000000003"
rev2_event_id = "00000000-0000-0000-0000-000000000004"
audit1_id = "00000000-0000-0000-0000-000000000005"
audit2_id = "00000000-0000-0000-0000-000000000006"
# 2. Insert populated data representing multiple reviews and terminal outbox event
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO scoring_runs ("
" id, tenant_id, subject_ref, scoring_config_version, idempotency_key, "
" payload_hash, status, snapshot_data, version, rule_score, "
" ai_score, final_score, effective_score, scoring_mode, "
" degraded, review_decision, reviewed_by, reviewed_at, review_note, "
" created_at, updated_at"
") VALUES ("
" :id, 'tenant_1', 'lead_1', '1.0.0', 'run-idem-1', "
" 'hash-1', 'completed', '{}'::jsonb, 3, 80, "
" NULL, 80, 95, 'rules_only', "
" false, 'override', 'admin_user', now(), 'Second override note', "
" now(), now()"
")"
),
{"id": run_id},
)
# Terminal event
await conn.execute(
text(
"INSERT INTO outbox_events ("
" id, event_type, event_version, run_id, payload, occurred_at"
") VALUES ("
" :id, 'lead_scoring.run.terminal', '1.0.0', :run_id, "
" '{}'::jsonb, now()"
")"
),
{"id": terminal_event_id, "run_id": run_id},
)
# Two review events for the same run_id (would violate unique without partial index)
await conn.execute(
text(
"INSERT INTO outbox_events ("
" id, event_type, event_version, run_id, payload, occurred_at"
") VALUES ("
" :id, 'lead_scoring.run.reviewed', '1.0.0', :run_id, "
" '{\"version\": 2}'::jsonb, now()"
")"
),
{"id": rev1_event_id, "run_id": run_id},
)
await conn.execute(
text(
"INSERT INTO outbox_events ("
" id, event_type, event_version, run_id, payload, occurred_at"
") VALUES ("
" :id, 'lead_scoring.run.reviewed', '1.0.0', :run_id, "
" '{\"version\": 3}'::jsonb, now()"
")"
),
{"id": rev2_event_id, "run_id": run_id},
)
# Review audits
await conn.execute(
text(
"INSERT INTO review_audits ("
" id, run_id, tenant_id, decision, actor_id, actor_role, idempotency_key, "
" payload_hash, previous_version, new_version, calculated_score, effective_score, "
" note, created_at"
") VALUES ("
" :id, :run_id, 'tenant_1', 'override', 'admin_user', 'administrator', 'idem-1', "
" 'hash1', 1, 2, 80, 90, 'first note', now()"
")"
),
{"id": audit1_id, "run_id": run_id},
)
await conn.execute(
text(
"INSERT INTO review_audits ("
" id, run_id, tenant_id, decision, actor_id, actor_role, idempotency_key, "
" payload_hash, previous_version, new_version, calculated_score, effective_score, "
" note, created_at"
") VALUES ("
" :id, :run_id, 'tenant_1', 'override', 'admin_user', 'administrator', 'idem-2', "
" 'hash2', 2, 3, 80, 95, 'second note', now()"
")"
),
{"id": audit2_id, "run_id": run_id},
)
# 3. Downgrade to 0004_medium_ai_pipeline (prior revision)
await asyncio.to_thread(command.downgrade, alembic_cfg, "0004_medium_ai_pipeline")
# 4. Verify post-downgrade state
async with engine.connect() as conn:
res = await conn.execute(text("SELECT version_num FROM alembic_version;"))
assert res.scalar() == "0004_medium_ai_pipeline"
# review_audits table should be gone
audit_check = await conn.execute(
text(
"SELECT EXISTS ("
" SELECT FROM information_schema.tables "
" WHERE table_schema = 'public' AND table_name = 'review_audits'"
");"
)
)
assert audit_check.scalar() is False
# scoring_runs should have kept existing pre-0005 data while columns were dropped
run_res = await conn.execute(
text("SELECT id, status, final_score FROM scoring_runs WHERE id = :id;"),
{"id": run_id},
)
run_row = run_res.fetchone()
assert run_row is not None
assert run_row[0] == run_id
assert run_row[1] == "completed"
assert run_row[2] == 80
# outbox_events should retain terminal event while review events are purged
events_res = await conn.execute(
text("SELECT id, event_type FROM outbox_events WHERE run_id = :id;"), {"id": run_id}
)
events = events_res.fetchall()
assert len(events) == 1
assert events[0][0] == terminal_event_id
assert events[0][1] == "lead_scoring.run.terminal"
# 5. Upgrade back to head to verify round-trip
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
async with engine.connect() as conn:
res = await conn.execute(text("SELECT version_num FROM alembic_version;"))
assert res.scalar() == "0010_dotb_headers"
# Downgrade to base to clean up
await asyncio.to_thread(command.downgrade, alembic_cfg, "base")
await engine.dispose()
@pytest.mark.asyncio
async def test_alembic_migration_0006_downgrade_and_upgrade_with_reevaluate_data(
temp_postgres_db: str,
):
alembic_cfg = get_alembic_config(temp_postgres_db)
engine = create_async_engine(temp_postgres_db)
# 1. Upgrade to head (0006)
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
parent_run_id = "00000000-0000-0000-0000-000000000010"
child_run_id = "00000000-0000-0000-0000-000000000011"
audit_id = "00000000-0000-0000-0000-000000000012"
async with engine.begin() as conn:
# Insert parent run (superseded)
await conn.execute(
text(
"INSERT INTO scoring_runs ("
" id, tenant_id, subject_ref, scoring_config_version, idempotency_key, "
" payload_hash, status, snapshot_data, version, rule_score, "
" ai_score, final_score, effective_score, scoring_mode, "
" degraded, review_decision, reviewed_by, reviewed_at, review_note, "
" parent_run_id, created_at, updated_at"
") VALUES ("
" :id, 'tenant_1', 'lead_1', '1.0.0', 'parent-idem-1', "
" 'hash-p', 'superseded', '{}'::jsonb, 2, 60, "
" NULL, 60, NULL, 'rules_only', "
" false, 're_evaluate', 'admin_user', now(), 'Re-evaluating with new data', "
" NULL, now(), now()"
")"
),
{"id": parent_run_id},
)
# Insert child run with parent_run_id
await conn.execute(
text(
"INSERT INTO scoring_runs ("
" id, tenant_id, subject_ref, scoring_config_version, idempotency_key, "
" payload_hash, status, snapshot_data, version, rule_score, "
" ai_score, final_score, effective_score, scoring_mode, "
" degraded, review_decision, reviewed_by, reviewed_at, review_note, "
" parent_run_id, created_at, updated_at"
") VALUES ("
" :id, 'tenant_1', 'lead_1', '1.0.0', 'child-idem-1', "
" 'hash-c', 'pending', '{}'::jsonb, 1, NULL, "
" NULL, NULL, NULL, NULL, "
" NULL, NULL, NULL, NULL, NULL, "
" :parent_id, now(), now()"
")"
),
{"id": child_run_id, "parent_id": parent_run_id},
)
# Insert review audit with NULL effective_score and child_run_id
await conn.execute(
text(
"INSERT INTO review_audits ("
" id, run_id, tenant_id, decision, actor_id, actor_role, idempotency_key, "
" payload_hash, previous_version, new_version, "
" calculated_score, effective_score, "
" child_run_id, note, created_at"
") VALUES ("
" :id, :parent_id, 'tenant_1', 're_evaluate', 'admin_user', "
" 'administrator', 'rev-idem-1', "
" 'hash-rev', 1, 2, 60, NULL, "
" :child_id, 'Re-evaluating with new data', now()"
")"
),
{"id": audit_id, "parent_id": parent_run_id, "child_id": child_run_id},
)
# 2. Downgrade to 0005_review_api_and_audit
await asyncio.to_thread(command.downgrade, alembic_cfg, "0005_review_api_and_audit")
async with engine.connect() as conn:
res = await conn.execute(text("SELECT version_num FROM alembic_version;"))
assert res.scalar() == "0005_review_api_and_audit"
# Check that audit effective_score defaulted to 0 on downgrade (not NULL)
audit_res = await conn.execute(
text("SELECT effective_score FROM review_audits WHERE id = :id;"),
{"id": audit_id},
)
assert audit_res.scalar() == 0
# Check that parent_run_id column no longer exists
cols_result = await conn.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'scoring_runs';"
)
)
cols = {r[0] for r in cols_result.fetchall()}
assert "parent_run_id" not in cols
# 3. Upgrade back to head (0009)
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
async with engine.connect() as conn:
res = await conn.execute(text("SELECT version_num FROM alembic_version;"))
assert res.scalar() == "0010_dotb_headers"
# Downgrade to base
await asyncio.to_thread(command.downgrade, alembic_cfg, "base")
await engine.dispose()
@pytest.mark.asyncio
async def test_alembic_migration_0007_downgrade_and_upgrade_with_outbox_data(
temp_postgres_db: str,
):
alembic_cfg = get_alembic_config(temp_postgres_db)
engine = create_async_engine(temp_postgres_db)
# 1. Upgrade to head (0007)
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
run_id = "00000000-0000-0000-0000-000000000020"
event_id = "00000000-0000-0000-0000-000000000021"
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO scoring_runs ("
" id, tenant_id, subject_ref, scoring_config_version, idempotency_key, "
" payload_hash, status, snapshot_data, version, rule_score, "
" created_at, updated_at"
") VALUES ("
" :id, 'tenant_1', 'lead_1', '1.0.0', 'run-idem-20', "
" 'hash-20', 'completed', '{}'::jsonb, 1, 85, "
" now(), now()"
")"
),
{"id": run_id},
)
await conn.execute(
text(
"INSERT INTO outbox_events ("
" id, event_type, event_version, run_id, payload, occurred_at, "
" delivery_status, attempt_count, next_attempt_at, claim_token, "
" last_error_code, last_error_detail, last_http_status"
") VALUES ("
" :id, 'lead_scoring.run.terminal', '1.0.0', :run_id, '{}'::jsonb, now(), "
" 'dead_letter', 5, now(), 'token-20', "
" 'retries_exhausted', 'Max retry limit reached', 500"
")"
),
{"id": event_id, "run_id": run_id},
)
# 2. Downgrade to 0006_reevaluate_child_lineage
await asyncio.to_thread(command.downgrade, alembic_cfg, "0006_reevaluate_child_lineage")
async with engine.connect() as conn:
res = await conn.execute(text("SELECT version_num FROM alembic_version;"))
assert res.scalar() == "0006_reevaluate_child_lineage"
# Check outbox_events retained original core columns and record
outbox_res = await conn.execute(
text("SELECT id, event_type, run_id FROM outbox_events WHERE id = :id;"),
{"id": event_id},
)
row = outbox_res.fetchone()
assert row is not None
assert row[0] == event_id
assert row[1] == "lead_scoring.run.terminal"
assert row[2] == run_id
# Check that 0007 columns are dropped
cols_result = await conn.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'outbox_events';"
)
)
cols = {r[0] for r in cols_result.fetchall()}
assert "delivery_status" not in cols
assert "attempt_count" not in cols
assert "next_attempt_at" not in cols
# 3. Upgrade back to head (0009)
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
async with engine.connect() as conn:
res = await conn.execute(text("SELECT version_num FROM alembic_version;"))
assert res.scalar() == "0010_dotb_headers"
# Check default values populated on upgraded existing row
outbox_res = await conn.execute(
text("SELECT id, delivery_status, attempt_count FROM outbox_events WHERE id = :id;"),
{"id": event_id},
)
row = outbox_res.fetchone()
assert row is not None
assert row[1] == "pending"
assert row[2] == 0
# Downgrade to base
await asyncio.to_thread(command.downgrade, alembic_cfg, "base")
await engine.dispose()
@pytest.mark.asyncio
async def test_alembic_migration_0008_downgrade_and_upgrade_with_retention_data(
temp_postgres_db: str,
):
alembic_cfg = get_alembic_config(temp_postgres_db)
engine = create_async_engine(temp_postgres_db)
# 1. Upgrade to head (0008)
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
run_id = "00000000-0000-0000-0000-000000000030"
audit_id = "00000000-0000-0000-0000-000000000031"
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO scoring_runs ("
" id, tenant_id, subject_ref, scoring_config_version, idempotency_key, "
" payload_hash, status, snapshot_data, version, rule_score, "
" redacted_at, created_at, updated_at"
") VALUES ("
" :id, 'tenant_1', 'lead_1', '1.0.0', 'run-idem-30', "
" 'hash-30', 'completed', '{}'::jsonb, 1, 85, "
" now(), now(), now()"
")"
),
{"id": run_id},
)
await conn.execute(
text(
"INSERT INTO review_audits ("
" id, run_id, tenant_id, decision, actor_id, actor_role, idempotency_key, "
" payload_hash, previous_version, new_version, calculated_score, effective_score, "
" redacted_at, created_at"
") VALUES ("
" :id, :run_id, 'tenant_1', 'approve', 'redacted', 'agent', 'rev-idem-30', "
" 'redacted', 1, 2, 85, 85, now(), now()"
")"
),
{"id": audit_id, "run_id": run_id},
)
# 2. Downgrade to 0007_outbox_delivery_state
await asyncio.to_thread(command.downgrade, alembic_cfg, "0007_outbox_delivery_state")
async with engine.connect() as conn:
res = await conn.execute(text("SELECT version_num FROM alembic_version;"))
assert res.scalar() == "0007_outbox_delivery_state"
# Check that redacted_at columns are dropped from scoring_runs and review_audits
run_cols_result = await conn.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'scoring_runs';"
)
)
run_cols = {r[0] for r in run_cols_result.fetchall()}
assert "redacted_at" not in run_cols
audit_cols_result = await conn.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'review_audits';"
)
)
audit_cols = {r[0] for r in audit_cols_result.fetchall()}
assert "redacted_at" not in audit_cols
# Ensure base records are still preserved
run_res = await conn.execute(
text("SELECT id, status FROM scoring_runs WHERE id = :id;"),
{"id": run_id},
)
assert run_res.fetchone() is not None
# 3. Upgrade back to head (0009)
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
async with engine.connect() as conn:
res = await conn.execute(text("SELECT version_num FROM alembic_version;"))
assert res.scalar() == "0010_dotb_headers"
# Check redacted_at is now NULL for existing rows
run_check = await conn.execute(
text("SELECT id, redacted_at FROM scoring_runs WHERE id = :id;"),
{"id": run_id},
)
row = run_check.fetchone()
assert row is not None
assert row[1] is None
# Downgrade to base
await asyncio.to_thread(command.downgrade, alembic_cfg, "base")
await engine.dispose()
@pytest.mark.asyncio
async def test_alembic_migration_0009_downgrade_and_upgrade_with_execution_trace(
temp_postgres_db: str,
):
alembic_cfg = get_alembic_config(temp_postgres_db)
engine = create_async_engine(temp_postgres_db)
# 1. Upgrade to head (0009)
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
run_id = "00000000-0000-0000-0000-000000000040"
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO scoring_runs ("
" id, tenant_id, subject_ref, scoring_config_version, idempotency_key, "
" payload_hash, status, snapshot_data, version, rule_score, "
" execution_trace, created_at, updated_at"
") VALUES ("
" :id, 'tenant_1', 'lead_1', '1.0.0', 'run-idem-40', "
" 'hash-40', 'completed', '{}'::jsonb, 1, 85, "
' \'{"status": "completed", "calculation": {"rule_score": 85}}\'::jsonb, '
" now(), now()"
")"
),
{"id": run_id},
)
# 2. Downgrade to 0008_retention_redaction_state
await asyncio.to_thread(command.downgrade, alembic_cfg, "0008_retention_redaction_state")
async with engine.connect() as conn:
res = await conn.execute(text("SELECT version_num FROM alembic_version;"))
assert res.scalar() == "0008_retention_redaction_state"
# Check that execution_trace column is dropped
run_cols_result = await conn.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'scoring_runs';"
)
)
run_cols = {r[0] for r in run_cols_result.fetchall()}
assert "execution_trace" not in run_cols
# Ensure base records are still preserved
run_res = await conn.execute(
text("SELECT id, status FROM scoring_runs WHERE id = :id;"),
{"id": run_id},
)
assert run_res.fetchone() is not None
# 3. Upgrade back to head (0010)
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
async with engine.connect() as conn:
res = await conn.execute(text("SELECT version_num FROM alembic_version;"))
assert res.scalar() == "0010_dotb_headers"
# Check execution_trace is now NULL for existing rows
run_check = await conn.execute(
text("SELECT id, execution_trace FROM scoring_runs WHERE id = :id;"),
{"id": run_id},
)
row = run_check.fetchone()
assert row is not None
assert row[1] is None
# Downgrade to base
await asyncio.to_thread(command.downgrade, alembic_cfg, "base")
await engine.dispose()
@pytest.mark.asyncio
async def test_alembic_migration_0010_downgrade_and_upgrade_with_dotb_headers(
temp_postgres_db: str,
):
alembic_cfg = get_alembic_config(temp_postgres_db)
engine = create_async_engine(temp_postgres_db)
# 1. Upgrade to head (0010)
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
run_id = "00000000-0000-0000-0000-000000000050"
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO scoring_runs ("
" id, tenant_id, subject_ref, scoring_config_version, idempotency_key, "
" payload_hash, status, snapshot_data, version, rule_score, "
" dotb_app, dotb_feature, created_at, updated_at"
") VALUES ("
" :id, 'tenant_1', 'lead_1', '1.0.0', 'run-idem-50', "
" 'hash-50', 'completed', '{}'::jsonb, 1, 85, "
" 'dotb_omni', 'lead-scoring', "
" now(), now()"
")"
),
{"id": run_id},
)
# 2. Downgrade to 0009_execution_trace
await asyncio.to_thread(command.downgrade, alembic_cfg, "0009_execution_trace")
async with engine.connect() as conn:
res = await conn.execute(text("SELECT version_num FROM alembic_version;"))
assert res.scalar() == "0009_execution_trace"
# Check that dotb_app and dotb_feature columns are dropped
run_cols_result = await conn.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND table_name = 'scoring_runs';"
)
)
run_cols = {r[0] for r in run_cols_result.fetchall()}
assert "dotb_app" not in run_cols
assert "dotb_feature" not in run_cols
# Ensure base records are still preserved
run_res = await conn.execute(
text("SELECT id, status FROM scoring_runs WHERE id = :id;"),
{"id": run_id},
)
assert run_res.fetchone() is not None
# 3. Upgrade back to head (0010)
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
async with engine.connect() as conn:
res = await conn.execute(text("SELECT version_num FROM alembic_version;"))
assert res.scalar() == "0010_dotb_headers"
# Check columns are restored and NULL for existing row
run_check = await conn.execute(
text("SELECT id, dotb_app, dotb_feature FROM scoring_runs WHERE id = :id;"),
{"id": run_id},
)
row = run_check.fetchone()
assert row is not None
assert row[1] is None
assert row[2] is None
# Downgrade to base
await asyncio.to_thread(command.downgrade, alembic_cfg, "base")
await engine.dispose()