""" Tests for migration 0005: Add document_id column to research_resources. Tests cover: - Column creation with correct type and nullability - Index creation (ix_research_resources_document_id) - Full migration chain from empty database - Idempotency (running migrations multiple times) - Downgrade behavior (column and index removal) - Data preservation during upgrade and downgrade - Edge cases (missing table, pre-existing column, in-memory database) """ from alembic import command import pytest from sqlalchemy import create_engine, inspect, text from local_deep_research.database.alembic_runner import ( get_alembic_config, get_current_revision, get_head_revision, needs_migration, run_migrations, ) def _run_upgrade_to(engine, revision): """Run migrations up to a specific 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): """Run downgrade to a specific revision.""" config = get_alembic_config(engine) with engine.begin() as conn: config.attributes["connection"] = conn command.downgrade(config, revision) def _get_columns(engine, table_name): """Get a dict of {column_name: column_info} for a table.""" insp = inspect(engine) if not insp.has_table(table_name): return {} return {col["name"]: col for col in insp.get_columns(table_name)} def _get_indexes_by_name(engine, table_name): """Get a dict of {index_name: [column_names]} for a table.""" insp = inspect(engine) if not insp.has_table(table_name): return {} return { idx["name"]: idx["column_names"] for idx in insp.get_indexes(table_name) if idx["name"] is not None } @pytest.fixture def fresh_engine(tmp_path): """Create a fresh SQLite engine (empty database, no tables).""" db_path = tmp_path / "fresh_0005_test.db" engine = create_engine(f"sqlite:///{db_path}") yield engine engine.dispose() @pytest.fixture def migrated_to_0004_engine(tmp_path): """Create a database migrated to revision 0004 (before document_id).""" db_path = tmp_path / "migrated_0004_test.db" engine = create_engine(f"sqlite:///{db_path}") _run_upgrade_to(engine, "0004") yield engine engine.dispose() @pytest.fixture def fully_migrated_engine(tmp_path): """Create a database migrated up to revision 0005 (this file's target). Stops at 0005 instead of head — the downgrade tests below would otherwise have to roll back through migration 0010, which is documented as non-reversible (raises NotImplementedError). Every test in this file is scoped to 0005 behaviour. """ db_path = tmp_path / "fully_migrated_0005_test.db" engine = create_engine(f"sqlite:///{db_path}") _run_upgrade_to(engine, "0005") yield engine engine.dispose() class TestMigration0005UpgradeColumn: """Tests that verify document_id column creation on upgrade.""" def test_document_id_column_exists(self, fully_migrated_engine): """document_id column should exist on research_resources after migration.""" columns = _get_columns(fully_migrated_engine, "research_resources") assert "document_id" in columns def test_document_id_column_is_nullable(self, fully_migrated_engine): """document_id should be nullable (existing rows have no value).""" columns = _get_columns(fully_migrated_engine, "research_resources") assert columns["document_id"]["nullable"] is True def test_document_id_column_type_is_varchar(self, fully_migrated_engine): """document_id should be VARCHAR(36) to hold UUIDs.""" columns = _get_columns(fully_migrated_engine, "research_resources") col_type = str(columns["document_id"]["type"]) assert "VARCHAR" in col_type or "CHAR" in col_type def test_document_id_index_exists(self, fully_migrated_engine): """Index ix_research_resources_document_id should exist.""" indexes = _get_indexes_by_name( fully_migrated_engine, "research_resources" ) assert "ix_research_resources_document_id" in indexes assert indexes["ix_research_resources_document_id"] == ["document_id"] def test_document_id_defaults_to_null(self, fully_migrated_engine): """Inserting a row without document_id should default to NULL.""" engine = fully_migrated_engine with engine.begin() as conn: conn.execute( text( "INSERT INTO research_resources " "(research_id, title, url, created_at) " "VALUES ('test-rh-1', 'Test Resource', " "'https://example.com', '2026-01-01')" ) ) with engine.connect() as conn: result = conn.execute( text( "SELECT document_id FROM research_resources " "WHERE research_id = 'test-rh-1'" ) ).fetchone() assert result[0] is None def test_document_id_can_store_uuid(self, fully_migrated_engine): """document_id should accept a UUID string value.""" engine = fully_migrated_engine test_uuid = "abcdef01-2345-6789-abcd-ef0123456789" with engine.begin() as conn: conn.execute( text( "INSERT INTO research_resources " "(research_id, title, url, created_at, document_id) " "VALUES ('test-rh-2', 'Linked Resource', " "'https://example.com', '2026-01-01', :doc_id)" ), {"doc_id": test_uuid}, ) with engine.connect() as conn: result = conn.execute( text( "SELECT document_id FROM research_resources " "WHERE research_id = 'test-rh-2'" ) ).fetchone() assert result[0] == test_uuid class TestMigration0005UpgradeFromPrior: """Tests that verify the upgrade path for databases missing document_id. Migration 0001 uses Base.metadata.create_all() which includes document_id from the current model. To test the real-world scenario (database created before document_id was added to the model), we manually create the table WITHOUT document_id and stamp at 0004. """ @pytest.fixture def legacy_engine(self, tmp_path): """Create a database with research_resources missing document_id. This simulates a database created before commit 2033f977e added document_id to the ResearchResource model. """ db_path = tmp_path / "legacy_no_docid.db" engine = create_engine(f"sqlite:///{db_path}") with engine.begin() as conn: # Create research_history (needed for FK) conn.execute( text( "CREATE TABLE research_history (" "id VARCHAR(36) PRIMARY KEY, " "query TEXT NOT NULL, " "mode TEXT NOT NULL, " "status TEXT NOT NULL, " "created_at TEXT NOT NULL" ")" ) ) # Create research_resources WITHOUT document_id conn.execute( text( "CREATE TABLE research_resources (" "id INTEGER PRIMARY KEY AUTOINCREMENT, " "research_id VARCHAR(36) NOT NULL " " REFERENCES research_history(id) ON DELETE CASCADE, " "title TEXT, " "url TEXT, " "content_preview TEXT, " "source_type TEXT, " "metadata JSON, " "created_at VARCHAR NOT NULL" ")" ) ) # Stamp at 0004 so migration 0005 runs conn.execute( text( "CREATE TABLE alembic_version " "(version_num VARCHAR(32) NOT NULL)" ) ) conn.execute(text("INSERT INTO alembic_version VALUES ('0004')")) yield engine engine.dispose() def test_upgrade_adds_column_to_legacy_table(self, legacy_engine): """Upgrading a database missing document_id should add the column.""" columns = _get_columns(legacy_engine, "research_resources") assert "document_id" not in columns _run_upgrade_to(legacy_engine, "0005") columns = _get_columns(legacy_engine, "research_resources") assert "document_id" in columns def test_upgrade_adds_index_to_legacy_table(self, legacy_engine): """Upgrading should create the document_id index.""" _run_upgrade_to(legacy_engine, "0005") indexes = _get_indexes_by_name(legacy_engine, "research_resources") assert "ix_research_resources_document_id" in indexes def test_revision_is_0005_after_upgrade(self, legacy_engine): """Current revision should be 0005 after targeted upgrade.""" _run_upgrade_to(legacy_engine, "0005") assert get_current_revision(legacy_engine) == "0005" def test_existing_data_preserved_after_upgrade(self, legacy_engine): """Rows inserted before upgrade should survive with NULL document_id.""" with legacy_engine.begin() as conn: conn.execute( text( "INSERT INTO research_history " "(id, query, mode, status, created_at) " "VALUES ('rh-legacy', 'old query', 'quick', " "'completed', '2025-06-01')" ) ) conn.execute( text( "INSERT INTO research_resources " "(research_id, title, url, created_at) " "VALUES ('rh-legacy', 'Old Resource', " "'https://old.com', '2025-06-01')" ) ) _run_upgrade_to(legacy_engine, "0005") with legacy_engine.connect() as conn: result = conn.execute( text( "SELECT title, url, document_id " "FROM research_resources " "WHERE research_id = 'rh-legacy'" ) ).fetchone() assert result[0] == "Old Resource" assert result[1] == "https://old.com" assert result[2] is None def test_idempotent_on_fresh_db(self, migrated_to_0004_engine): """On a fresh DB (0001 already created document_id), 0005 is a no-op.""" engine = migrated_to_0004_engine # 0001's create_all already added document_id from current model columns_before = _get_columns(engine, "research_resources") assert "document_id" in columns_before _run_upgrade_to(engine, "0005") columns_after = _get_columns(engine, "research_resources") assert "document_id" in columns_after assert get_current_revision(engine) == "0005" class TestMigration0005FromFreshDatabase: """Tests that verify the full migration chain on a fresh database.""" def test_fresh_db_full_migration_creates_column(self, fresh_engine): """Running all migrations on empty DB should create document_id.""" run_migrations(fresh_engine) columns = _get_columns(fresh_engine, "research_resources") assert "document_id" in columns def test_head_revision_is_real_id(self): """get_head_revision() returns a real 4-digit revision id.""" head = get_head_revision() assert head is not None and head.isdigit() and len(head) == 4 def test_current_revision_is_head_after_migrate(self, fresh_engine): """After full migration, current revision should match head.""" run_migrations(fresh_engine) assert get_current_revision(fresh_engine) == get_head_revision() def test_needs_migration_false_after_full_upgrade(self, fresh_engine): """After full migration, needs_migration() should return False.""" run_migrations(fresh_engine) assert needs_migration(fresh_engine) is False class TestMigration0005Idempotency: """Tests that verify safe re-runs of migration.""" def test_run_migrations_twice_no_error(self, fresh_engine): """Calling run_migrations() twice should not raise.""" run_migrations(fresh_engine) run_migrations(fresh_engine) def test_column_unchanged_after_double_migration(self, fresh_engine): """Column should be identical after running migrations twice.""" run_migrations(fresh_engine) columns_first = _get_columns(fresh_engine, "research_resources") run_migrations(fresh_engine) columns_second = _get_columns(fresh_engine, "research_resources") assert ("document_id" in columns_first) == ( "document_id" in columns_second ) def test_pre_existing_column_not_duplicated(self, tmp_path): """If document_id already exists (fresh DB), migration is a no-op.""" db_path = tmp_path / "pre_existing_col.db" engine = create_engine(f"sqlite:///{db_path}") try: # Full migration creates the column via 0001's create_all run_migrations(engine) columns_before = _get_columns(engine, "research_resources") assert "document_id" in columns_before # Downgrade to 0004 and re-upgrade to test the migration path # when the column was already added by create_all # (This tests the idempotency guard) col_count_before = len(columns_before) # Running migrations again should be a no-op run_migrations(engine) columns_after = _get_columns(engine, "research_resources") assert len(columns_after) == col_count_before finally: engine.dispose() class TestMigration0005Downgrade: """Tests for rollback behavior.""" def test_downgrade_to_0004_removes_column(self, fully_migrated_engine): """Downgrade from 0005 to 0004 should remove document_id.""" _run_downgrade_to(fully_migrated_engine, "0004") columns = _get_columns(fully_migrated_engine, "research_resources") assert "document_id" not in columns def test_downgrade_to_0004_removes_index(self, fully_migrated_engine): """Downgrade should remove ix_research_resources_document_id.""" _run_downgrade_to(fully_migrated_engine, "0004") indexes = _get_indexes_by_name( fully_migrated_engine, "research_resources" ) assert "ix_research_resources_document_id" not in indexes def test_downgrade_preserves_table(self, fully_migrated_engine): """research_resources table should still exist after downgrade.""" _run_downgrade_to(fully_migrated_engine, "0004") insp = inspect(fully_migrated_engine) assert insp.has_table("research_resources") def test_downgrade_preserves_other_columns(self, fully_migrated_engine): """Other columns (title, url, etc.) should survive downgrade.""" _run_downgrade_to(fully_migrated_engine, "0004") columns = _get_columns(fully_migrated_engine, "research_resources") for col in ("id", "research_id", "title", "url", "created_at"): assert col in columns, f"Column {col} lost during downgrade" def test_downgrade_then_upgrade_roundtrip(self, fully_migrated_engine): """Downgrade to 0004 then upgrade back to 0005 should restore column.""" engine = fully_migrated_engine _run_downgrade_to(engine, "0004") assert get_current_revision(engine) == "0004" _run_upgrade_to(engine, "0005") assert get_current_revision(engine) == "0005" columns = _get_columns(engine, "research_resources") assert "document_id" in columns indexes = _get_indexes_by_name(engine, "research_resources") assert "ix_research_resources_document_id" in indexes class TestMigration0005DataPreservation: """Tests that verify migration is non-destructive.""" def test_existing_rows_preserved_after_upgrade( self, migrated_to_0004_engine ): """Data in research_resources should survive the migration.""" engine = migrated_to_0004_engine # Insert a research_history row first (FK constraint) with engine.begin() as conn: conn.execute( text( "INSERT INTO research_history " "(id, query, mode, status, created_at) " "VALUES ('rh-preserve', 'test query', 'quick', " "'completed', '2026-01-01')" ) ) conn.execute( text( "INSERT INTO research_resources " "(research_id, title, url, created_at) " "VALUES ('rh-preserve', 'Preserved Resource', " "'https://example.com', '2026-01-01')" ) ) _run_upgrade_to(engine, "0005") with engine.connect() as conn: result = conn.execute( text( "SELECT title, url, document_id " "FROM research_resources " "WHERE research_id = 'rh-preserve'" ) ).fetchone() assert result is not None assert result[0] == "Preserved Resource" assert result[1] == "https://example.com" assert result[2] is None # New column defaults to NULL def test_data_preserved_after_downgrade(self, fully_migrated_engine): """Non-document_id data should survive downgrade.""" engine = fully_migrated_engine with engine.begin() as conn: conn.execute( text( "INSERT INTO research_history " "(id, query, mode, status, created_at) " "VALUES ('rh-down', 'downgrade query', 'detailed', " "'completed', '2026-01-01')" ) ) conn.execute( text( "INSERT INTO research_resources " "(research_id, title, url, created_at, document_id) " "VALUES ('rh-down', 'Will Lose DocID', " "'https://example.com', '2026-01-01', 'some-uuid')" ) ) _run_downgrade_to(engine, "0004") with engine.connect() as conn: result = conn.execute( text( "SELECT title, url FROM research_resources " "WHERE research_id = 'rh-down'" ) ).fetchone() assert result is not None assert result[0] == "Will Lose DocID" assert result[1] == "https://example.com" class TestMigration0005EdgeCases: """Tests for edge cases and robustness.""" def test_missing_research_resources_table(self, tmp_path): """Migration should be a no-op when research_resources doesn't exist.""" db_path = tmp_path / "no_resources_table.db" engine = create_engine(f"sqlite:///{db_path}") try: with engine.begin() as conn: conn.execute( text( "CREATE TABLE alembic_version " "(version_num VARCHAR(32) NOT NULL)" ) ) conn.execute( text("INSERT INTO alembic_version VALUES ('0004')") ) _run_upgrade_to(engine, "0005") assert get_current_revision(engine) == "0005" insp = inspect(engine) assert not insp.has_table("research_resources") finally: engine.dispose() def test_in_memory_database(self): """Migration should work on in-memory SQLite database.""" engine = create_engine("sqlite:///:memory:") try: run_migrations(engine) assert get_current_revision(engine) == get_head_revision() columns = _get_columns(engine, "research_resources") assert "document_id" in columns indexes = _get_indexes_by_name(engine, "research_resources") assert "ix_research_resources_document_id" in indexes finally: engine.dispose() def test_document_id_queryable_after_upgrade(self, migrated_to_0004_engine): """Queries filtering on document_id should work after upgrade.""" engine = migrated_to_0004_engine with engine.begin() as conn: conn.execute( text( "INSERT INTO research_history " "(id, query, mode, status, created_at) " "VALUES ('rh-query', 'query test', 'quick', " "'completed', '2026-01-01')" ) ) conn.execute( text( "INSERT INTO research_resources " "(research_id, title, url, created_at) " "VALUES ('rh-query', 'Resource A', " "'https://a.com', '2026-01-01')" ) ) _run_upgrade_to(engine, "0005") # Set document_id on the row with engine.begin() as conn: conn.execute( text( "UPDATE research_resources SET document_id = 'doc-uuid-1' " "WHERE research_id = 'rh-query'" ) ) # Query using the new column (mimics library_service.py join condition) with engine.connect() as conn: result = conn.execute( text( "SELECT title FROM research_resources " "WHERE document_id = 'doc-uuid-1'" ) ).fetchall() assert len(result) == 1 assert result[0][0] == "Resource A" # NULL document_id query result = conn.execute( text( "SELECT COUNT(*) FROM research_resources " "WHERE document_id IS NULL" ) ).scalar() assert result == 0 # We updated the only row