Ctrl K
ComunidadGuías…

backend/tests/test_database_backup_scheduler.py

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

Archivo original ↗Preguntar a KiBot
"""Tests for `start_backup_scheduler` honouring the four `backup_auto_*` config fields.

Before this audit the scheduler hardcoded interval=24h and hour=3 UTC and
ignored every config field — the four UI toggles in System Settings did
nothing. These tests pin the new behaviour.
"""

import threading
from unittest.mock import MagicMock, patch


def _settings(**overrides):
    s = MagicMock()
    s.backup_auto_enabled = False
    s.backup_auto_interval_hours = 24
    s.backup_auto_on_startup = False
    s.backup_notify_on_failure = True
    for k, v in overrides.items():
        setattr(s, k, v)
    return s


def test_read_auto_backup_settings_returns_disabled_defaults():
    from database_backup import _read_auto_backup_settings

    with patch("config.get_settings", return_value=_settings()):
        enabled, interval, on_startup, notify = _read_auto_backup_settings()

    assert enabled is False
    assert interval == 24
    assert on_startup is False
    assert notify is True


def test_read_auto_backup_settings_clamps_zero_interval():
    """0 / negative values must not collapse the wait loop into a busy spin."""
    from database_backup import _read_auto_backup_settings

    with patch("config.get_settings", return_value=_settings(backup_auto_interval_hours=0)):
        _, interval, _, _ = _read_auto_backup_settings()

    assert interval >= 1


def test_read_auto_backup_settings_swallows_settings_failure():
    """Settings import errors must not crash the scheduler — return safe defaults."""
    from database_backup import _read_auto_backup_settings

    with patch("config.get_settings", side_effect=RuntimeError("settings unavailable")):
        enabled, interval, on_startup, notify = _read_auto_backup_settings()

    assert enabled is False
    assert interval == 24
    assert on_startup is False
    assert notify is True


def test_label_for_picks_monthly_on_first_of_month():
    from datetime import UTC, datetime

    from database_backup import _label_for

    assert _label_for(datetime(2026, 5, 1, 12, 0, tzinfo=UTC)) == "monthly"


def test_label_for_picks_weekly_on_monday():
    from datetime import UTC, datetime

    from database_backup import _label_for

    # 2026-05-04 is a Monday but not 1st.
    assert _label_for(datetime(2026, 5, 4, 12, 0, tzinfo=UTC)) == "weekly"


def test_label_for_picks_daily_otherwise():
    from datetime import UTC, datetime

    from database_backup import _label_for

    # 2026-05-06 — Wednesday.
    assert _label_for(datetime(2026, 5, 6, 12, 0, tzinfo=UTC)) == "daily"


def test_run_one_backup_emits_notification_on_failure():
    """When `notify_on_failure=True`, a backup raise must reach send_notification."""
    from database_backup import _run_one_backup

    backup = MagicMock()
    backup.create_backup.side_effect = RuntimeError("disk full")

    sent = []

    def _capture(**kw):
        sent.append(kw)

    with patch("notifier.send_notification", side_effect=_capture):
        _run_one_backup(backup, notify_on_failure=True)

    assert len(sent) == 1
    assert sent[0]["event_type"] == "error"
    assert "disk full" in sent[0]["body"]


def test_run_one_backup_skips_notification_when_flag_off():
    from database_backup import _run_one_backup

    backup = MagicMock()
    backup.create_backup.side_effect = RuntimeError("disk full")

    with patch("notifier.send_notification") as mock_notify:
        _run_one_backup(backup, notify_on_failure=False)

    mock_notify.assert_not_called()


def test_run_one_backup_swallows_notify_errors():
    """Apprise / notifier failures must not crash the loop."""
    from database_backup import _run_one_backup

    backup = MagicMock()
    backup.create_backup.side_effect = RuntimeError("disk full")

    with patch("notifier.send_notification", side_effect=RuntimeError("apprise broken")):
        # Must not raise.
        _run_one_backup(backup, notify_on_failure=True)


def test_run_one_backup_calls_rotate_on_success():
    from database_backup import _run_one_backup

    backup = MagicMock()
    backup.create_backup.return_value = {"path": "/x.db"}

    _run_one_backup(backup, notify_on_failure=True)

    backup.create_backup.assert_called_once()
    backup.rotate.assert_called_once()


