Files
notify-bridge/packages/server/tests/test_snapshot.py
T
alexei.dolgolyov 7cbb02b1ef
Build and Test / test-backend (push) Successful in 2m38s
Build and Test / test-frontend (push) Successful in 9m44s
Build and Test / build-image (push) Failing after 17m9s
feat(db): pre-migration SQLite snapshots via VACUUM INTO
Take a consistent, atomic copy of the DB at lifespan startup BEFORE
migrations run, so a botched future upgrade is recoverable by restoring
a single file instead of a data-loss incident.

Uses SQLite's VACUUM INTO — safe under WAL, cannot tear against
concurrent writes. Best-effort: failures are logged, never raised —
the main DB remains the source of truth.

Configurable via NOTIFY_BRIDGE_PRE_MIGRATE_SNAPSHOT_KEEP (default 5;
0 disables). Snapshots land in ``data_dir/backups/pre-migrate-<ts>.db``
and the N oldest are pruned each boot.
2026-04-23 19:53:15 +03:00

117 lines
4.2 KiB
Python

"""Pre-migration snapshot: atomic copy + retention pruning."""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from notify_bridge_server.database.snapshot import (
prune_old_snapshots,
snapshot_and_prune,
snapshot_database,
)
@pytest.fixture
async def sqlite_engine(tmp_path: Path):
"""Tiny SQLite DB with one table + one row, closed cleanly after the test."""
db_path = tmp_path / "app.db"
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
async with engine.begin() as conn:
await conn.execute(text("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"))
await conn.execute(text("INSERT INTO t (v) VALUES ('seed')"))
yield engine, db_path, tmp_path / "backups"
await engine.dispose()
class TestSnapshot:
@pytest.mark.asyncio
async def test_creates_consistent_copy(self, sqlite_engine) -> None:
engine, _db, backups = sqlite_engine
dest = await snapshot_database(engine, backups)
assert dest is not None
assert dest.exists()
# Can open the snapshot and see the seed row — proves it's a real DB copy.
copy = create_async_engine(f"sqlite+aiosqlite:///{dest}")
async with copy.connect() as c:
result = await c.execute(text("SELECT v FROM t"))
rows = result.all()
await copy.dispose()
assert rows == [("seed",)]
@pytest.mark.asyncio
async def test_skips_when_db_missing(self, tmp_path: Path) -> None:
# Engine pointing at a path that doesn't exist yet.
engine = create_async_engine(
f"sqlite+aiosqlite:///{tmp_path / 'does-not-exist.db'}"
)
try:
dest = await snapshot_database(engine, tmp_path / "backups")
finally:
await engine.dispose()
assert dest is None
@pytest.mark.asyncio
async def test_rejects_unsafe_label(self, sqlite_engine) -> None:
engine, _db, backups = sqlite_engine
dest = await snapshot_database(engine, backups, label="bad'; DROP TABLE t;--")
assert dest is None
class TestPrune:
def _make_snapshot(self, backups: Path, age_seconds: int) -> Path:
backups.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc) - timedelta(seconds=age_seconds)
name = f"pre-migrate-{ts.strftime('%Y-%m-%dT%H-%M-%S')}.db"
p = backups / name
p.write_bytes(b"x")
mtime = ts.timestamp()
import os
os.utime(p, (mtime, mtime))
return p
def test_keeps_n_newest(self, tmp_path: Path) -> None:
backups = tmp_path / "backups"
for age in (100, 80, 60, 40, 20, 0):
self._make_snapshot(backups, age)
deleted = prune_old_snapshots(backups, keep=3)
remaining = sorted(backups.glob("pre-migrate-*.db"))
assert len(deleted) == 3
assert len(remaining) == 3
def test_keep_zero_deletes_all(self, tmp_path: Path) -> None:
backups = tmp_path / "backups"
for age in (30, 20, 10):
self._make_snapshot(backups, age)
prune_old_snapshots(backups, keep=0)
assert list(backups.glob("pre-migrate-*.db")) == []
def test_missing_dir_is_noop(self, tmp_path: Path) -> None:
assert prune_old_snapshots(tmp_path / "never-created", keep=5) == []
class TestSnapshotAndPrune:
@pytest.mark.asyncio
async def test_keep_zero_disables(self, sqlite_engine) -> None:
engine, _db, backups = sqlite_engine
result = await snapshot_and_prune(engine, backups, keep=0)
assert result is None
assert not backups.exists() or list(backups.glob("*.db")) == []
@pytest.mark.asyncio
async def test_end_to_end(self, sqlite_engine) -> None:
engine, _db, backups = sqlite_engine
# Run twice — second run should keep both snapshots (keep=5).
a = await snapshot_and_prune(engine, backups, keep=5)
# Guarantee distinct filenames (timestamp has second resolution).
await asyncio.sleep(1.05)
b = await snapshot_and_prune(engine, backups, keep=5)
assert a and b and a != b
assert a.exists() and b.exists()