Ctrl K
ComunidadGuías…

backend/tests/test_database_health.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Tests for database_health.py — dialect-aware health checks and maintenance."""

import sys
from unittest.mock import MagicMock, patch

import pytest

from error_handler import DatabaseIntegrityError

# ── _is_postgresql ───────────────────────────────────────────────────────────


class TestIsPostgresql:
    def test_returns_true_for_postgresql_dialect(self):
        from database_health import _is_postgresql

        mock_db = MagicMock()
        mock_db.engine.dialect.name = "postgresql"
        with patch.dict(sys.modules, {"extensions": MagicMock(db=mock_db)}):
            assert _is_postgresql() is True

    def test_returns_false_for_sqlite_dialect(self):
        from database_health import _is_postgresql

        mock_db = MagicMock()
        mock_db.engine.dialect.name = "sqlite"
        with patch.dict(sys.modules, {"extensions": MagicMock(db=mock_db)}):
            assert _is_postgresql() is False

    def test_returns_false_on_exception(self):
        """When extensions.db raises, fall back to False."""
        from database_health import _is_postgresql

        mock_ext = MagicMock()
        mock_ext.db.engine.dialect.name = property(lambda self: (_ for _ in ()).throw(RuntimeError))

        # Simpler: make accessing .dialect raise
        type(mock_ext.db.engine).dialect = property(
            lambda self: (_ for _ in ()).throw(RuntimeError("no dialect"))
        )

        with patch.dict(sys.modules, {"extensions": mock_ext}):
            assert _is_postgresql() is False


# ── check_integrity ──────────────────────────────────────────────────────────


class TestCheckIntegrity:
    def test_healthy_database(self):
        from database_health import check_integrity

        mock_db = MagicMock()
        mock_db.execute.return_value.fetchall.return_value = [("ok",)]

        is_ok, message = check_integrity(mock_db)

        assert is_ok is True
        assert message == "ok"

    def test_corrupted_database_raises_integrity_error(self):
        from database_health import check_integrity

        mock_db = MagicMock()
        mock_db.execute.return_value.fetchall.return_value = [
            ("row 1 missing from index idx_foo",),
            ("row 5 missing from index idx_bar",),
        ]

        with pytest.raises(DatabaseIntegrityError, match="row 1 missing"):
            check_integrity(mock_db)

    def test_unknown_result_when_empty_rows(self):
        from database_health import check_integrity

        mock_db = MagicMock()
        mock_db.execute.return_value.fetchall.return_value = []

        # "unknown" != "ok", so it should raise DatabaseIntegrityError
        with pytest.raises(DatabaseIntegrityError, match="Integrity check failed"):
            check_integrity(mock_db)

    def test_general_exception_returns_false(self):
        from database_health import check_integrity

        mock_db = MagicMock()
        mock_db.execute.side_effect = RuntimeError("disk I/O error")

        is_ok, message = check_integrity(mock_db)

        assert is_ok is False
        assert "disk I/O error" in message

    def test_database_integrity_error_re_raised(self):
        """DatabaseIntegrityError from the pragma result is re-raised, not caught."""
        from database_health import check_integrity

        mock_db = MagicMock()
        mock_db.execute.return_value.fetchall.return_value = [("corrupt page 42",)]

        with pytest.raises(DatabaseIntegrityError):
            check_integrity(mock_db)


# ── get_database_stats ───────────────────────────────────────────────────────


class TestGetDatabaseStats:
    def _make_execute(self, pragma_map=None, tables=None, table_counts=None):
        """Helper to build a mock db.execute with predictable PRAGMA/table responses."""
        if pragma_map is None:
            pragma_map = {
                "journal_mode": ("wal",),
                "page_size": (4096,),
                "page_count": (100,),
                "freelist_count": (5,),
            }
        if tables is None:
            tables = []
        if table_counts is None:
            table_counts = {}

        def execute_side_effect(stmt):
            sql = str(stmt)
            result = MagicMock()
            # PRAGMA checks
            for pragma_name, value in pragma_map.items():
                if pragma_name in sql:
                    result.fetchone.return_value = value
                    return result
            # sqlite_master
            if "sqlite_master" in sql:
                result.fetchall.return_value = [(t,) for t in tables]
                return result
            # COUNT(*) for specific tables
            for tname, count in table_counts.items():
                if tname in sql and "COUNT" in sql:
                    if isinstance(count, Exception):
                        raise count
                    result.fetchone.return_value = (count,)
                    return result
            result.fetchone.return_value = None
            result.fetchall.return_value = []
            return result

        return execute_side_effect

    def test_basic_stats_collection(self):
        from database_health import get_database_stats

        mock_db = MagicMock()
        mock_db.execute.side_effect = self._make_execute(
            tables=["subtitles", "config_entries"],
            table_counts={"subtitles": 42, "config_entries": 10},
        )

        with (
            patch("os.path.getsize", return_value=102400),
            patch("os.path.exists", return_value=False),
        ):
            stats = get_database_stats(mock_db, "/fake/db.sqlite")

        assert stats["size_bytes"] == 102400
        assert stats["wal_mode"] is True
        assert stats["page_size"] == 4096
        assert stats["page_count"] == 100
        assert stats["freelist_count"] == 5
        assert stats["tables"]["subtitles"] == 42
        assert stats["tables"]["config_entries"] == 10

    def test_wal_file_size_when_exists(self):
        from database_health import get_database_stats

        mock_db = MagicMock()
        mock_db.execute.side_effect = self._make_execute()

        def getsize_side_effect(path):
            if path.endswith("-wal"):
                return 8192
            return 51200

        with (
            patch("os.path.getsize", side_effect=getsize_side_effect),
            patch("os.path.exists", return_value=True),
        ):
            stats = get_database_stats(mock_db, "/fake/db.sqlite")

        assert stats["wal_size_bytes"] == 8192

    def test_missing_db_file_returns_zero_size(self):
        from database_health import get_database_stats

        mock_db = MagicMock()
        mock_db.execute.side_effect = self._make_execute()

        with (
            patch("os.path.getsize", side_effect=OSError("not found")),
            patch("os.path.exists", return_value=False),
        ):
            stats = get_database_stats(mock_db, "/nonexistent/db.sqlite")

        assert stats["size_bytes"] == 0
        assert stats["wal_size_bytes"] == 0

    def test_non_standard_table_name_skipped(self):
        """Tables with names like '-- drop' are skipped for safety."""
        from database_health import get_database_stats

        mock_db = MagicMock()
        mock_db.execute.side_effect = self._make_execute(
            tables=["valid_table", "-- injection"],
            table_counts={"valid_table": 7},
        )

        with (
            patch("os.path.getsize", return_value=1000),
            patch("os.path.exists", return_value=False),
        ):
            stats = get_database_stats(mock_db, "/fake/db.sqlite")

        assert "valid_table" in stats["tables"]
        assert "-- injection" not in stats["tables"]

    def test_pragma_exception_sets_none(self):
        from database_health import get_database_stats

        mock_db = MagicMock()
        mock_db.execute.side_effect = RuntimeError("pragma failed")

        with (
            patch("os.path.getsize", return_value=500),
            patch("os.path.exists", return_value=False),
        ):
            stats = get_database_stats(mock_db, "/fake/db.sqlite")

        # PRAGMAs should be None, tables should be empty
        assert stats["page_size"] is None
        assert stats["tables"] == {}

    def test_table_count_failure_returns_minus_one(self):
        """If counting rows in a table fails, record -1."""
        from database_health import get_database_stats

        mock_db = MagicMock()
        mock_db.execute.side_effect = self._make_execute(
            tables=["broken_table"],
            table_counts={"broken_table": RuntimeError("table locked")},
        )

        with (
            patch("os.path.getsize", return_value=500),
            patch("os.path.exists", return_value=False),
        ):
            stats = get_database_stats(mock_db, "/fake/db.sqlite")

        assert stats["tables"]["broken_table"] == -1

    def test_wal_size_oserror_returns_zero(self):
        """OSError reading WAL file returns 0."""
        from database_health import get_database_stats

        mock_db = MagicMock()
        mock_db.execute.side_effect = self._make_execute()

        def getsize_side_effect(path):
            if path.endswith("-wal"):
                raise OSError("wal broken")
            return 1000

        with (
            patch("os.path.getsize", side_effect=getsize_side_effect),
            patch("os.path.exists", return_value=True),
        ):
            stats = get_database_stats(mock_db, "/fake/db.sqlite")

        assert stats["wal_size_bytes"] == 0


# ── vacuum ───────────────────────────────────────────────────────────────────


class TestVacuum:
    def test_vacuum_reports_space_savings(self):
        from database_health import vacuum

        mock_db = MagicMock()
        sizes = iter([10240, 8192])

        with patch("os.path.getsize", side_effect=lambda _: next(sizes)):
            result = vacuum(mock_db, "/fake/db.sqlite")

        assert result["before_bytes"] == 10240
        assert result["after_bytes"] == 8192
        assert result["saved_bytes"] == 2048

        # Verify VACUUM was executed
        mock_db.execute.assert_called_once()

    def test_vacuum_with_missing_file(self):
        from database_health import vacuum

        mock_db = MagicMock()

        with patch("os.path.getsize", side_effect=OSError("no file")):
            result = vacuum(mock_db, "/nonexistent/db.sqlite")

        assert result["before_bytes"] == 0
        assert result["after_bytes"] == 0
        assert result["saved_bytes"] == 0

    def test_vacuum_no_savings(self):
        from database_health import vacuum

        mock_db = MagicMock()

        with patch("os.path.getsize", return_value=5000):
            result = vacuum(mock_db, "/fake/db.sqlite")

        assert result["saved_bytes"] == 0


# ── _sqlite_health_report ────────────────────────────────────────────────────


class TestSqliteHealthReport:
    def test_healthy_report(self):
        from database_health import _sqlite_health_report

        mock_db = MagicMock()
        mock_settings = MagicMock()
        mock_settings.db_path = "/fake/db.sqlite"

        # check_integrity: integrity_check PRAGMA returns "ok"
        # get_database_stats: various PRAGMAs
        def execute_side_effect(stmt):
            sql = str(stmt)
            result = MagicMock()
            if "integrity_check" in sql:
                result.fetchall.return_value = [("ok",)]
            elif "sqlite_master" in sql:
                result.fetchall.return_value = []
            else:
                result.fetchone.return_value = (0,)
            return result

        mock_db.execute.side_effect = execute_side_effect

        with (
            patch("db.get_db", return_value=mock_db),
            patch("config.get_settings", return_value=mock_settings),
            patch("os.path.getsize", return_value=1024),
            patch("os.path.exists", return_value=False),
        ):
            report = _sqlite_health_report()

        assert report["status"] == "healthy"
        assert report["backend"] == "sqlite"
        assert report["details"]["integrity"]["ok"] is True
        assert report["details"]["integrity"]["message"] == "ok"

    def test_unhealthy_report_on_generic_error(self):
        """When check_integrity hits a generic exception, report is unhealthy."""
        from database_health import _sqlite_health_report

        mock_db = MagicMock()
        mock_settings = MagicMock()
        mock_settings.db_path = "/fake/db.sqlite"

        # First call (integrity check) raises generic error -> returns (False, msg)
        # Subsequent calls (stats) also raise but that's handled
        mock_db.execute.side_effect = RuntimeError("disk error")

        with (
            patch("db.get_db", return_value=mock_db),
            patch("config.get_settings", return_value=mock_settings),
            patch("os.path.getsize", return_value=0),
            patch("os.path.exists", return_value=False),
        ):
            report = _sqlite_health_report()

        assert report["status"] == "unhealthy"
        assert report["details"]["integrity"]["ok"] is False


# ── get_health_report (dispatch) ─────────────────────────────────────────────


class TestGetHealthReport:
    def test_dispatches_to_sqlite(self):
        from database_health import get_health_report

        with (
            patch("database_health._is_postgresql", return_value=False),
            patch(
                "database_health._sqlite_health_report",
                return_value={"status": "healthy", "backend": "sqlite", "details": {}},
            ) as mock_sqlite,
        ):
            result = get_health_report()

        mock_sqlite.assert_called_once()
        assert result["backend"] == "sqlite"

    def test_dispatches_to_postgresql(self):
        from database_health import get_health_report

        with (
            patch("database_health._is_postgresql", return_value=True),
            patch(
                "database_health._pg_health_report",
                return_value={"status": "healthy", "backend": "postgresql", "details": {}},
            ) as mock_pg,
        ):
            result = get_health_report()

        mock_pg.assert_called_once()
        assert result["backend"] == "postgresql"


# ── get_pool_stats ───────────────────────────────────────────────────────────


class TestGetPoolStats:
    def test_returns_none_for_sqlite(self):
        from database_health import get_pool_stats

        with patch("database_health._is_postgresql", return_value=False):
            assert get_pool_stats() is None

    def test_returns_pool_stats_for_postgresql(self):
        from database_health import get_pool_stats

        mock_pool = MagicMock()
        mock_pool.size.return_value = 5
        mock_pool.checkedin.return_value = 3
        mock_pool.checkedout.return_value = 2
        mock_pool.overflow.return_value = 0
        mock_pool.status.return_value = "OK"

        mock_ext = MagicMock()
        mock_ext.db.engine.pool = mock_pool

        with (
            patch("database_health._is_postgresql", return_value=True),
            patch.dict(sys.modules, {"extensions": mock_ext}),
        ):
            stats = get_pool_stats()

        assert stats == {
            "size": 5,
            "checked_in": 3,
            "checked_out": 2,
            "overflow": 0,
            "status": "OK",
        }

    def test_returns_none_on_exception(self):
        from database_health import get_pool_stats

        mock_ext = MagicMock()
        mock_ext.db.engine.pool.size.side_effect = RuntimeError("pool error")

        with (
            patch("database_health._is_postgresql", return_value=True),
            patch.dict(sys.modules, {"extensions": mock_ext}),
        ):
            stats = get_pool_stats()

        assert stats is None


# ── _pg_health_report ────────────────────────────────────────────────────────


class TestPgHealthReport:
    def _make_pg_conn(self, execute_fn):
        """Create a mock connection manager from an execute function."""
        mock_conn = MagicMock()
        mock_conn.execute.side_effect = execute_fn
        mock_conn.__enter__ = MagicMock(return_value=mock_conn)
        mock_conn.__exit__ = MagicMock(return_value=False)
        return mock_conn

    def _default_pg_execute(self, stmt):
        """Default PG execute that returns reasonable data for all queries."""
        sql = str(stmt)
        result = MagicMock()
        if "pg_database_size" in sql:
            result.fetchone.return_value = (1048576,)
        elif "pg_stat_activity" in sql:
            result.fetchone.return_value = (3,)
        elif "pg_class" in sql:
            result.fetchall.return_value = [("users", 8192), ("logs", 4096)]
        elif "pg_stat_user_tables" in sql:
            result.fetchall.return_value = [("users", 100), ("logs", 500)]
        elif "pg_stat_user_indexes" in sql:
            result.fetchall.return_value = [("idx_users_id", 200)]
        elif "SELECT 1" in sql:
            result.fetchone.return_value = (1,)
        return result

    def test_healthy_pg_report(self):
        from database_health import _pg_health_report

        mock_conn = self._make_pg_conn(self._default_pg_execute)
        mock_ext = MagicMock()
        mock_ext.db.engine.connect.return_value = mock_conn

        with (
            patch.dict(sys.modules, {"extensions": mock_ext}),
            patch("database_health.get_pool_stats", return_value=None),
        ):
            report = _pg_health_report()

        assert report["status"] == "healthy"
        assert report["backend"] == "postgresql"
        assert report["details"]["size_bytes"] == 1048576
        assert report["details"]["active_connections"] == 3
        assert report["details"]["table_sizes"] == {"users": 8192, "logs": 4096}
        assert report["details"]["tables"] == {"users": 100, "logs": 500}
        assert report["details"]["index_usage"] == {"idx_users_id": 200}
        assert report["details"]["connection_ok"] is True

    def test_pg_report_connection_failure(self):
        from database_health import _pg_health_report

        mock_ext = MagicMock()
        mock_ext.db.engine.connect.side_effect = RuntimeError("connection refused")

        with (
            patch.dict(sys.modules, {"extensions": mock_ext}),
            patch("database_health.get_pool_stats", return_value=None),
        ):
            report = _pg_health_report()

        assert report["status"] == "unhealthy"
        assert "error" in report["details"]
        assert "connection refused" in report["details"]["error"]

    def test_pg_report_with_pool_stats(self):
        from database_health import _pg_health_report

        mock_conn = self._make_pg_conn(self._default_pg_execute)
        mock_ext = MagicMock()
        mock_ext.db.engine.connect.return_value = mock_conn

        pool_info = {
            "size": 10,
            "checked_in": 8,
            "checked_out": 2,
            "overflow": 0,
            "status": "OK",
        }

        with (
            patch.dict(sys.modules, {"extensions": mock_ext}),
            patch("database_health.get_pool_stats", return_value=pool_info),
        ):
            report = _pg_health_report()

        assert report["details"]["pool"] == pool_info

    def test_pg_report_partial_query_failures(self):
        """Individual query failures are logged but don't crash the report."""
        from database_health import _pg_health_report

        def execute_side_effect(stmt):
            sql = str(stmt)
            if "SELECT 1" in sql:
                return MagicMock(fetchone=MagicMock(return_value=(1,)))
            raise RuntimeError("query failed")

        mock_conn = self._make_pg_conn(execute_side_effect)
        mock_ext = MagicMock()
        mock_ext.db.engine.connect.return_value = mock_conn

        with (
            patch.dict(sys.modules, {"extensions": mock_ext}),
            patch("database_health.get_pool_stats", return_value=None),
        ):
            report = _pg_health_report()

        # Despite query failures, the report completes
        assert report["status"] == "healthy"
        assert report["details"]["size_bytes"] == 0
        assert report["details"]["active_connections"] == -1
        assert report["details"]["table_sizes"] == {}
        assert report["details"]["index_usage"] == {}

    def test_pg_report_connection_check_failure_marks_unhealthy(self):
        """If SELECT 1 fails, status becomes unhealthy."""
        from database_health import _pg_health_report

        def execute_side_effect(stmt):
            sql = str(stmt)
            result = MagicMock()
            if "SELECT 1" in sql:
                raise RuntimeError("connection lost")
            if "pg_database_size" in sql or "pg_stat_activity" in sql:
                result.fetchone.return_value = (0,)
            else:
                result.fetchall.return_value = []
            return result

        mock_conn = self._make_pg_conn(execute_side_effect)
        mock_ext = MagicMock()
        mock_ext.db.engine.connect.return_value = mock_conn

        with (
            patch.dict(sys.modules, {"extensions": mock_ext}),
            patch("database_health.get_pool_stats", return_value=None),
        ):
            report = _pg_health_report()

        assert report["status"] == "unhealthy"
        assert report["details"]["connection_ok"] is False

    def test_pg_report_no_pool_stats_omits_pool_key(self):
        """When get_pool_stats returns None, 'pool' key is absent from details."""
        from database_health import _pg_health_report

        mock_conn = self._make_pg_conn(self._default_pg_execute)
        mock_ext = MagicMock()
        mock_ext.db.engine.connect.return_value = mock_conn

        with (
            patch.dict(sys.modules, {"extensions": mock_ext}),
            patch("database_health.get_pool_stats", return_value=None),
        ):
            report = _pg_health_report()

        assert "pool" not in report["details"]