"""v45 → v46 migration: per-user opt-out (dismiss) for curated memory items. Adds ``knowledge_item_user_dismissed`` ((user_id, item_id) PK, dismissed_at) plus an index on ``user_id`` to support the EXISTS subquery used by list_items / search / count_items / bundle. Mandatory items are governance-protected: the API rejects POSTs against them, and the SQL filter exempts ``status = 'mandatory'`` so any stale row from before an item was mandated is silently ignored. """ import duckdb from src.db import SCHEMA_VERSION, _ensure_schema, _v45_to_v46, get_schema_version def test_fresh_install_creates_dismissed_table(tmp_path): """A brand-new DB ends at v46 with the dismiss table + index in place.""" db_path = tmp_path / "system.duckdb" conn = duckdb.connect(str(db_path)) _ensure_schema(conn) assert get_schema_version(conn) == SCHEMA_VERSION tables = { r[0] for r in conn.execute( "SELECT table_name FROM information_schema.tables WHERE table_schema = 'main'" ).fetchall() } assert "knowledge_item_user_dismissed" in tables, ( f"knowledge_item_user_dismissed missing from {tables}" ) cols = { r[0] for r in conn.execute( "SELECT column_name FROM information_schema.columns " "WHERE table_name = 'knowledge_item_user_dismissed'" ).fetchall() } assert {"user_id", "item_id", "dismissed_at"} <= cols, ( f"missing columns on knowledge_item_user_dismissed: {cols}" ) idx_names = { r[0] for r in conn.execute( "SELECT index_name FROM duckdb_indexes " "WHERE table_name = 'knowledge_item_user_dismissed'" ).fetchall() } assert "idx_knowledge_item_user_dismissed_user" in idx_names, ( f"index on user_id missing: {idx_names}" ) conn.close() def test_v45_db_migrates_cleanly_to_v46(tmp_path): """A pre-existing v45 DB (no dismiss table) climbs to v46 without error.""" db_path = tmp_path / "v45.duckdb" conn = duckdb.connect(str(db_path)) # Stand up a minimal v45-shape: schema_version row pinned at 45 plus a # knowledge_items table with one survivor row that must come through # the migration intact. conn.execute( "CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp)" ) conn.execute("INSERT INTO schema_version (version) VALUES (45)") conn.execute( """CREATE TABLE knowledge_items ( id VARCHAR PRIMARY KEY, title VARCHAR NOT NULL, content TEXT, category VARCHAR, status VARCHAR DEFAULT 'pending', created_at TIMESTAMP DEFAULT current_timestamp, updated_at TIMESTAMP )""" ) conn.execute( "INSERT INTO knowledge_items (id, title, content, category, status) " "VALUES ('legacy', 'Legacy', 'still here', 'engineering', 'approved')" ) _ensure_schema(conn) assert get_schema_version(conn) == SCHEMA_VERSION tables = { r[0] for r in conn.execute( "SELECT table_name FROM information_schema.tables WHERE table_schema = 'main'" ).fetchall() } assert "knowledge_item_user_dismissed" in tables # Pre-existing knowledge_items row survived the migration. row = conn.execute( "SELECT id, title, status FROM knowledge_items WHERE id = 'legacy'" ).fetchone() assert row == ("legacy", "Legacy", "approved") # Inserts work, ON CONFLICT path is idempotent. conn.execute( "INSERT INTO knowledge_item_user_dismissed (user_id, item_id) VALUES ('u1', 'legacy')" ) conn.execute( "INSERT INTO knowledge_item_user_dismissed (user_id, item_id) VALUES ('u1', 'legacy') " "ON CONFLICT (user_id, item_id) DO NOTHING" ) cnt = conn.execute( "SELECT COUNT(*) FROM knowledge_item_user_dismissed WHERE user_id = 'u1' AND item_id = 'legacy'" ).fetchone()[0] assert cnt == 1, "primary key + ON CONFLICT DO NOTHING must collapse duplicate inserts" conn.close() def test_v45_to_v46_function_is_idempotent(tmp_path): """Calling ``_v45_to_v46`` twice on the same DB is a no-op the second time.""" db_path = tmp_path / "twice.duckdb" conn = duckdb.connect(str(db_path)) _ensure_schema(conn) # Re-running the migration step directly must not error — CREATE TABLE # IF NOT EXISTS / CREATE INDEX IF NOT EXISTS are idempotent by design. _v45_to_v46(conn) _v45_to_v46(conn) assert get_schema_version(conn) == SCHEMA_VERSION conn.close()