2026-03-21 17:51:36 +01:00
|
|
|
"""Tests for config module."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
2026-05-28 07:21:29 +02:00
|
|
|
from steam_backlog_enforcer.config import (
|
2026-03-21 17:51:36 +01:00
|
|
|
Config,
|
|
|
|
|
State,
|
2026-03-25 19:19:52 +01:00
|
|
|
_atomic_write,
|
2026-03-21 17:51:36 +01:00
|
|
|
interactive_setup,
|
|
|
|
|
load_snapshot,
|
|
|
|
|
save_snapshot,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
2026-03-25 19:19:52 +01:00
|
|
|
class TestAtomicWrite:
|
|
|
|
|
"""Tests for _atomic_write."""
|
|
|
|
|
|
|
|
|
|
def test_writes_file(self, tmp_path: Path) -> None:
|
|
|
|
|
target = tmp_path / "out.json"
|
|
|
|
|
_atomic_write(target, '{"key": "value"}\n')
|
|
|
|
|
assert target.read_text(encoding="utf-8") == '{"key": "value"}\n'
|
|
|
|
|
|
|
|
|
|
def test_creates_parent_dirs(self, tmp_path: Path) -> None:
|
|
|
|
|
target = tmp_path / "sub" / "deep" / "out.json"
|
|
|
|
|
_atomic_write(target, "data")
|
|
|
|
|
assert target.read_text(encoding="utf-8") == "data"
|
|
|
|
|
|
|
|
|
|
def test_cleanup_on_write_error(self, tmp_path: Path) -> None:
|
|
|
|
|
target = tmp_path / "out.json"
|
|
|
|
|
with (
|
|
|
|
|
patch(
|
2026-05-28 07:21:29 +02:00
|
|
|
"steam_backlog_enforcer.config.os.write",
|
2026-03-25 19:19:52 +01:00
|
|
|
side_effect=OSError("disk full"),
|
|
|
|
|
),
|
|
|
|
|
pytest.raises(OSError, match="disk full"),
|
|
|
|
|
):
|
|
|
|
|
_atomic_write(target, "data")
|
|
|
|
|
assert not target.exists()
|
|
|
|
|
tmp_files = list(tmp_path.glob("*.tmp"))
|
|
|
|
|
assert tmp_files == []
|
|
|
|
|
|
|
|
|
|
def test_cleanup_on_replace_error(self, tmp_path: Path) -> None:
|
|
|
|
|
target = tmp_path / "out.json"
|
|
|
|
|
with (
|
|
|
|
|
patch.object(
|
|
|
|
|
type(target),
|
|
|
|
|
"replace",
|
|
|
|
|
side_effect=OSError("no perm"),
|
|
|
|
|
),
|
|
|
|
|
pytest.raises(OSError, match="no perm"),
|
|
|
|
|
):
|
|
|
|
|
_atomic_write(target, "data")
|
|
|
|
|
assert not target.exists()
|
|
|
|
|
tmp_files = list(tmp_path.glob("*.tmp"))
|
|
|
|
|
assert tmp_files == []
|
|
|
|
|
|
|
|
|
|
|
2026-03-21 17:51:36 +01:00
|
|
|
class TestConfig:
|
|
|
|
|
"""Tests for Config dataclass."""
|
|
|
|
|
|
|
|
|
|
def test_defaults(self) -> None:
|
|
|
|
|
cfg = Config()
|
|
|
|
|
assert cfg.steam_api_key == ""
|
|
|
|
|
assert cfg.steam_id == ""
|
|
|
|
|
assert cfg.block_store is True
|
|
|
|
|
assert cfg.kill_unauthorized_games is True
|
|
|
|
|
assert cfg.uninstall_other_games is True
|
|
|
|
|
assert cfg.desktop_notifications is True
|
|
|
|
|
|
|
|
|
|
def test_save(self, tmp_path: Path) -> None:
|
|
|
|
|
cfg = Config(steam_api_key="abc", steam_id="123")
|
|
|
|
|
config_dir = tmp_path / "cfg"
|
|
|
|
|
config_file = config_dir / "config.json"
|
|
|
|
|
with (
|
2026-05-28 07:21:29 +02:00
|
|
|
patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
|
|
|
|
|
patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file),
|
2026-03-21 17:51:36 +01:00
|
|
|
):
|
|
|
|
|
cfg.save()
|
|
|
|
|
data = json.loads(config_file.read_text(encoding="utf-8"))
|
|
|
|
|
assert data["steam_api_key"] == "abc"
|
|
|
|
|
assert data["steam_id"] == "123"
|
|
|
|
|
|
|
|
|
|
def test_load_existing(self, tmp_path: Path) -> None:
|
|
|
|
|
config_file = tmp_path / "config.json"
|
|
|
|
|
config_file.write_text(
|
|
|
|
|
json.dumps({"steam_api_key": "key1", "steam_id": "id1"}) + "\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
2026-05-28 07:21:29 +02:00
|
|
|
with patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file):
|
2026-03-21 17:51:36 +01:00
|
|
|
cfg = Config.load()
|
|
|
|
|
assert cfg.steam_api_key == "key1"
|
|
|
|
|
assert cfg.steam_id == "id1"
|
|
|
|
|
|
|
|
|
|
def test_load_missing(self, tmp_path: Path) -> None:
|
|
|
|
|
config_file = tmp_path / "nonexistent.json"
|
2026-05-28 07:21:29 +02:00
|
|
|
with patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file):
|
2026-03-21 17:51:36 +01:00
|
|
|
cfg = Config.load()
|
|
|
|
|
assert cfg.steam_api_key == ""
|
|
|
|
|
|
|
|
|
|
def test_load_extra_fields_ignored(self, tmp_path: Path) -> None:
|
|
|
|
|
config_file = tmp_path / "config.json"
|
|
|
|
|
config_file.write_text(
|
|
|
|
|
json.dumps({"steam_api_key": "k", "unknown_field": 42}) + "\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
2026-05-28 07:21:29 +02:00
|
|
|
with patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file):
|
2026-03-21 17:51:36 +01:00
|
|
|
cfg = Config.load()
|
|
|
|
|
assert cfg.steam_api_key == "k"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestState:
|
|
|
|
|
"""Tests for State dataclass."""
|
|
|
|
|
|
|
|
|
|
def test_defaults(self) -> None:
|
|
|
|
|
state = State()
|
|
|
|
|
assert state.current_app_id is None
|
|
|
|
|
assert state.current_game_name == ""
|
|
|
|
|
assert state.finished_app_ids == []
|
|
|
|
|
|
|
|
|
|
def test_save(self, tmp_path: Path) -> None:
|
|
|
|
|
state = State(current_app_id=100, current_game_name="TestGame")
|
|
|
|
|
config_dir = tmp_path / "cfg"
|
|
|
|
|
state_file = config_dir / "state.json"
|
|
|
|
|
with (
|
2026-05-28 07:21:29 +02:00
|
|
|
patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
|
|
|
|
|
patch("steam_backlog_enforcer.config.STATE_FILE", state_file),
|
2026-03-21 17:51:36 +01:00
|
|
|
):
|
|
|
|
|
state.save()
|
|
|
|
|
data = json.loads(state_file.read_text(encoding="utf-8"))
|
|
|
|
|
assert data["current_app_id"] == 100
|
|
|
|
|
assert data["current_game_name"] == "TestGame"
|
|
|
|
|
|
|
|
|
|
def test_load_existing(self, tmp_path: Path) -> None:
|
|
|
|
|
state_file = tmp_path / "state.json"
|
|
|
|
|
state_file.write_text(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"current_app_id": 50,
|
|
|
|
|
"current_game_name": "G",
|
|
|
|
|
"finished_app_ids": [1, 2],
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
+ "\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
2026-05-28 07:21:29 +02:00
|
|
|
with patch("steam_backlog_enforcer.config.STATE_FILE", state_file):
|
2026-03-21 17:51:36 +01:00
|
|
|
st = State.load()
|
|
|
|
|
assert st.current_app_id == 50
|
|
|
|
|
assert st.finished_app_ids == [1, 2]
|
|
|
|
|
|
|
|
|
|
def test_load_missing(self, tmp_path: Path) -> None:
|
|
|
|
|
state_file = tmp_path / "nonexistent.json"
|
2026-05-28 07:21:29 +02:00
|
|
|
with patch("steam_backlog_enforcer.config.STATE_FILE", state_file):
|
2026-03-21 17:51:36 +01:00
|
|
|
st = State.load()
|
|
|
|
|
assert st.current_app_id is None
|
|
|
|
|
|
2026-03-25 19:19:52 +01:00
|
|
|
def test_load_corrupt(self, tmp_path: Path) -> None:
|
|
|
|
|
state_file = tmp_path / "state.json"
|
|
|
|
|
state_file.write_text("not valid json{{", encoding="utf-8")
|
2026-05-28 07:21:29 +02:00
|
|
|
with patch("steam_backlog_enforcer.config.STATE_FILE", state_file):
|
2026-03-25 19:19:52 +01:00
|
|
|
st = State.load()
|
|
|
|
|
assert st.current_app_id is None
|
|
|
|
|
assert st.current_game_name == ""
|
|
|
|
|
|
2026-05-23 21:19:44 +02:00
|
|
|
def test_skip_for_days_records_iso_timestamp(self) -> None:
|
|
|
|
|
state = State()
|
|
|
|
|
state.skip_for_days(42, 7)
|
|
|
|
|
assert "42" in state.skipped_until
|
|
|
|
|
# Round-trip parse and check ~7 days in the future.
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
|
|
|
|
expiry = datetime.fromisoformat(state.skipped_until["42"])
|
|
|
|
|
delta = (expiry - datetime.now(timezone.utc)).total_seconds()
|
|
|
|
|
assert 6 * 86400 < delta <= 7 * 86400 + 1
|
|
|
|
|
|
|
|
|
|
def test_active_skipped_ids_returns_active(self) -> None:
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
|
|
|
|
state = State()
|
|
|
|
|
future = datetime.now(timezone.utc) + timedelta(days=3)
|
|
|
|
|
state.skipped_until["100"] = future.isoformat()
|
|
|
|
|
assert state.active_skipped_ids() == {100}
|
|
|
|
|
# Active entry retained.
|
|
|
|
|
assert "100" in state.skipped_until
|
|
|
|
|
|
|
|
|
|
def test_active_skipped_ids_prunes_expired(self) -> None:
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
|
|
|
|
state = State()
|
|
|
|
|
past = datetime.now(timezone.utc) - timedelta(days=1)
|
|
|
|
|
state.skipped_until["50"] = past.isoformat()
|
|
|
|
|
assert state.active_skipped_ids() == set()
|
|
|
|
|
assert "50" not in state.skipped_until
|
|
|
|
|
|
|
|
|
|
def test_active_skipped_ids_prunes_malformed(self) -> None:
|
|
|
|
|
state = State()
|
|
|
|
|
state.skipped_until["77"] = "not-a-date"
|
|
|
|
|
assert state.active_skipped_ids() == set()
|
|
|
|
|
assert "77" not in state.skipped_until
|
|
|
|
|
|
2026-03-21 17:51:36 +01:00
|
|
|
|
|
|
|
|
class TestSnapshot:
|
|
|
|
|
"""Tests for snapshot save/load."""
|
|
|
|
|
|
|
|
|
|
def test_save_and_load(self, tmp_path: Path) -> None:
|
|
|
|
|
config_dir = tmp_path / "cfg"
|
|
|
|
|
snap_file = config_dir / "snapshot.json"
|
|
|
|
|
with (
|
2026-05-28 07:21:29 +02:00
|
|
|
patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
|
|
|
|
|
patch("steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file),
|
2026-03-21 17:51:36 +01:00
|
|
|
):
|
|
|
|
|
data: list[dict[str, Any]] = [{"app_id": 1, "name": "G1"}]
|
|
|
|
|
save_snapshot(data)
|
|
|
|
|
loaded = load_snapshot()
|
|
|
|
|
assert loaded == data
|
|
|
|
|
|
|
|
|
|
def test_load_none(self, tmp_path: Path) -> None:
|
|
|
|
|
snap_file = tmp_path / "nonexistent.json"
|
2026-05-28 07:21:29 +02:00
|
|
|
with patch("steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file):
|
2026-03-21 17:51:36 +01:00
|
|
|
assert load_snapshot() is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestInteractiveSetup:
|
|
|
|
|
"""Tests for interactive_setup."""
|
|
|
|
|
|
|
|
|
|
def test_success(self, tmp_path: Path) -> None:
|
|
|
|
|
config_dir = tmp_path / "cfg"
|
|
|
|
|
config_file = config_dir / "config.json"
|
|
|
|
|
with (
|
2026-05-28 07:21:29 +02:00
|
|
|
patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
|
|
|
|
|
patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file),
|
2026-03-21 17:51:36 +01:00
|
|
|
patch("builtins.input", side_effect=["mykey", "myid"]),
|
|
|
|
|
):
|
|
|
|
|
cfg = interactive_setup()
|
|
|
|
|
assert cfg.steam_api_key == "mykey"
|
|
|
|
|
assert cfg.steam_id == "myid"
|
|
|
|
|
assert config_file.exists()
|
|
|
|
|
|
|
|
|
|
def test_empty_api_key_exits(self) -> None:
|
|
|
|
|
with (
|
|
|
|
|
patch("builtins.input", return_value=""),
|
|
|
|
|
pytest.raises(SystemExit),
|
|
|
|
|
):
|
|
|
|
|
interactive_setup()
|
|
|
|
|
|
|
|
|
|
def test_empty_steam_id_exits(self, tmp_path: Path) -> None:
|
|
|
|
|
config_dir = tmp_path / "cfg"
|
|
|
|
|
config_file = config_dir / "config.json"
|
|
|
|
|
with (
|
2026-05-28 07:21:29 +02:00
|
|
|
patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
|
|
|
|
|
patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file),
|
2026-03-21 17:51:36 +01:00
|
|
|
patch("builtins.input", side_effect=["key", ""]),
|
|
|
|
|
pytest.raises(SystemExit),
|
|
|
|
|
):
|
|
|
|
|
interactive_setup()
|