130 lines
5.0 KiB
Python
130 lines
5.0 KiB
Python
"""Tests for the #66140 fix: pre-update snapshots cover every profile."""
|
|
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.backup as backup
|
|
|
|
|
|
def _mk_profile(home: Path, jobs: int = 0) -> Path:
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
(home / "config.yaml").write_text("model: {}\n", encoding="utf-8")
|
|
if jobs:
|
|
cron = home / "cron"
|
|
cron.mkdir(exist_ok=True)
|
|
payload = {"jobs": [{"id": f"j{i}"} for i in range(jobs)]}
|
|
(cron / "jobs.json").write_text(json.dumps(payload), encoding="utf-8")
|
|
return home
|
|
|
|
|
|
@pytest.fixture()
|
|
def profiles(monkeypatch, tmp_path):
|
|
"""default (invoking) + work + sparks profile homes."""
|
|
default_home = _mk_profile(tmp_path / "home", jobs=3)
|
|
work = _mk_profile(tmp_path / "home" / "profiles" / "work", jobs=5)
|
|
sparks = _mk_profile(tmp_path / "home" / "profiles" / "sparks", jobs=0)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.profiles._get_default_hermes_home", lambda: default_home
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.profiles._get_profiles_root", lambda: tmp_path / "home" / "profiles"
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.profiles._PROFILE_ID_RE",
|
|
re.compile(r"^[a-z0-9][a-z0-9_-]*$"),
|
|
raising=False,
|
|
)
|
|
return {"default": default_home, "work": work, "sparks": sparks}
|
|
|
|
|
|
class TestSiblingEnumeration:
|
|
def test_excludes_invoking_profile(self, profiles):
|
|
names = [n for n, _ in backup._sibling_profile_homes(profiles["default"])]
|
|
assert names == ["sparks", "work"]
|
|
|
|
def test_invoked_from_named_profile_includes_default(self, profiles):
|
|
names = [n for n, _ in backup._sibling_profile_homes(profiles["work"])]
|
|
assert names == ["default", "sparks"]
|
|
|
|
def test_never_raises(self, monkeypatch, tmp_path):
|
|
def _boom():
|
|
raise RuntimeError("no profiles module")
|
|
|
|
monkeypatch.setattr("hermes_cli.profiles._get_default_hermes_home", _boom)
|
|
assert backup._sibling_profile_homes(tmp_path) == []
|
|
|
|
|
|
class TestAllProfileSnapshots:
|
|
def test_each_sibling_snapshotted_into_own_home(self, profiles):
|
|
result = backup.create_pre_update_snapshots_all_profiles(
|
|
invoking_home=profiles["default"], keep=1
|
|
)
|
|
assert set(result) == {"work", "sparks"}
|
|
for name, snap_id in result.items():
|
|
snap_dir = profiles[name] / "state-snapshots" / snap_id
|
|
assert snap_dir.is_dir()
|
|
assert (snap_dir / "config.yaml").is_file()
|
|
assert "pre-update" in snap_id
|
|
# invoking profile untouched by THIS call
|
|
assert not (profiles["default"] / "state-snapshots").exists()
|
|
|
|
def test_size_cap_forwarded(self, profiles):
|
|
big = profiles["work"] / "state.db"
|
|
big.write_bytes(b"\x00" * 4096)
|
|
result = backup.create_pre_update_snapshots_all_profiles(
|
|
invoking_home=profiles["default"], keep=1, max_file_size=1024
|
|
)
|
|
snap_dir = profiles["work"] / "state-snapshots" / result["work"]
|
|
assert not (snap_dir / "state.db").exists() # capped out
|
|
assert (snap_dir / "config.yaml").is_file() # small files captured
|
|
|
|
def test_one_failing_sibling_does_not_block_others(self, profiles, monkeypatch):
|
|
real = backup.create_quick_snapshot
|
|
|
|
def _flaky(label=None, hermes_home=None, keep=None, max_file_size=None):
|
|
if hermes_home == profiles["work"]:
|
|
raise OSError("disk full")
|
|
return real(
|
|
label=label, hermes_home=hermes_home, keep=keep,
|
|
max_file_size=max_file_size,
|
|
)
|
|
|
|
monkeypatch.setattr(backup, "create_quick_snapshot", _flaky)
|
|
result = backup.create_pre_update_snapshots_all_profiles(
|
|
invoking_home=profiles["default"]
|
|
)
|
|
assert "sparks" in result and "work" not in result
|
|
|
|
|
|
class TestPerProfileCronRestore:
|
|
def test_lost_jobs_restored_from_own_snapshot(self, profiles):
|
|
snaps = backup.create_pre_update_snapshots_all_profiles(
|
|
invoking_home=profiles["default"], keep=1
|
|
)
|
|
# simulate the migration emptying work's jobs.json
|
|
jobs_path = profiles["work"] / "cron" / "jobs.json"
|
|
jobs_path.write_text(json.dumps({"jobs": []}), encoding="utf-8")
|
|
|
|
restored = backup.restore_cron_jobs_all_profiles(
|
|
snaps, invoking_home=profiles["default"]
|
|
)
|
|
assert len(restored) == 1
|
|
assert restored[0]["profile"] == "work"
|
|
assert restored[0]["job_count"] == 5
|
|
live = json.loads(jobs_path.read_text(encoding="utf-8"))
|
|
assert len(live["jobs"]) == 5
|
|
|
|
def test_healthy_profiles_untouched(self, profiles):
|
|
snaps = backup.create_pre_update_snapshots_all_profiles(
|
|
invoking_home=profiles["default"], keep=1
|
|
)
|
|
assert backup.restore_cron_jobs_all_profiles(
|
|
snaps, invoking_home=profiles["default"]
|
|
) == []
|
|
|
|
def test_empty_map_is_noop(self, profiles):
|
|
assert backup.restore_cron_jobs_all_profiles({}) == []
|