test(persistence): alembic 0001 round-trip on SQLite + Postgres
Verifies alembic upgrade 0001 adds users.default_workspace_id (with FK to workspaces) and that downgrade cleanly removes it. Runs on both dialects because the migration uses op.batch_alter_table for SQLite ALTER compatibility. Tests are intentionally sync — alembic's command layer is sync and env.py calls asyncio.run(); running under pytest-anyio would deadlock on the inner event loop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""Alembic revision 0001 round-trips on both Postgres and SQLite.
|
||||
|
||||
Verifies the first DeerFlow migration adds `users.default_workspace_id`
|
||||
(with FK to `workspaces`) on upgrade and removes it on downgrade. Both
|
||||
backends are exercised because the migration relies on
|
||||
`op.batch_alter_table` for SQLite ALTER compatibility — we want to know
|
||||
if either dialect regresses.
|
||||
|
||||
These tests are synchronous: alembic's command layer is sync, and our
|
||||
`env.py` calls `asyncio.run(...)` internally. Running under
|
||||
pytest-anyio would put us inside an event loop and crash that
|
||||
`asyncio.run` call, so we keep the test bodies plain `def`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
_ALEMBIC_INI = Path(__file__).resolve().parents[1] / "packages" / "harness" / "deerflow" / "persistence" / "migrations" / "alembic.ini"
|
||||
|
||||
|
||||
def _make_alembic_config(url: str) -> Config:
|
||||
cfg = Config(str(_ALEMBIC_INI))
|
||||
cfg.set_main_option("sqlalchemy.url", url)
|
||||
return cfg
|
||||
|
||||
|
||||
def _bootstrap_pre_pr4_schema(sync_url: str) -> None:
|
||||
"""Create the minimal pre-PR4 schema the migration needs to ALTER.
|
||||
|
||||
Only `users` (without `default_workspace_id`) and `workspaces` (just `id`)
|
||||
are required so the FK target resolves. The rest of the production
|
||||
schema is irrelevant to this migration.
|
||||
"""
|
||||
engine = create_engine(sync_url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE workspaces (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE users (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _assert_column_present(sync_url: str) -> None:
|
||||
engine = create_engine(sync_url)
|
||||
insp = inspect(engine)
|
||||
cols = {c["name"] for c in insp.get_columns("users")}
|
||||
assert "default_workspace_id" in cols, f"column missing; got {cols}"
|
||||
fks = insp.get_foreign_keys("users")
|
||||
fk_to_ws = [fk for fk in fks if fk.get("referred_table") == "workspaces"]
|
||||
assert fk_to_ws, f"FK to workspaces missing; got {fks}"
|
||||
assert fk_to_ws[0]["constrained_columns"] == ["default_workspace_id"]
|
||||
assert fk_to_ws[0]["referred_columns"] == ["id"]
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _assert_column_absent(sync_url: str) -> None:
|
||||
engine = create_engine(sync_url)
|
||||
insp = inspect(engine)
|
||||
cols = {c["name"] for c in insp.get_columns("users")}
|
||||
assert "default_workspace_id" not in cols, f"column still present; got {cols}"
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------- SQLite tests ----------------------------------------------------
|
||||
|
||||
|
||||
def test_sqlite_upgrade_adds_default_workspace_id_with_fk() -> None:
|
||||
"""SQLite: upgrade 0001 adds column + FK; batch_alter_table works."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "test.db"
|
||||
sync_url = f"sqlite:///{db_path}"
|
||||
async_url = f"sqlite+aiosqlite:///{db_path}"
|
||||
|
||||
_bootstrap_pre_pr4_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(async_url)
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
_assert_column_present(sync_url)
|
||||
|
||||
|
||||
def test_sqlite_downgrade_removes_default_workspace_id() -> None:
|
||||
"""SQLite: downgrade 0001 removes the column it added."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = Path(tmp) / "test.db"
|
||||
sync_url = f"sqlite:///{db_path}"
|
||||
async_url = f"sqlite+aiosqlite:///{db_path}"
|
||||
|
||||
_bootstrap_pre_pr4_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(async_url)
|
||||
command.upgrade(cfg, "head")
|
||||
_assert_column_present(sync_url)
|
||||
|
||||
command.downgrade(cfg, "-1")
|
||||
_assert_column_absent(sync_url)
|
||||
|
||||
|
||||
# ---------- Postgres tests --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_postgres_upgrade_adds_default_workspace_id_with_fk(postgres_url: str) -> None:
|
||||
"""Postgres: upgrade 0001 adds column + FK pointing at workspaces(id)."""
|
||||
sync_url = postgres_url.replace("+asyncpg", "+psycopg")
|
||||
_bootstrap_pre_pr4_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(postgres_url)
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
_assert_column_present(sync_url)
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_postgres_downgrade_removes_default_workspace_id(postgres_url: str) -> None:
|
||||
"""Postgres: downgrade 0001 cleanly drops the FK and column."""
|
||||
sync_url = postgres_url.replace("+asyncpg", "+psycopg")
|
||||
_bootstrap_pre_pr4_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(postgres_url)
|
||||
command.upgrade(cfg, "head")
|
||||
_assert_column_present(sync_url)
|
||||
|
||||
command.downgrade(cfg, "-1")
|
||||
_assert_column_absent(sync_url)
|
||||
Reference in New Issue
Block a user