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:
1445043649
2026-05-13 09:16:24 +08:00
parent 73d0b7017b
commit 30f2bd0084
+134 -4
View File
@@ -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")