7dae15fd29
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
360 lines
16 KiB
Python
360 lines
16 KiB
Python
"""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)
|