202 lines
6.5 KiB
Python
202 lines
6.5 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
import zipfile
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
from bot.services.backup_worker import BACKUP_FILENAME_PREFIX, BackupWorker
|
|
from bot.services.settings_override_service import refresh_overrides_from_db
|
|
from config.settings import Settings
|
|
|
|
|
|
class _FakeBot:
|
|
def __init__(self):
|
|
self.send_document = AsyncMock()
|
|
self.send_message = AsyncMock()
|
|
|
|
|
|
class _FakePgDumpBackupWorker(BackupWorker):
|
|
def _run_pg_dump(self, dump_path: Path) -> None:
|
|
dump_path.write_bytes(b"fake custom pg dump")
|
|
|
|
|
|
class _FakeSession:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
|
|
class _FakeSessionFactory:
|
|
def __call__(self):
|
|
return _FakeSession()
|
|
|
|
|
|
def _settings(tmp_path: Path, compose_dir: Path, **overrides) -> Settings:
|
|
values = {
|
|
"BOT_TOKEN": "token",
|
|
"POSTGRES_USER": "app_user",
|
|
"POSTGRES_PASSWORD": "app_password",
|
|
"POSTGRES_DB": "shop",
|
|
"BACKUP_DIR": str(tmp_path / "backups"),
|
|
"BACKUP_COMPOSE_SOURCE_DIR": str(compose_dir),
|
|
"BACKUP_CHAT_ID": 123,
|
|
"BACKUP_LOCAL_RETENTION": 1,
|
|
"_env_file": None,
|
|
}
|
|
values.update(overrides)
|
|
return Settings(**values)
|
|
|
|
|
|
def test_backup_worker_creates_archive_with_db_dump_and_compose_snapshot(tmp_path):
|
|
compose_dir = tmp_path / "compose"
|
|
compose_dir.mkdir()
|
|
(compose_dir / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8")
|
|
(compose_dir / ".env").write_text("POSTGRES_PASSWORD=secret\n", encoding="utf-8")
|
|
(compose_dir / "Caddyfile").write_text("example.com\n", encoding="utf-8")
|
|
(compose_dir / "node_modules").mkdir()
|
|
(compose_dir / "node_modules" / "ignored.txt").write_text("ignored", encoding="utf-8")
|
|
|
|
settings = _settings(tmp_path, compose_dir)
|
|
backup_dir = Path(settings.BACKUP_DIR)
|
|
backup_dir.mkdir(parents=True)
|
|
old_archive = backup_dir / f"{BACKUP_FILENAME_PREFIX}old.zip"
|
|
old_archive.write_text("old", encoding="utf-8")
|
|
os.utime(old_archive, (1, 1))
|
|
|
|
bot = _FakeBot()
|
|
worker = _FakePgDumpBackupWorker(settings, bot)
|
|
|
|
result = asyncio.run(worker.create_and_send_backup())
|
|
|
|
assert result.archive_path.is_file()
|
|
assert result.db_dump_included is True
|
|
assert result.compose_files_count == 3
|
|
assert not old_archive.exists()
|
|
bot.send_document.assert_awaited_once()
|
|
send_kwargs = bot.send_document.await_args.kwargs
|
|
assert send_kwargs["chat_id"] == 123
|
|
assert "Database dump: yes" in send_kwargs["caption"]
|
|
|
|
with zipfile.ZipFile(result.archive_path) as archive:
|
|
names = set(archive.namelist())
|
|
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
|
|
|
|
assert "database/shop.dump" in names
|
|
assert "compose/docker-compose.yml" in names
|
|
assert "compose/.env" in names
|
|
assert "compose/Caddyfile" in names
|
|
assert all("node_modules" not in name for name in names)
|
|
assert manifest["postgres"]["database"] == "shop"
|
|
assert manifest["compose"]["files_count"] == 3
|
|
|
|
|
|
def test_backup_worker_falls_back_to_log_chat_and_thread(tmp_path):
|
|
compose_dir = tmp_path / "compose"
|
|
compose_dir.mkdir()
|
|
settings = _settings(
|
|
tmp_path,
|
|
compose_dir,
|
|
BACKUP_CHAT_ID="",
|
|
BACKUP_THREAD_ID="",
|
|
LOG_CHAT_ID=-100123,
|
|
LOG_THREAD_ID=77,
|
|
BACKUP_POSTGRES_DUMP_ENABLED=False,
|
|
BACKUP_COMPOSE_ENABLED=False,
|
|
)
|
|
bot = _FakeBot()
|
|
worker = _FakePgDumpBackupWorker(settings, bot)
|
|
|
|
result = asyncio.run(worker.create_and_send_backup())
|
|
|
|
assert result.db_dump_included is False
|
|
bot.send_document.assert_awaited_once()
|
|
send_kwargs = bot.send_document.await_args.kwargs
|
|
assert send_kwargs["chat_id"] == -100123
|
|
assert send_kwargs["message_thread_id"] == 77
|
|
|
|
|
|
def test_backup_worker_can_create_manual_backup(tmp_path):
|
|
compose_dir = tmp_path / "compose"
|
|
compose_dir.mkdir()
|
|
settings = _settings(
|
|
tmp_path,
|
|
compose_dir,
|
|
BACKUP_POSTGRES_DUMP_ENABLED=True,
|
|
BACKUP_COMPOSE_ENABLED=False,
|
|
)
|
|
bot = _FakeBot()
|
|
worker = _FakePgDumpBackupWorker(settings, bot)
|
|
|
|
result = asyncio.run(worker.create_backup(backup_type="manual"))
|
|
|
|
assert result.archive_path.is_file()
|
|
assert result.to_payload()["archive_name"] == result.archive_path.name
|
|
with zipfile.ZipFile(result.archive_path) as archive:
|
|
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
|
|
assert manifest["type"] == "manual"
|
|
assert "archive" in manifest
|
|
|
|
|
|
def test_backup_worker_allocates_unique_archive_path(tmp_path):
|
|
settings = _settings(tmp_path, tmp_path / "compose")
|
|
worker = _FakePgDumpBackupWorker(settings, _FakeBot())
|
|
archive_path = tmp_path / "remnawave-minishop-backup-20260527-120000+0300.zip"
|
|
archive_path.write_text("existing", encoding="utf-8")
|
|
|
|
unique_path = worker._unique_archive_path(archive_path)
|
|
|
|
assert unique_path.name == "remnawave-minishop-backup-20260527-120000+0300-2.zip"
|
|
|
|
|
|
def test_backup_worker_does_not_fail_when_compose_source_is_not_mounted(tmp_path):
|
|
missing_compose_dir = tmp_path / "missing-compose"
|
|
settings = _settings(
|
|
tmp_path,
|
|
missing_compose_dir,
|
|
BACKUP_POSTGRES_DUMP_ENABLED=True,
|
|
BACKUP_COMPOSE_ENABLED=True,
|
|
)
|
|
bot = _FakeBot()
|
|
worker = _FakePgDumpBackupWorker(settings, bot)
|
|
|
|
result = asyncio.run(worker.create_and_send_backup())
|
|
|
|
assert result.archive_path.is_file()
|
|
assert result.db_dump_included is True
|
|
assert result.compose_files_count == 0
|
|
assert any("Compose source directory is unavailable" in item for item in result.warnings)
|
|
with zipfile.ZipFile(result.archive_path) as archive:
|
|
names = set(archive.namelist())
|
|
assert "database/shop.dump" in names
|
|
|
|
|
|
def test_backup_settings_refresh_restores_env_default_when_override_is_deleted(monkeypatch):
|
|
from bot.services import settings_override_service
|
|
|
|
settings = SimpleNamespace(BACKUP_ENABLED=True)
|
|
monkeypatch.setattr(
|
|
settings_override_service.app_settings_dal,
|
|
"get_all_overrides",
|
|
AsyncMock(return_value={}),
|
|
)
|
|
monkeypatch.setattr(
|
|
settings_override_service,
|
|
"Settings",
|
|
lambda: SimpleNamespace(BACKUP_ENABLED=False),
|
|
)
|
|
|
|
applied = asyncio.run(
|
|
refresh_overrides_from_db(
|
|
settings,
|
|
_FakeSessionFactory(),
|
|
keys={"BACKUP_ENABLED"},
|
|
)
|
|
)
|
|
|
|
assert applied == 0
|
|
assert settings.BACKUP_ENABLED is False
|