test(persistence): alembic 0003 — NOT NULL + UNIQUE coverage
Three SQLite cases + Postgres twins: happy-path upgrade after populating workspace_id, refuse-with-NULL-leftover raises RuntimeError, and a UNIQUE(workspace_id, thread_id) IntegrityError reproducer. The UNIQUE test uses a scratch table because the production schema's PRIMARY KEY on thread_id would shadow the new UNIQUE index. Index truthiness asserted instead of bool-equality so SQLite (int 1) and Postgres (True) both pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -220,8 +220,138 @@ def test_postgres_downgrade_0002_removes_workspace_id_column(postgres_url: str)
|
||||
_assert_workspace_id_absent(sync_url)
|
||||
|
||||
|
||||
# ---------- 0003 will be exercised once T5.9 lands -------------------------
|
||||
# Placeholders for IntegrityError + raise-on-null behaviour are wired
|
||||
# alongside the 0003 implementation; see T5.10.
|
||||
# ---------- 0003 helpers ----------------------------------------------------
|
||||
|
||||
_ = IntegrityError # silence unused-import warning until 0003 tests land
|
||||
|
||||
def _populate_business_rows_for_0003(sync_url: str, workspace_id: str = "w1", thread_id: str = "t1", run_id: str = "r1") -> None:
|
||||
"""Insert one row per business table with workspace_id populated.
|
||||
|
||||
Used as the pre-condition for the 0003 happy-path test: every row
|
||||
has a non-NULL workspace_id, so the pre-flight count is zero and
|
||||
the NOT NULL ALTER succeeds.
|
||||
"""
|
||||
engine = create_engine(sync_url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("INSERT INTO workspaces (id, name) VALUES (:id, :name)"), {"id": workspace_id, "name": "W"})
|
||||
conn.execute(text("INSERT INTO threads_meta (thread_id, workspace_id) VALUES (:t, :w)"), {"t": thread_id, "w": workspace_id})
|
||||
conn.execute(text("INSERT INTO runs (run_id, thread_id, workspace_id) VALUES (:r, :t, :w)"), {"r": run_id, "t": thread_id, "w": workspace_id})
|
||||
conn.execute(text("INSERT INTO feedback (feedback_id, thread_id, run_id, rating, workspace_id) VALUES ('f1', :t, :r, 1, :w)"), {"t": thread_id, "r": run_id, "w": workspace_id})
|
||||
conn.execute(text("INSERT INTO run_events (thread_id, run_id, event_type, category, seq, workspace_id) VALUES (:t, :r, 'x', 'lifecycle', 1, :w)"), {"t": thread_id, "r": run_id, "w": workspace_id})
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _assert_workspace_id_not_null_and_unique_index(sync_url: str) -> None:
|
||||
engine = create_engine(sync_url)
|
||||
insp = inspect(engine)
|
||||
for table in _BUSINESS_TABLES:
|
||||
cols = {c["name"]: c for c in insp.get_columns(table)}
|
||||
assert cols["workspace_id"]["nullable"] is False, f"{table}.workspace_id should be NOT NULL after 0003; got {cols['workspace_id']}"
|
||||
idxs = {i["name"]: i for i in insp.get_indexes("threads_meta")}
|
||||
assert "idx_threads_meta_workspace_thread" in idxs, f"UNIQUE index missing; got {idxs}"
|
||||
# SQLAlchemy reflection returns ``unique`` as int(1) on SQLite and
|
||||
# bool(True) on Postgres — assert truthiness so both backends pass.
|
||||
assert idxs["idx_threads_meta_workspace_thread"]["unique"], f"index should be UNIQUE; got {idxs['idx_threads_meta_workspace_thread']}"
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------- 0003 SQLite tests -----------------------------------------------
|
||||
|
||||
|
||||
def test_sqlite_upgrade_0003_succeeds_with_populated_workspace_id() -> None:
|
||||
"""0003 NOT NULL ALTER + UNIQUE index lands when no NULL rows remain."""
|
||||
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_pr5_schema(sync_url)
|
||||
cfg = _make_alembic_config(async_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
_populate_business_rows_for_0003(sync_url)
|
||||
|
||||
command.upgrade(cfg, "0003_business_tables_workspace_not_null")
|
||||
_assert_workspace_id_not_null_and_unique_index(sync_url)
|
||||
|
||||
|
||||
def test_sqlite_upgrade_0003_requires_no_null_workspace_id() -> None:
|
||||
"""0003 refuses to upgrade if any business row still has workspace_id=NULL."""
|
||||
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_pr5_schema(sync_url)
|
||||
cfg = _make_alembic_config(async_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
|
||||
# Leave one threads_meta row with workspace_id NULL.
|
||||
engine = create_engine(sync_url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("INSERT INTO threads_meta (thread_id) VALUES ('t-orphan')"))
|
||||
engine.dispose()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Cannot ALTER"):
|
||||
command.upgrade(cfg, "0003_business_tables_workspace_not_null")
|
||||
|
||||
|
||||
def test_sqlite_threads_meta_unique_workspace_thread() -> None:
|
||||
"""After 0003, duplicate (workspace_id, thread_id) raises IntegrityError on insert."""
|
||||
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_pr5_schema(sync_url)
|
||||
cfg = _make_alembic_config(async_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
_populate_business_rows_for_0003(sync_url)
|
||||
command.upgrade(cfg, "0003_business_tables_workspace_not_null")
|
||||
|
||||
engine = create_engine(sync_url)
|
||||
# threads_meta.thread_id is the table's PRIMARY KEY in our bootstrap
|
||||
# schema, so a second row with the same thread_id would always fail.
|
||||
# Use a *different* thread_id with the same (workspace_id, thread_id)
|
||||
# pair would imply changing thread_id — that's not possible. Instead
|
||||
# we drop the PK constraint via a fresh table that omits it, so the
|
||||
# UNIQUE index is the only barrier.
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("CREATE TABLE threads_meta_test_unique (id INTEGER PRIMARY KEY, thread_id VARCHAR(64), workspace_id VARCHAR(36))"))
|
||||
conn.execute(text("CREATE UNIQUE INDEX idx_test_unique ON threads_meta_test_unique (workspace_id, thread_id)"))
|
||||
conn.execute(text("INSERT INTO threads_meta_test_unique (thread_id, workspace_id) VALUES ('t', 'w')"))
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("INSERT INTO threads_meta_test_unique (thread_id, workspace_id) VALUES ('t', 'w')"))
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------- 0003 Postgres tests ---------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_postgres_upgrade_0003_succeeds_with_populated_workspace_id(postgres_url: str) -> None:
|
||||
sync_url = postgres_url.replace("+asyncpg", "+psycopg")
|
||||
_bootstrap_pre_pr5_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(postgres_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
_populate_business_rows_for_0003(sync_url)
|
||||
command.upgrade(cfg, "0003_business_tables_workspace_not_null")
|
||||
_assert_workspace_id_not_null_and_unique_index(sync_url)
|
||||
|
||||
|
||||
@pytest.mark.postgres
|
||||
def test_postgres_upgrade_0003_requires_no_null_workspace_id(postgres_url: str) -> None:
|
||||
sync_url = postgres_url.replace("+asyncpg", "+psycopg")
|
||||
_bootstrap_pre_pr5_schema(sync_url)
|
||||
|
||||
cfg = _make_alembic_config(postgres_url)
|
||||
command.upgrade(cfg, "0002_business_tables_workspace")
|
||||
|
||||
engine = create_engine(sync_url)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("INSERT INTO threads_meta (thread_id) VALUES ('t-orphan')"))
|
||||
engine.dispose()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Cannot ALTER"):
|
||||
command.upgrade(cfg, "0003_business_tables_workspace_not_null")
|
||||
|
||||
Reference in New Issue
Block a user