def test_run_one_backup_skips_rotate_on_failure():
    """If create_backup raises, we should not attempt to rotate."""
    from database_backup import _run_one_backup

    backup = MagicMock()
    backup.create_backup.side_effect = RuntimeError("boom")

    with patch("notifier.send_notification"):
        _run_one_backup(backup, notify_on_failure=True)

    backup.rotate.assert_not_called()


def test_start_backup_scheduler_skips_when_disabled(tmp_path):
    """With backup_auto_enabled=False the loop must NOT call create_backup."""
    import database_backup

    # Reset module state.
    database_backup._scheduler_thread = None
    database_backup._scheduler_stop.clear()

    fake_backup = MagicMock()
    fake_backup.create_backup = MagicMock()

    started = threading.Event()

    def _fake_settings():
        started.set()
        return _settings(backup_auto_enabled=False)

    with (
        patch("config.get_settings", side_effect=_fake_settings),
        patch("database_backup.DatabaseBackup", return_value=fake_backup),
        # Force a fast poll so the test doesn't hang for 60s.
        patch.object(database_backup, "_DISABLED_POLL_SECS", 0.1),
    ):
        database_backup.start_backup_scheduler(
            db_path=str(tmp_path / "db.sqlite"),
            backup_dir=str(tmp_path / "backups"),
        )

        assert started.wait(timeout=2.0)
        # Give the loop a moment to make a decision.
        threading.Event().wait(0.3)

        database_backup.stop_backup_scheduler()
        if database_backup._scheduler_thread:
            database_backup._scheduler_thread.join(timeout=2.0)

    fake_backup.create_backup.assert_not_called()


def test_start_backup_scheduler_runs_immediately_when_on_startup(tmp_path):
    """on_startup=True must trigger a backup before the first interval wait."""
    import database_backup

    database_backup._scheduler_thread = None
    database_backup._scheduler_stop.clear()

    fake_backup = MagicMock()
    fake_backup.create_backup = MagicMock(return_value={"path": "x.db"})

    backup_done = threading.Event()

    def _create(**kw):
        backup_done.set()
        return {"path": "x.db"}

    fake_backup.create_backup.side_effect = _create

    with (
        patch(
            "config.get_settings",
            return_value=_settings(backup_auto_enabled=True, backup_auto_on_startup=True),
        ),
        patch("database_backup.DatabaseBackup", return_value=fake_backup),
    ):
        database_backup.start_backup_scheduler(
            db_path=str(tmp_path / "db.sqlite"),
            backup_dir=str(tmp_path / "backups"),
        )

        assert backup_done.wait(timeout=3.0)

        database_backup.stop_backup_scheduler()
        if database_backup._scheduler_thread:
            database_backup._scheduler_thread.join(timeout=2.0)

    fake_backup.create_backup.assert_called()
    fake_backup.rotate.assert_called()


def test_start_backup_scheduler_uses_app_context_when_provided(tmp_path):
    """If `app` is given, on_startup runs inside `app.app_context()`."""
    import database_backup

    database_backup._scheduler_thread = None
    database_backup._scheduler_stop.clear()

    fake_backup = MagicMock()
    fake_backup.create_backup = MagicMock(return_value={"path": "x.db"})

    ctx = MagicMock()
    ctx.__enter__ = MagicMock(return_value=ctx)
    ctx.__exit__ = MagicMock(return_value=False)
    fake_app = MagicMock()
    fake_app.app_context.return_value = ctx

    backup_done = threading.Event()
    fake_backup.create_backup.side_effect = lambda **kw: (backup_done.set(), {"path": "x.db"})[1]

    with (
        patch(
            "config.get_settings",
            return_value=_settings(backup_auto_enabled=True, backup_auto_on_startup=True),
        ),
        patch("database_backup.DatabaseBackup", return_value=fake_backup),
    ):
        database_backup.start_backup_scheduler(
            db_path=str(tmp_path / "db.sqlite"),
            backup_dir=str(tmp_path / "backups"),
            app=fake_app,
        )

        assert backup_done.wait(timeout=3.0)

        database_backup.stop_backup_scheduler()
        if database_backup._scheduler_thread:
            database_backup._scheduler_thread.join(timeout=2.0)

    fake_app.app_context.assert_called()