steam-backlog-enforcer/steam_backlog_enforcer/tests/test_backups.py
2026-10-10 16:07:42 +02:00

117 lines
4.3 KiB
Python

"""Tests for _backups: state.json snapshots taken before destructive commands."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
from steam_backlog_enforcer import _backups as backups
from steam_backlog_enforcer import config
if TYPE_CHECKING:
from pathlib import Path
_OLD_ID = "20260101T000000Z-aaaaaa"
_NEW_ID = "20260202T000000Z-bbbbbb"
def _write_state(text: str = '{"current_app_id": 1}') -> None:
config.STATE_FILE.write_text(text, encoding="utf-8")
def _plant(
backup_id: str, *, state: str | None = '{"x": 1}', meta: str | None = None
) -> Path:
"""Create a backup directory by hand; ``None`` omits that file."""
directory = backups.BACKUPS_DIR / backup_id
directory.mkdir(parents=True)
if state is not None:
(directory / "state.json").write_text(state, encoding="utf-8")
if meta is not None:
(directory / "meta.json").write_text(meta, encoding="utf-8")
return directory
class TestBackupDir:
@pytest.mark.parametrize("bad", ["", "..", "../x", "20260101T000000Z", "a/b"])
def test_refuses_anything_that_is_not_an_id(self, bad: str) -> None:
with pytest.raises(backups.BackupError, match="Not a backup id"):
backups._backup_dir(bad)
def test_resolves_a_valid_id(self) -> None:
assert backups._backup_dir(_OLD_ID) == backups.BACKUPS_DIR / _OLD_ID
class TestCreateBackup:
def test_no_state_file_means_no_backup(self) -> None:
assert backups.create_backup("why") is None
assert not backups.BACKUPS_DIR.exists()
def test_copies_state_and_records_why(self) -> None:
_write_state()
backup = backups.create_backup("before reset")
assert backup is not None
assert backup.reason == "before reset"
assert backup.size_bytes == len('{"current_app_id": 1}')
copy = backups.BACKUPS_DIR / backup.id / "state.json"
assert copy.read_text(encoding="utf-8") == '{"current_app_id": 1}'
assert backup.to_json()["id"] == backup.id
class TestReadBackup:
def test_missing_meta_is_unreadable(self) -> None:
assert backups._read_backup(_plant(_OLD_ID)) is None
def test_corrupt_meta_is_unreadable(self) -> None:
assert backups._read_backup(_plant(_OLD_ID, meta="{nope")) is None
def test_missing_state_is_unreadable(self) -> None:
directory = _plant(_OLD_ID, state=None, meta="{}")
assert backups._read_backup(directory) is None
def test_absent_meta_fields_default_to_empty(self) -> None:
found = backups._read_backup(_plant(_OLD_ID, meta="{}"))
assert found is not None
assert (found.created_at, found.reason) == ("", "")
class TestListBackups:
def test_no_directory_is_empty(self) -> None:
assert backups.list_backups() == []
def test_newest_first_and_skips_junk(self) -> None:
_plant(_OLD_ID, meta='{"reason": "old"}')
_plant(_NEW_ID, meta='{"reason": "new"}')
_plant("20260303T000000Z-cccccc") # incomplete: no meta
(backups.BACKUPS_DIR / "not-an-id").mkdir()
assert [b.id for b in backups.list_backups()] == [_NEW_ID, _OLD_ID]
class TestRestoreBackup:
def test_replaces_state_and_backs_up_the_old_one(self) -> None:
_plant(_OLD_ID, state='{"current_app_id": 7}', meta="{}")
_write_state('{"current_app_id": 1}')
previous = backups.restore_backup(_OLD_ID)
assert json.loads(config.STATE_FILE.read_text("utf-8")) == {"current_app_id": 7}
assert previous is not None
assert previous.reason == f"before restoring {_OLD_ID}"
def test_restoring_over_no_state_has_no_previous(self) -> None:
_plant(_OLD_ID, state="{}", meta="{}")
assert backups.restore_backup(_OLD_ID) is None
assert config.STATE_FILE.read_text(encoding="utf-8") == "{}"
@pytest.mark.parametrize("state", [None, "{broken"])
def test_unreadable_backup_raises_and_keeps_state(self, state: str | None) -> None:
_plant(_OLD_ID, state=state, meta="{}")
_write_state()
with pytest.raises(backups.BackupError, match="unreadable"):
backups.restore_backup(_OLD_ID)
assert config.STATE_FILE.read_text(encoding="utf-8") == '{"current_app_id": 1}'
def test_malformed_id_raises(self) -> None:
with pytest.raises(backups.BackupError):
backups.restore_backup("../../etc")