mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-07-30 23:39:50 +00:00
270 lines
8.9 KiB
Python
270 lines
8.9 KiB
Python
"""Regression tests for the staged, startup-only database restore workflow."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import modules.database_restore as database_restore_module
|
|
from modules.database_restore import (
|
|
DatabaseRestoreError,
|
|
apply_pending_database_restore,
|
|
apply_pending_restores_from_config,
|
|
pending_restore_path,
|
|
stage_database_restore,
|
|
validate_restore_database,
|
|
)
|
|
from modules.db_migrations import MigrationRunner
|
|
|
|
|
|
def _create_meshcore_database(path: Path, marker: str) -> None:
|
|
with sqlite3.connect(path) as conn:
|
|
MigrationRunner(conn, logging.getLogger(__name__)).run()
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO bot_metadata (key, value) VALUES ('restore.marker', ?)",
|
|
(marker,),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _marker(path: Path) -> str:
|
|
with sqlite3.connect(path) as conn:
|
|
row = conn.execute(
|
|
"SELECT value FROM bot_metadata WHERE key = 'restore.marker'"
|
|
).fetchone()
|
|
assert row is not None
|
|
return str(row[0])
|
|
|
|
|
|
def test_stage_does_not_replace_live_database(tmp_path: Path) -> None:
|
|
active = tmp_path / "active.db"
|
|
backup = tmp_path / "backup.db"
|
|
_create_meshcore_database(active, "active")
|
|
_create_meshcore_database(backup, "backup")
|
|
|
|
pending = stage_database_restore(backup, active)
|
|
|
|
assert pending == pending_restore_path(active)
|
|
assert pending.exists()
|
|
assert _marker(active) == "active"
|
|
assert _marker(pending) == "backup"
|
|
assert pending.stat().st_mode & 0o007 == 0
|
|
|
|
|
|
def test_apply_pending_restore_creates_recovery_and_clears_sidecars(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
active = tmp_path / "active.db"
|
|
backup = tmp_path / "backup.db"
|
|
_create_meshcore_database(active, "active")
|
|
_create_meshcore_database(backup, "backup")
|
|
pending = stage_database_restore(backup, active)
|
|
Path(f"{active}-wal").write_bytes(b"stale")
|
|
Path(f"{active}-shm").write_bytes(b"stale")
|
|
|
|
result = apply_pending_database_restore(active)
|
|
|
|
assert result is not None
|
|
assert result.database_path == active.resolve()
|
|
assert result.recovery_backup_path is not None
|
|
assert result.recovery_backup_path.exists()
|
|
assert _marker(result.recovery_backup_path) == "active"
|
|
assert _marker(active) == "backup"
|
|
assert not pending.exists()
|
|
assert not Path(f"{active}-wal").exists()
|
|
assert not Path(f"{active}-shm").exists()
|
|
|
|
|
|
@pytest.mark.parametrize("contents", [b"not sqlite", b"SQLite format 3\x00broken"])
|
|
def test_invalid_pending_restore_never_changes_active_database(
|
|
tmp_path: Path,
|
|
contents: bytes,
|
|
) -> None:
|
|
active = tmp_path / "active.db"
|
|
_create_meshcore_database(active, "active")
|
|
pending_restore_path(active).write_bytes(contents)
|
|
|
|
with pytest.raises(DatabaseRestoreError):
|
|
apply_pending_database_restore(active)
|
|
|
|
assert _marker(active) == "active"
|
|
|
|
|
|
def test_foreign_sqlite_database_is_rejected(tmp_path: Path) -> None:
|
|
foreign = tmp_path / "foreign.db"
|
|
with sqlite3.connect(foreign) as conn:
|
|
conn.execute("CREATE TABLE unrelated (id INTEGER)")
|
|
conn.commit()
|
|
|
|
with pytest.raises(DatabaseRestoreError, match="missing table"):
|
|
validate_restore_database(foreign)
|
|
|
|
|
|
def test_recognizable_pre_migration_database_is_accepted_and_upgradable(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
legacy = tmp_path / "legacy.db"
|
|
active = tmp_path / "active.db"
|
|
with sqlite3.connect(legacy) as conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE bot_metadata (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE generic_cache (
|
|
id INTEGER PRIMARY KEY,
|
|
cache_key TEXT UNIQUE NOT NULL,
|
|
cache_value TEXT NOT NULL,
|
|
cache_type TEXT NOT NULL,
|
|
expires_at TIMESTAMP NOT NULL
|
|
);
|
|
CREATE TABLE channels (
|
|
channel_idx INTEGER PRIMARY KEY,
|
|
channel_name TEXT NOT NULL
|
|
);
|
|
INSERT INTO bot_metadata (key, value)
|
|
VALUES ('restore.marker', 'legacy');
|
|
"""
|
|
)
|
|
conn.commit()
|
|
|
|
validate_restore_database(legacy)
|
|
stage_database_restore(legacy, active)
|
|
apply_pending_database_restore(active)
|
|
with sqlite3.connect(active) as conn:
|
|
MigrationRunner(conn, logging.getLogger(__name__)).run()
|
|
assert conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
|
assert _marker(active) == "legacy"
|
|
|
|
|
|
def test_restore_size_limit_is_enforced_before_staging(tmp_path: Path) -> None:
|
|
active = tmp_path / "active.db"
|
|
backup = tmp_path / "backup.db"
|
|
_create_meshcore_database(active, "active")
|
|
_create_meshcore_database(backup, "backup")
|
|
|
|
with pytest.raises(DatabaseRestoreError, match="safety limit"):
|
|
stage_database_restore(backup, active, max_bytes=1024)
|
|
|
|
assert not pending_restore_path(active).exists()
|
|
assert _marker(active) == "active"
|
|
|
|
|
|
def test_post_replace_failure_automatically_rolls_back(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
active = tmp_path / "active.db"
|
|
backup = tmp_path / "backup.db"
|
|
_create_meshcore_database(active, "active")
|
|
_create_meshcore_database(backup, "backup")
|
|
stage_database_restore(backup, active)
|
|
real_validate = database_restore_module.validate_restore_database
|
|
validation_count = 0
|
|
|
|
def fail_first_post_replace_validation(path: str | Path) -> None:
|
|
nonlocal validation_count
|
|
validation_count += 1
|
|
# pending validation, recovery validation, then replaced active DB
|
|
if validation_count == 3:
|
|
raise DatabaseRestoreError("simulated post-replace validation failure")
|
|
real_validate(path)
|
|
|
|
monkeypatch.setattr(
|
|
database_restore_module,
|
|
"validate_restore_database",
|
|
fail_first_post_replace_validation,
|
|
)
|
|
|
|
with pytest.raises(DatabaseRestoreError, match="candidate was re-queued"):
|
|
apply_pending_database_restore(active)
|
|
|
|
assert _marker(active) == "active"
|
|
assert _marker(pending_restore_path(active)) == "backup"
|
|
|
|
|
|
def test_configured_bot_and_viewer_restores_apply_before_startup(tmp_path: Path) -> None:
|
|
active_bot = tmp_path / "bot.db"
|
|
active_viewer = tmp_path / "viewer.db"
|
|
backup_bot = tmp_path / "bot-backup.db"
|
|
backup_viewer = tmp_path / "viewer-backup.db"
|
|
for path, marker in (
|
|
(active_bot, "old-bot"),
|
|
(active_viewer, "old-viewer"),
|
|
(backup_bot, "new-bot"),
|
|
(backup_viewer, "new-viewer"),
|
|
):
|
|
_create_meshcore_database(path, marker)
|
|
stage_database_restore(backup_bot, active_bot)
|
|
stage_database_restore(backup_viewer, active_viewer)
|
|
config_path = tmp_path / "config.ini"
|
|
config_path.write_text(
|
|
"[Bot]\n"
|
|
"db_path = bot.db\n"
|
|
"local_dir_path = local\n"
|
|
"[Web_Viewer]\n"
|
|
"db_path = viewer.db\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
results = apply_pending_restores_from_config(config_path)
|
|
|
|
assert {result.database_path for result in results} == {
|
|
active_bot.resolve(),
|
|
active_viewer.resolve(),
|
|
}
|
|
assert _marker(active_bot) == "new-bot"
|
|
assert _marker(active_viewer) == "new-viewer"
|
|
|
|
|
|
def test_configured_restore_failure_rolls_back_and_requeues_prior_database(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
active_bot = tmp_path / "bot.db"
|
|
active_viewer = tmp_path / "viewer.db"
|
|
backup_bot = tmp_path / "bot-backup.db"
|
|
backup_viewer = tmp_path / "viewer-backup.db"
|
|
for path, marker in (
|
|
(active_bot, "old-bot"),
|
|
(active_viewer, "old-viewer"),
|
|
(backup_bot, "new-bot"),
|
|
(backup_viewer, "new-viewer"),
|
|
):
|
|
_create_meshcore_database(path, marker)
|
|
stage_database_restore(backup_bot, active_bot)
|
|
stage_database_restore(backup_viewer, active_viewer)
|
|
config_path = tmp_path / "config.ini"
|
|
config_path.write_text(
|
|
"[Bot]\n"
|
|
"db_path = bot.db\n"
|
|
"[Web_Viewer]\n"
|
|
"db_path = viewer.db\n",
|
|
encoding="utf-8",
|
|
)
|
|
real_apply = database_restore_module.apply_pending_database_restore
|
|
|
|
def fail_viewer(path: str | Path):
|
|
if Path(path).resolve() == active_viewer.resolve():
|
|
raise DatabaseRestoreError("simulated viewer restore failure")
|
|
return real_apply(path)
|
|
|
|
monkeypatch.setattr(
|
|
database_restore_module,
|
|
"apply_pending_database_restore",
|
|
fail_viewer,
|
|
)
|
|
|
|
with pytest.raises(DatabaseRestoreError, match="rolled back and re-queued"):
|
|
apply_pending_restores_from_config(config_path)
|
|
|
|
assert _marker(active_bot) == "old-bot"
|
|
assert _marker(active_viewer) == "old-viewer"
|
|
assert _marker(pending_restore_path(active_bot)) == "new-bot"
|
|
assert _marker(pending_restore_path(active_viewer)) == "new-viewer"
|