"""Tests for migration 0015: drop the dead ``documents.notes`` legacy column. Covers: - The drop removes ``notes`` from ``documents``. - Other columns, indexes, and row data on the rebuilt table survive. - Idempotency (re-run, and tables that never had the column). - Missing-table guard. - Downgrade re-adds ``notes`` as nullable Text, and a round-trip is stable. (The head-alignment guard moved to the newer 0016 migration's test file, per the convention that it lives in the newest migration's tests.) Note on test setup ================== Migration 0001 runs ``Base.metadata.create_all()`` against the CURRENT model, which (after this PR) no longer has ``notes`` — so a freshly initialised DB never has the column. To pin "0015 is the migration that drops it" we use the same pattern as ``test_migration_0005_resource_document_id.py`` / ``test_migration_0014_*``: hand-build a "legacy" ``documents`` table that still has ``notes``, stamp at ``0014``, and run the upgrade against that. """ import pytest from alembic import command from sqlalchemy import create_engine, inspect, text from local_deep_research.database.alembic_runner import ( get_alembic_config, stamp_database, ) def _run_upgrade_to(engine, revision): config = get_alembic_config(engine) with engine.begin() as conn: config.attributes["connection"] = conn command.upgrade(config, revision) def _run_downgrade_to(engine, revision): config = get_alembic_config(engine) with engine.begin() as conn: config.attributes["connection"] = conn command.downgrade(config, revision) def _columns(engine, table): insp = inspect(engine) if not insp.has_table(table): return set() return {c["name"] for c in insp.get_columns(table)} def _column_info(engine, table): insp = inspect(engine) if not insp.has_table(table): return {} return {c["name"]: c for c in insp.get_columns(table)} def _index_names(engine, table): insp = inspect(engine) if not insp.has_table(table): return set() return {idx["name"] for idx in insp.get_indexes(table) if idx["name"]} @pytest.fixture def legacy_engine(tmp_path): """A DB whose ``documents`` table still has the ``notes`` column. Simulates an installation created before 0015. Stamped at 0014 so 0015's upgrade is a clean delta. Includes a named index and a seeded row so the batch-mode table rebuild can be checked for column/index/data preservation. """ db_path = tmp_path / "legacy_pre_0015.db" engine = create_engine(f"sqlite:///{db_path}") with engine.begin() as conn: conn.execute( text( "CREATE TABLE documents (" "id INTEGER PRIMARY KEY AUTOINCREMENT, " "document_hash VARCHAR(64) NOT NULL, " "title VARCHAR(500), " "favorite BOOLEAN NOT NULL DEFAULT 0, " "tags TEXT, " "notes TEXT, " "created_at TEXT NOT NULL" ")" ) ) conn.execute( text("CREATE INDEX idx_document_hash ON documents (document_hash)") ) conn.execute( text( "INSERT INTO documents " "(document_hash, title, favorite, tags, notes, created_at) " "VALUES ('hash-1', 'Doc One', 1, '[\"a\"]', " "'a stray note', '2026-01-01 00:00:00')" ) ) stamp_database(engine, "0014") yield engine engine.dispose() class TestMigration0015Upgrade: def test_drops_notes_column(self, legacy_engine): engine = legacy_engine assert "notes" in _columns(engine, "documents") _run_upgrade_to(engine, "0015") assert "notes" not in _columns(engine, "documents") def test_preserves_other_columns(self, legacy_engine): engine = legacy_engine _run_upgrade_to(engine, "0015") cols = _columns(engine, "documents") for expected in { "id", "document_hash", "title", "favorite", "tags", "created_at", }: assert expected in cols def test_preserves_index(self, legacy_engine): engine = legacy_engine assert "idx_document_hash" in _index_names(engine, "documents") _run_upgrade_to(engine, "0015") assert "idx_document_hash" in _index_names(engine, "documents") def test_preserves_row_data(self, legacy_engine): engine = legacy_engine _run_upgrade_to(engine, "0015") with engine.begin() as conn: row = conn.execute( text( "SELECT document_hash, title, favorite, tags " "FROM documents WHERE document_hash = 'hash-1'" ) ).fetchone() assert row is not None assert row[0] == "hash-1" assert row[1] == "Doc One" assert row[2] == 1 assert row[3] == '["a"]' def test_upgrade_is_idempotent(self, legacy_engine): engine = legacy_engine _run_upgrade_to(engine, "0015") _run_upgrade_to(engine, "0015") # second run is a no-op, no error assert "notes" not in _columns(engine, "documents") def test_missing_table_does_not_crash(self, tmp_path): """Upgrade is a no-op when ``documents`` doesn't exist yet.""" engine = create_engine(f"sqlite:///{tmp_path}/no_documents.db") stamp_database(engine, "0014") _run_upgrade_to(engine, "0015") # must not raise assert not inspect(engine).has_table("documents") engine.dispose() class TestMigration0015Downgrade: def test_downgrade_readds_notes_nullable(self, legacy_engine): engine = legacy_engine _run_upgrade_to(engine, "0015") assert "notes" not in _columns(engine, "documents") _run_downgrade_to(engine, "0014") cols = _column_info(engine, "documents") assert "notes" in cols assert cols["notes"]["nullable"] is True assert "TEXT" in str(cols["notes"]["type"]).upper() def test_downgrade_then_upgrade_roundtrip(self, legacy_engine): engine = legacy_engine _run_upgrade_to(engine, "0015") _run_downgrade_to(engine, "0014") _run_upgrade_to(engine, "0015") assert "notes" not in _columns(engine, "documents") # Seeded row still present after the round-trip. with engine.begin() as conn: count = conn.execute( text("SELECT COUNT(*) FROM documents") ).scalar() assert count == 1