From 30f2bd00849e2f2994a021139c2a911e903fc2f4 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Wed, 13 May 2026 09:16:24 +0800 Subject: [PATCH] =?UTF-8?q?test(persistence):=20alembic=200003=20=E2=80=94?= =?UTF-8?q?=20NOT=20NULL=20+=20UNIQUE=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/tests/test_alembic_business_tables.py | 138 +++++++++++++++++- 1 file changed, 134 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_alembic_business_tables.py b/backend/tests/test_alembic_business_tables.py index 04efaf12..1c351f03 100644 --- a/backend/tests/test_alembic_business_tables.py +++ b/backend/tests/test_alembic_business_tables.py @@ -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")