"""Tests for app/utils/db_migrate.py module.""" from unittest.mock import MagicMock, patch import pytest from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from app.database import Base from app.utils.db_migrate import ( _make_engine, _ordered_tables, _stamp_alembic_head, migrate_data, preview_migration, ) @pytest.mark.unit class TestMakeEngine: """Tests for _make_engine helper function.""" def test_sqlite_engine_has_check_same_thread(self): """Test that SQLite engine has check_same_thread set.""" engine = _make_engine("sqlite:///:memory:") assert engine is not None engine.dispose() def test_non_sqlite_engine_created(self): """Test that non-SQLite engine can be created (even if driver is missing).""" # _make_engine only creates the engine object; it doesn't connect. # If the driver isn't installed, create_engine raises at creation time. try: engine = _make_engine("postgresql://u:p@localhost:5432/test") assert engine is not None engine.dispose() except Exception: # Driver not installed in test environment — acceptable pass @pytest.mark.unit class TestOrderedTables: """Tests for _ordered_tables helper function.""" def test_known_tables_come_first(self): """Test that known tables from _TABLE_ORDER come first.""" mock_inspector = MagicMock() mock_inspector.get_table_names.return_value = [ "webhook_configs", "documents", "files", "custom_table", "alembic_version", ] result = _ordered_tables(mock_inspector) # alembic_version should be skipped assert "alembic_version" not in result # Known tables should come first in their predefined order assert result.index("documents") < result.index("files") assert result.index("files") < result.index("webhook_configs") # custom_table is not in _TABLE_ORDER so comes after known tables assert "custom_table" in result def test_skips_alembic_version(self): """Test that alembic_version table is always skipped.""" mock_inspector = MagicMock() mock_inspector.get_table_names.return_value = ["alembic_version", "documents"] result = _ordered_tables(mock_inspector) assert "alembic_version" not in result assert "documents" in result def test_unknown_tables_appended_alphabetically(self): """Test that tables not in _TABLE_ORDER are appended alphabetically.""" mock_inspector = MagicMock() mock_inspector.get_table_names.return_value = ["zebra", "apple", "documents"] result = _ordered_tables(mock_inspector) assert result[0] == "documents" # apple and zebra should be after documents, in alpha order remaining = result[1:] assert remaining == sorted(remaining) def test_empty_database(self): """Test with an empty database returns empty list.""" mock_inspector = MagicMock() mock_inspector.get_table_names.return_value = [] result = _ordered_tables(mock_inspector) assert result == [] @pytest.mark.unit class TestPreviewMigration: """Tests for preview_migration function.""" def test_preview_in_memory_sqlite(self): """Test previewing an in-memory SQLite database.""" # Create a temporary source DB with some data src_engine = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(bind=src_engine) # Insert a test row Session = sessionmaker(bind=src_engine) session = Session() session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')")) session.commit() session.close() # Preview using the engine's URL won't work for :memory:, # but we can test the error path result = preview_migration("sqlite:///:memory:") # For :memory: this creates a new empty DB, so tables are empty assert result["success"] is True assert isinstance(result["tables"], list) def test_preview_invalid_url(self): """Test preview with invalid URL returns error.""" result = preview_migration("invalid://not-a-db") assert result["success"] is False assert "error" in result def test_preview_with_patched_source_shows_tables(self): """Test preview with source that has tables and data.""" real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(bind=real_src) # Insert test data Session = sessionmaker(bind=real_src) session = Session() session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')")) session.commit() session.close() with patch("app.utils.db_migrate._make_engine", return_value=real_src): result = preview_migration("sqlite:///:memory:") assert result["success"] is True assert result["total_rows"] >= 1 # At least the documents table should be in results table_names = [t["name"] for t in result["tables"]] assert "documents" in table_names doc_table = next(t for t in result["tables"] if t["name"] == "documents") assert doc_table["row_count"] >= 1 @pytest.mark.unit class TestMigrateData: """Tests for migrate_data function.""" def test_migrate_empty_sqlite_to_sqlite(self): """Test migrating an empty SQLite DB to another SQLite DB.""" # Both are file-based temp databases for this test src_url = "sqlite:///:memory:" tgt_url = "sqlite://" # Another in-memory DB # Create source schema src_engine = create_engine(src_url, connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(bind=src_engine) src_engine.dispose() # Run migration from empty source with patch("app.utils.db_migrate._make_engine") as mock_make: # Create real engines for both real_src = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool ) Base.metadata.create_all(bind=real_src) real_tgt = create_engine( "sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool ) mock_make.side_effect = [real_src, real_tgt] with patch("app.utils.db_migrate._stamp_alembic_head"): result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:") assert result["success"] is True assert result["rows_copied"] == 0 def test_migrate_with_data(self): """Test migrating a SQLite DB with actual data.""" real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(bind=real_src) # Insert test data Session = sessionmaker(bind=real_src) session = Session() session.execute(text("INSERT INTO documents (filename) VALUES ('invoice.pdf')")) session.execute(text("INSERT INTO documents (filename) VALUES ('receipt.pdf')")) session.commit() session.close() real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) with patch("app.utils.db_migrate._make_engine") as mock_make: mock_make.side_effect = [real_src, real_tgt] with patch("app.utils.db_migrate._stamp_alembic_head"): result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:") assert result["success"] is True assert result["rows_copied"] >= 2 # At least the 2 documents rows def test_migrate_with_progress_callback(self): """Test that progress callback is invoked during migration.""" real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(bind=real_src) Session = sessionmaker(bind=real_src) session = Session() session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')")) session.commit() session.close() real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) callback = MagicMock() with patch("app.utils.db_migrate._make_engine") as mock_make: mock_make.side_effect = [real_src, real_tgt] with patch("app.utils.db_migrate._stamp_alembic_head"): result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:", progress_callback=callback) assert result["success"] is True # Callback should have been called at least once for the non-empty table if result["rows_copied"] > 0: assert callback.call_count > 0 def test_migrate_global_exception(self): """Test that a global exception is caught gracefully.""" with patch("app.utils.db_migrate._make_engine", side_effect=Exception("boom")): result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:") assert result["success"] is False assert len(result["errors"]) > 0 def test_migrate_stamp_failure_is_recorded(self): """Test that Alembic stamp failure is recorded as an error.""" real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(bind=real_src) real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) with patch("app.utils.db_migrate._make_engine") as mock_make: mock_make.side_effect = [real_src, real_tgt] with patch("app.utils.db_migrate._stamp_alembic_head", side_effect=Exception("stamp failed")): result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:") # Data copy succeeds but stamp fails — errors list non-empty assert len(result["errors"]) > 0 assert any("stamp" in e.lower() for e in result["errors"]) def test_migrate_table_copy_exception(self): """Test that per-table copy exception is recorded but migration continues.""" real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(bind=real_src) # Insert data so the table isn't empty Session = sessionmaker(bind=real_src) session = Session() session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')")) session.commit() session.close() real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(bind=real_tgt) # Make the target reflect fail for one table to trigger the error path original_reflect = MagicMock(side_effect=Exception("reflect error")) with patch("app.utils.db_migrate._make_engine") as mock_make: mock_make.side_effect = [real_src, real_tgt] with patch("app.utils.db_migrate._stamp_alembic_head"): # Patch MetaData so that reflecting target raises for the first table with patch("app.utils.db_migrate.MetaData") as mock_meta_cls: # First MetaData() is for source reflect (should work) src_meta = MagicMock() src_table = MagicMock() src_table.columns = [] src_table.select.return_value = text("SELECT 1") src_meta.tables = {"documents": src_table} src_meta.reflect = MagicMock() # Second MetaData() is for target reflect (should fail) tgt_meta = MagicMock() tgt_meta.reflect.side_effect = Exception("target reflect error") mock_meta_cls.side_effect = [src_meta, tgt_meta] result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:") assert any("Error copying table" in e for e in result["errors"]) def test_migrate_target_table_not_found(self): """Test that missing target table after reflect is recorded.""" real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(bind=real_src) Session = sessionmaker(bind=real_src) session = Session() session.execute(text("INSERT INTO documents (filename) VALUES ('test.pdf')")) session.commit() session.close() real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) # Create schema in target so reflect works but returns empty Base.metadata.create_all(bind=real_tgt) with patch("app.utils.db_migrate._make_engine") as mock_make: mock_make.side_effect = [real_src, real_tgt] with patch("app.utils.db_migrate._stamp_alembic_head"): result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:") # With schema in target, migration should succeed normally assert result["success"] is True def test_migrate_returns_tables_and_rows_counts(self): """Test that successful migration returns expected count fields.""" real_src = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(bind=real_src) real_tgt = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool) with patch("app.utils.db_migrate._make_engine") as mock_make: mock_make.side_effect = [real_src, real_tgt] with patch("app.utils.db_migrate._stamp_alembic_head"): result = migrate_data("sqlite:///:memory:", "sqlite:///:memory:") assert "tables_copied" in result assert "rows_copied" in result assert "errors" in result assert isinstance(result["errors"], list) @pytest.mark.unit class TestStampAlembicHead: """Tests for _stamp_alembic_head helper function.""" def test_stamp_calls_alembic_command(self): """Test that stamping calls alembic command.stamp with 'head'.""" mock_engine = MagicMock() mock_connection = MagicMock() mock_engine.begin.return_value.__enter__ = MagicMock(return_value=mock_connection) mock_engine.begin.return_value.__exit__ = MagicMock(return_value=False) with patch("alembic.command.stamp") as mock_stamp: _stamp_alembic_head(mock_engine) mock_stamp.assert_called_once() # Verify it stamps to "head" args = mock_stamp.call_args assert args[0][1] == "head" def test_stamp_raises_on_error(self): """Test that stamp propagates exceptions.""" mock_engine = MagicMock() mock_connection = MagicMock() mock_engine.begin.return_value.__enter__ = MagicMock(return_value=mock_connection) mock_engine.begin.return_value.__exit__ = MagicMock(return_value=False) with patch("alembic.command.stamp", side_effect=Exception("stamp error")): with pytest.raises(Exception, match="stamp error"): _stamp_alembic_head(mock_engine)