diff --git a/steam_backlog_enforcer/hltb.py b/steam_backlog_enforcer/hltb.py index ab76878..13fa64e 100644 --- a/steam_backlog_enforcer/hltb.py +++ b/steam_backlog_enforcer/hltb.py @@ -117,7 +117,7 @@ async def _get_auth_token( ts = int(time.time() * 1000) headers = { "User-Agent": ( - "Mozilla/5.0 (X11; Linux x86_64; rv:136.0)" " Gecko/20100101 Firefox/136.0" + "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0" ), "referer": "https://howlongtobeat.com/", } @@ -313,7 +313,7 @@ async def _fetch_batch( "content-type": "application/json", "accept": "*/*", "User-Agent": ( - "Mozilla/5.0 (X11; Linux x86_64; rv:136.0)" " Gecko/20100101 Firefox/136.0" + "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0" ), "referer": "https://howlongtobeat.com/", "x-auth-token": token, diff --git a/steam_backlog_enforcer/library_hider.py b/steam_backlog_enforcer/library_hider.py index e4b3020..21e4d61 100644 --- a/steam_backlog_enforcer/library_hider.py +++ b/steam_backlog_enforcer/library_hider.py @@ -44,8 +44,6 @@ def _get_shared_js_ws_url() -> str | None: """Query the CDP HTTP endpoint and return the SharedJSContext WS URL.""" url = f"http://127.0.0.1:{_CDP_PORT}/json" try: - if not url.startswith(("http://", "https://")): - return None with urllib.request.urlopen(url, timeout=5) as resp: targets = json.loads(resp.read()) except (OSError, ValueError): @@ -247,7 +245,7 @@ def hide_other_games( collectionStore.SetAppsAsHidden(newIds, true); }} // Unhide the allowed game if it was hidden. - const allowedId = {allowed_app_id if allowed_app_id is not None else 'null'}; + const allowedId = {allowed_app_id if allowed_app_id is not None else "null"}; if (allowedId !== null && collectionStore.BIsHidden(allowedId)) {{ collectionStore.SetAppsAsHidden([allowedId], false); }} diff --git a/steam_backlog_enforcer/tests/__init__.py b/steam_backlog_enforcer/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/steam_backlog_enforcer/tests/test_config.py b/steam_backlog_enforcer/tests/test_config.py new file mode 100644 index 0000000..40415c1 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_config.py @@ -0,0 +1,177 @@ +"""Tests for config module.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any +from unittest.mock import patch + +import pytest + +from python_pkg.steam_backlog_enforcer.config import ( + Config, + State, + interactive_setup, + load_snapshot, + save_snapshot, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestConfig: + """Tests for Config dataclass.""" + + def test_defaults(self) -> None: + cfg = Config() + assert cfg.steam_api_key == "" + assert cfg.steam_id == "" + assert cfg.skip_app_ids == [] + 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 ( + patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir), + patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file), + ): + 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", + ) + with patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file): + 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" + with patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file): + 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", + ) + with patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file): + 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 ( + patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir), + patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file), + ): + 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", + ) + with patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file): + 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" + with patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file): + st = State.load() + assert st.current_app_id is None + + +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 ( + patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir), + patch("python_pkg.steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file), + ): + 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" + with patch("python_pkg.steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file): + 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 ( + patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir), + patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file), + 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 ( + patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir), + patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file), + patch("builtins.input", side_effect=["key", ""]), + pytest.raises(SystemExit), + ): + interactive_setup() diff --git a/steam_backlog_enforcer/tests/test_enforce_loop.py b/steam_backlog_enforcer/tests/test_enforce_loop.py new file mode 100644 index 0000000..9b7b250 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_enforce_loop.py @@ -0,0 +1,352 @@ +"""Tests for _enforce_loop module.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from python_pkg.steam_backlog_enforcer._enforce_loop import ( + _enforce_auto_install, + _enforce_hide_games, + _enforce_loop_iteration, + _enforce_setup, + _guard_installed_games, + do_enforce, + get_all_owned_app_ids, +) +from python_pkg.steam_backlog_enforcer.config import Config, State + +PKG = "python_pkg.steam_backlog_enforcer._enforce_loop" + + +class TestGetAllOwnedAppIds: + """Tests for get_all_owned_app_ids.""" + + def test_from_snapshot(self) -> None: + snap = [{"app_id": 1}, {"app_id": 2}] + with patch(f"{PKG}.load_snapshot", return_value=snap): + assert get_all_owned_app_ids(Config()) == [1, 2] + + def test_no_snapshot_falls_back_to_api(self) -> None: + mock_client = MagicMock() + mock_client.get_owned_games.return_value = [ + {"appid": 10}, + {"appid": 20}, + ] + with ( + patch(f"{PKG}.load_snapshot", return_value=None), + patch(f"{PKG}.SteamAPIClient", return_value=mock_client), + ): + result = get_all_owned_app_ids( + Config(steam_api_key="k", steam_id="i"), + ) + assert result == [10, 20] + + def test_api_fails(self) -> None: + with ( + patch(f"{PKG}.load_snapshot", return_value=None), + patch( + f"{PKG}.SteamAPIClient", + side_effect=OSError("fail"), + ), + ): + assert get_all_owned_app_ids(Config()) == [] + + def test_empty_snapshot_falls_through_to_api(self) -> None: + mock_client = MagicMock() + mock_client.get_owned_games.return_value = [{"appid": 5}] + with ( + patch(f"{PKG}.load_snapshot", return_value=[]), + patch(f"{PKG}.SteamAPIClient", return_value=mock_client), + ): + assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [5] + + +class TestGuardInstalledGames: + """Tests for _guard_installed_games.""" + + def test_removes_unauthorized(self) -> None: + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(999, "Bad Game")], + ), + patch(f"{PKG}.uninstall_game", return_value=True), + patch(f"{PKG}.send_notification"), + ): + assert _guard_installed_games(440) == 1 + + def test_skips_allowed(self) -> None: + with patch( + f"{PKG}.get_installed_games", + return_value=[(440, "TF2")], + ): + assert _guard_installed_games(440) == 0 + + def test_skips_protected(self) -> None: + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(228980, "Runtime")], + ), + patch(f"{PKG}.PROTECTED_APP_IDS", {228980}), + ): + assert _guard_installed_games(440) == 0 + + def test_uninstall_fails(self) -> None: + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(999, "Bad")], + ), + patch(f"{PKG}.uninstall_game", return_value=False), + ): + assert _guard_installed_games(440) == 0 + + +class TestEnforceSetup: + """Tests for _enforce_setup.""" + + def test_block_store_success(self) -> None: + config = Config(block_store=True, uninstall_other_games=False) + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.block_store", return_value=True), + patch(f"{PKG}._echo"), + patch(f"{PKG}._enforce_auto_install"), + patch(f"{PKG}._enforce_hide_games"), + ): + _enforce_setup(config, state) + + def test_block_store_fail(self) -> None: + config = Config(block_store=True, uninstall_other_games=False) + state = State() + with ( + patch(f"{PKG}.block_store", return_value=False), + patch(f"{PKG}._echo") as mock_echo, + patch(f"{PKG}._enforce_auto_install"), + patch(f"{PKG}._enforce_hide_games"), + ): + _enforce_setup(config, state) + assert any("FAILED" in str(c) for c in mock_echo.call_args_list) + + def test_no_block_store(self) -> None: + config = Config(block_store=False, uninstall_other_games=False) + state = State() + with ( + patch(f"{PKG}.block_store") as mock_block, + patch(f"{PKG}._echo"), + patch(f"{PKG}._enforce_auto_install"), + patch(f"{PKG}._enforce_hide_games"), + ): + _enforce_setup(config, state) + mock_block.assert_not_called() + + def test_uninstall_other_games(self) -> None: + config = Config(uninstall_other_games=True, block_store=False) + state = State(current_app_id=1) + with ( + patch(f"{PKG}.uninstall_other_games", return_value=3), + patch(f"{PKG}._echo"), + patch(f"{PKG}._enforce_auto_install"), + patch(f"{PKG}._enforce_hide_games"), + ): + _enforce_setup(config, state) + + +class TestEnforceAutoInstall: + """Tests for _enforce_auto_install.""" + + def test_no_app_id(self) -> None: + _enforce_auto_install(Config(), State()) + + def test_already_installed(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{PKG}._echo"), + ): + _enforce_auto_install(Config(), state) + + def test_installs_successfully(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.is_game_installed", return_value=False), + patch(f"{PKG}.install_game", return_value=True), + patch(f"{PKG}.send_notification"), + patch(f"{PKG}._echo"), + ): + _enforce_auto_install(Config(steam_id="i"), state) + + def test_install_fails(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.is_game_installed", return_value=False), + patch(f"{PKG}.install_game", return_value=False), + patch(f"{PKG}._echo") as mock_echo, + ): + _enforce_auto_install(Config(steam_id="i"), state) + assert any("manually" in str(c) for c in mock_echo.call_args_list) + + +class TestEnforceHideGames: + """Tests for _enforce_hide_games.""" + + def test_hides_some(self) -> None: + state = State(current_app_id=1) + with ( + patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]), + patch(f"{PKG}.hide_other_games", return_value=2), + patch(f"{PKG}._echo"), + ): + _enforce_hide_games(Config(), state) + + def test_already_hidden(self) -> None: + state = State(current_app_id=1) + with ( + patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]), + patch(f"{PKG}.hide_other_games", return_value=0), + patch(f"{PKG}._echo") as mock_echo, + ): + _enforce_hide_games(Config(), state) + assert any("already" in str(c) for c in mock_echo.call_args_list) + + def test_no_owned_ids(self) -> None: + state = State(current_app_id=1) + with ( + patch(f"{PKG}.get_all_owned_app_ids", return_value=[]), + patch(f"{PKG}._echo") as mock_echo, + ): + _enforce_hide_games(Config(), state) + assert any("skipped" in str(c) for c in mock_echo.call_args_list) + + +class TestEnforceLoopIteration: + """Tests for _enforce_loop_iteration.""" + + def test_kills_unauthorized(self) -> None: + config = Config( + kill_unauthorized_games=True, + uninstall_other_games=False, + ) + state = State(current_app_id=1, current_game_name="G") + with ( + patch( + f"{PKG}.enforce_allowed_game", + return_value=[(1234, 999)], + ), + patch(f"{PKG}.send_notification"), + patch(f"{PKG}._echo"), + patch(f"{PKG}.is_game_installed", return_value=True), + ): + _enforce_loop_iteration(config, state) + + def test_no_kill(self) -> None: + config = Config( + kill_unauthorized_games=False, + uninstall_other_games=False, + ) + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.enforce_allowed_game") as mock_enforce, + patch(f"{PKG}.is_game_installed", return_value=True), + ): + _enforce_loop_iteration(config, state) + mock_enforce.assert_not_called() + + def test_guards_installed(self) -> None: + config = Config( + kill_unauthorized_games=False, + uninstall_other_games=True, + ) + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}._guard_installed_games", return_value=1), + patch(f"{PKG}._echo"), + patch(f"{PKG}.is_game_installed", return_value=True), + ): + _enforce_loop_iteration(config, state) + + def test_guard_removes_zero(self) -> None: + config = Config( + kill_unauthorized_games=False, + uninstall_other_games=True, + ) + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}._guard_installed_games", return_value=0), + patch(f"{PKG}.is_game_installed", return_value=True), + ): + _enforce_loop_iteration(config, state) + + def test_reinstalls_missing(self) -> None: + config = Config( + kill_unauthorized_games=False, + uninstall_other_games=False, + ) + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.is_game_installed", return_value=False), + patch(f"{PKG}.install_game") as mock_install, + ): + _enforce_loop_iteration(config, state) + mock_install.assert_called_once() + + def test_no_app_id_skip_reinstall(self) -> None: + config = Config( + kill_unauthorized_games=False, + uninstall_other_games=False, + ) + state = State(current_app_id=None) + with patch(f"{PKG}.is_game_installed") as mock_installed: + _enforce_loop_iteration(config, state) + mock_installed.assert_not_called() + + +class TestDoEnforce: + """Tests for do_enforce.""" + + def test_no_game(self) -> None: + with patch(f"{PKG}._echo") as mock_echo: + do_enforce(Config(), State()) + assert any("No game" in str(c) for c in mock_echo.call_args_list) + + def test_keyboard_interrupt(self) -> None: + state = State(current_app_id=1, current_game_name="G") + config = Config() + fresh = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}._enforce_setup"), + patch(f"{PKG}._echo"), + patch.object(State, "load", return_value=fresh), + patch( + f"{PKG}._enforce_loop_iteration", + side_effect=KeyboardInterrupt, + ), + patch(f"{PKG}.time.sleep"), + ): + do_enforce(config, state) + + def test_runs_iterations(self) -> None: + state = State(current_app_id=1, current_game_name="G") + config = Config() + fresh = State(current_app_id=1, current_game_name="G") + call_count = 0 + + def side_effect(*_args: object, **_kwargs: object) -> None: + nonlocal call_count + call_count += 1 + if call_count >= 2: + raise KeyboardInterrupt + + with ( + patch(f"{PKG}._enforce_setup"), + patch(f"{PKG}._echo"), + patch.object(State, "load", return_value=fresh), + patch( + f"{PKG}._enforce_loop_iteration", + side_effect=side_effect, + ), + patch(f"{PKG}.time.sleep"), + ): + do_enforce(config, state) + assert call_count == 2 diff --git a/steam_backlog_enforcer/tests/test_enforcer.py b/steam_backlog_enforcer/tests/test_enforcer.py new file mode 100644 index 0000000..a2721f8 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_enforcer.py @@ -0,0 +1,192 @@ +"""Tests for enforcer module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +from python_pkg.steam_backlog_enforcer.enforcer import ( + enforce_allowed_game, + get_running_steam_game_pids, + kill_process, + send_notification, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestGetRunningPids: + """Tests for get_running_steam_game_pids.""" + + def test_finds_steam_pid(self, tmp_path: Path) -> None: + proc_dir = tmp_path / "proc" + pid_dir = proc_dir / "12345" + pid_dir.mkdir(parents=True) + environ = b"HOME=/home/user\x00SteamAppId=440\x00PATH=/usr/bin" + (pid_dir / "environ").write_bytes(environ) + + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.Path", + return_value=proc_dir, + ): + result = get_running_steam_game_pids() + assert result == {12345: 440} + + def test_skips_non_digit_entries(self, tmp_path: Path) -> None: + proc_dir = tmp_path / "proc" + proc_dir.mkdir(parents=True) + (proc_dir / "self").mkdir() + (proc_dir / "cpuinfo").touch() + + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.Path", + return_value=proc_dir, + ): + result = get_running_steam_game_pids() + assert result == {} + + def test_handles_permission_error(self, tmp_path: Path) -> None: + proc_dir = tmp_path / "proc" + pid_dir = proc_dir / "99" + pid_dir.mkdir(parents=True) + # No environ file -> OSError when reading + + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.Path", + return_value=proc_dir, + ): + result = get_running_steam_game_pids() + assert result == {} + + def test_skips_non_digit_steam_app_id(self, tmp_path: Path) -> None: + proc_dir = tmp_path / "proc" + pid_dir = proc_dir / "100" + pid_dir.mkdir(parents=True) + environ = b"SteamAppId=notanumber\x00" + (pid_dir / "environ").write_bytes(environ) + + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.Path", + return_value=proc_dir, + ): + result = get_running_steam_game_pids() + assert result == {} + + def test_no_steam_env(self, tmp_path: Path) -> None: + proc_dir = tmp_path / "proc" + pid_dir = proc_dir / "200" + pid_dir.mkdir(parents=True) + environ = b"HOME=/home/user\x00PATH=/usr/bin\x00" + (pid_dir / "environ").write_bytes(environ) + + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.Path", + return_value=proc_dir, + ): + result = get_running_steam_game_pids() + assert result == {} + + +class TestEnforceAllowedGame: + """Tests for enforce_allowed_game.""" + + def test_no_violations(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids", + return_value={100: 440}, + ): + result = enforce_allowed_game(440) + assert result == [] + + def test_kills_unauthorized(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids", + return_value={100: 570, 200: 440}, + ), + patch( + "python_pkg.steam_backlog_enforcer.enforcer.kill_process" + ) as mock_kill, + ): + result = enforce_allowed_game(440, kill_unauthorized=True) + assert result == [(100, 570)] + mock_kill.assert_called_once_with(100, 570) + + def test_skips_app_id_zero(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids", + return_value={100: 0}, + ): + result = enforce_allowed_game(440) + assert result == [] + + def test_detects_without_killing(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids", + return_value={100: 570}, + ): + result = enforce_allowed_game(440, kill_unauthorized=False) + assert result == [(100, 570)] + + def test_allowed_none(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids", + return_value={100: 570}, + ), + patch( + "python_pkg.steam_backlog_enforcer.enforcer.kill_process" + ) as mock_kill, + ): + result = enforce_allowed_game(None, kill_unauthorized=True) + assert result == [(100, 570)] + mock_kill.assert_called_once_with(100, 570) + + +class TestKillProcess: + """Tests for kill_process.""" + + def test_kill_success(self) -> None: + with patch("python_pkg.steam_backlog_enforcer.enforcer.os.kill") as mock_kill: + kill_process(123, 440) + mock_kill.assert_called_once() + + def test_process_already_gone(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.os.kill", + side_effect=ProcessLookupError, + ): + kill_process(123, 440) # Should not raise + + def test_permission_error(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.os.kill", + side_effect=PermissionError, + ): + kill_process(123, 440) # Should not raise + + +class TestSendNotification: + """Tests for send_notification.""" + + def test_sends(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.subprocess.run" + ) as mock_run: + send_notification("Title", "Body") + mock_run.assert_called_once() + + def test_handles_missing_notify_send(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.subprocess.run", + side_effect=FileNotFoundError, + ): + send_notification("Title", "Body") # Should not raise + + def test_handles_os_error(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.enforcer.subprocess.run", + side_effect=OSError, + ): + send_notification("Title", "Body") # Should not raise diff --git a/steam_backlog_enforcer/tests/test_game_install.py b/steam_backlog_enforcer/tests/test_game_install.py new file mode 100644 index 0000000..6dcfe66 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_game_install.py @@ -0,0 +1,475 @@ +"""Tests for game_install module.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from python_pkg.steam_backlog_enforcer.game_install import ( + _echo, + _ensure_steam_running, + _get_real_user, + _get_uid_gid_for_user, + _read_install_dir, + _remove_manifest, + _trigger_steam_install, + get_installed_games, + install_game, + is_game_installed, +) + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +class TestEcho: + """Tests for _echo.""" + + def test_default(self, capsys: pytest.CaptureFixture[str]) -> None: + _echo("hello") + assert capsys.readouterr().out == "hello\n" + + def test_custom_end(self, capsys: pytest.CaptureFixture[str]) -> None: + _echo("hi", end="") + assert capsys.readouterr().out == "hi" + + def test_empty(self, capsys: pytest.CaptureFixture[str]) -> None: + _echo() + assert capsys.readouterr().out == "\n" + + def test_flush(self, capsys: pytest.CaptureFixture[str]) -> None: + _echo("x", flush=True) + assert capsys.readouterr().out == "x\n" + + +class TestTriggerSteamInstall: + """Tests for _trigger_steam_install.""" + + def test_success(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.run" + ) as mock_run: + result = _trigger_steam_install(440, "TF2") + assert result is True + mock_run.assert_called_once() + + def test_file_not_found(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", + side_effect=FileNotFoundError, + ): + result = _trigger_steam_install(440, "TF2") + assert result is False + + def test_os_error(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", + side_effect=OSError, + ): + result = _trigger_steam_install(440, "TF2") + assert result is False + + def test_timeout(self) -> None: + import subprocess + + with patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", + side_effect=subprocess.TimeoutExpired("cmd", 15), + ): + result = _trigger_steam_install(440, "TF2") + assert result is False + + +class TestGetRealUser: + """Tests for _get_real_user.""" + + def test_sudo_user(self) -> None: + with patch.dict(os.environ, {"SUDO_USER": "alice", "USER": "root"}): + assert _get_real_user() == "alice" + + def test_regular_user(self) -> None: + with patch.dict(os.environ, {"USER": "bob"}, clear=False): + env = os.environ.copy() + env.pop("SUDO_USER", None) + with patch.dict(os.environ, env, clear=True): + assert _get_real_user() == "bob" + + +class TestGetUidGid: + """Tests for _get_uid_gid_for_user.""" + + def test_known_user(self) -> None: + mock_pw = MagicMock() + mock_pw.pw_uid = 1001 + mock_pw.pw_gid = 1001 + with patch( + "python_pkg.steam_backlog_enforcer.game_install.pwd.getpwnam", + return_value=mock_pw, + ): + assert _get_uid_gid_for_user("alice") == (1001, 1001) + + def test_unknown_user(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.game_install.pwd.getpwnam", + side_effect=KeyError, + ): + assert _get_uid_gid_for_user("nobody") == (1000, 1000) + + +class TestIsGameInstalled: + """Tests for is_game_installed.""" + + def test_installed(self, tmp_path: Path) -> None: + manifest = tmp_path / "appmanifest_440.acf" + manifest.touch() + with patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path + ): + assert is_game_installed(440) is True + + def test_not_installed(self, tmp_path: Path) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path + ): + assert is_game_installed(440) is False + + +class TestEnsureSteamRunning: + """Tests for _ensure_steam_running.""" + + def test_already_running(self) -> None: + mock_result = MagicMock(returncode=0) + with patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", + return_value=mock_result, + ): + _ensure_steam_running() + + def test_not_running_starts_as_non_root(self) -> None: + mock_result = MagicMock(returncode=1) + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", + return_value=mock_result, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen" + ) as mock_popen, + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", + return_value=1000, + ), + patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep"), + ): + _ensure_steam_running() + mock_popen.assert_called_once() + + def test_not_running_starts_as_root(self) -> None: + mock_result = MagicMock(returncode=1) + mock_pw = MagicMock() + mock_pw.pw_uid = 1000 + mock_pw.pw_gid = 1000 + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", + return_value=mock_result, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen" + ) as mock_popen, + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", + return_value=0, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._get_real_user", + return_value="alice", + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._get_uid_gid_for_user", + return_value=(1000, 1000), + ), + patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep"), + ): + _ensure_steam_running() + mock_popen.assert_called_once() + + def test_pgrep_not_found(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", + side_effect=FileNotFoundError, + ), + patch("python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen"), + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", + return_value=1000, + ), + patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep"), + ): + _ensure_steam_running() + + def test_steam_executable_not_found(self) -> None: + mock_result = MagicMock(returncode=1) + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", + return_value=mock_result, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen", + side_effect=FileNotFoundError, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", + return_value=1000, + ), + ): + _ensure_steam_running() + + +class TestInstallGame: + """Tests for install_game.""" + + def test_already_installed(self, tmp_path: Path) -> None: + manifest = tmp_path / "appmanifest_440.acf" + manifest.touch() + with patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path + ): + assert install_game(440, "TF2", "steam123") is True + + def test_use_steam_protocol_success(self, tmp_path: Path) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", + tmp_path, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._trigger_steam_install", + return_value=True, + ), + ): + assert install_game(440, "TF2", "s1", use_steam_protocol=True) is True + + def test_use_steam_protocol_fallback(self, tmp_path: Path) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", + tmp_path, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._trigger_steam_install", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", + return_value=1000, + ), + ): + assert install_game(440, "TF2", "s1", use_steam_protocol=True) is True + assert (tmp_path / "appmanifest_440.acf").exists() + + def test_manifest_write_as_root(self, tmp_path: Path) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", + tmp_path, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", + return_value=0, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._get_real_user", + return_value="alice", + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._get_uid_gid_for_user", + return_value=(1001, 1001), + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.chown" + ) as mock_chown, + ): + assert install_game(440, "TF2", "s1") is True + mock_chown.assert_called_once() + + def test_manifest_write_failure(self, tmp_path: Path) -> None: + # Make steamapps path not writable + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", + tmp_path / "nonexistent" / "deep", + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", + return_value=1000, + ), + ): + assert install_game(440, "TF2", "s1") is False + + def test_empty_game_name(self, tmp_path: Path) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", + tmp_path, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", + return_value=1000, + ), + ): + assert install_game(440, "", "s1") is True + + def test_manifest_not_root_no_chown(self, tmp_path: Path) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", + tmp_path, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", + return_value=1000, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.chown" + ) as mock_chown, + ): + assert install_game(440, "TF2", "s1") is True + mock_chown.assert_not_called() + + def test_root_user_is_root(self, tmp_path: Path) -> None: + """When real user IS root, don't chown.""" + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", + tmp_path, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", + return_value=0, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install._get_real_user", + return_value="root", + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.os.chown" + ) as mock_chown, + ): + assert install_game(440, "TF2", "s1") is True + mock_chown.assert_not_called() + + +class TestGetInstalledGames: + """Tests for get_installed_games.""" + + def test_parses_manifests(self, tmp_path: Path) -> None: + manifest = tmp_path / "appmanifest_440.acf" + manifest.write_text('"appid"\t\t"440"\n"name"\t\t"Team Fortress 2"\n') + with patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path + ): + result = get_installed_games() + assert result == [(440, "Team Fortress 2")] + + def test_no_name(self, tmp_path: Path) -> None: + manifest = tmp_path / "appmanifest_440.acf" + manifest.write_text('"appid"\t\t"440"\n') + with patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path + ): + result = get_installed_games() + assert result == [(440, "Unknown (440)")] + + def test_empty_dir(self, tmp_path: Path) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path + ): + result = get_installed_games() + assert result == [] + + def test_no_appid_match(self, tmp_path: Path) -> None: + manifest = tmp_path / "appmanifest_440.acf" + manifest.write_text('"name"\t\t"NoAppId"\n') + with patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path + ): + result = get_installed_games() + assert result == [] + + +class TestReadInstallDir: + """Tests for _read_install_dir.""" + + def test_reads_dir(self, tmp_path: Path) -> None: + manifest = tmp_path / "appmanifest_440.acf" + manifest.write_text('"installdir"\t\t"Team Fortress 2"\n') + with patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path + ): + result = _read_install_dir(manifest) + assert result == tmp_path / "common" / "Team Fortress 2" + + def test_no_match(self, tmp_path: Path) -> None: + manifest = tmp_path / "appmanifest_440.acf" + manifest.write_text('"appid"\t\t"440"\n') + with patch( + "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path + ): + assert _read_install_dir(manifest) is None + + def test_missing_file(self, tmp_path: Path) -> None: + manifest = tmp_path / "nonexistent.acf" + assert _read_install_dir(manifest) is None + + def test_os_error(self, tmp_path: Path) -> None: + manifest = MagicMock() + manifest.exists.return_value = True + manifest.read_text.side_effect = OSError + assert _read_install_dir(manifest) is None + + +class TestRemoveManifest: + """Tests for _remove_manifest.""" + + def test_removes(self, tmp_path: Path) -> None: + manifest = tmp_path / "appmanifest_440.acf" + manifest.touch() + assert _remove_manifest(manifest, "TF2", 440) is True + assert not manifest.exists() + + def test_already_gone(self, tmp_path: Path) -> None: + manifest = tmp_path / "nonexistent.acf" + assert _remove_manifest(manifest, "TF2", 440) is True + + def test_os_error(self) -> None: + manifest = MagicMock() + manifest.exists.return_value = True + manifest.unlink.side_effect = OSError + assert _remove_manifest(manifest, "TF2", 440) is False diff --git a/steam_backlog_enforcer/tests/test_game_install_part2.py b/steam_backlog_enforcer/tests/test_game_install_part2.py new file mode 100644 index 0000000..099b4a2 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_game_install_part2.py @@ -0,0 +1,163 @@ +"""Tests for game_install module — part 2 (missing coverage).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from python_pkg.steam_backlog_enforcer.game_install import ( + _remove_game_dirs, + uninstall_game, + uninstall_other_games, +) + +if TYPE_CHECKING: + from pathlib import Path + +PKG = "python_pkg.steam_backlog_enforcer.game_install" + + +class TestRemoveGameDirs: + """Tests for _remove_game_dirs.""" + + def test_removes_install_dir(self, tmp_path: Path) -> None: + install_dir = tmp_path / "common" / "MyGame" + install_dir.mkdir(parents=True) + (install_dir / "game.exe").touch() + with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): + result = _remove_game_dirs(install_dir, 440) + assert result is True + assert not install_dir.exists() + + def test_install_dir_none(self, tmp_path: Path) -> None: + with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): + result = _remove_game_dirs(None, 440) + assert result is True + + def test_install_dir_not_exists(self, tmp_path: Path) -> None: + missing = tmp_path / "common" / "Missing" + with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): + result = _remove_game_dirs(missing, 440) + assert result is True + + def test_install_dir_remove_fails(self, tmp_path: Path) -> None: + install_dir = tmp_path / "common" / "MyGame" + install_dir.mkdir(parents=True) + with ( + patch(f"{PKG}.STEAMAPPS_PATH", tmp_path), + patch(f"{PKG}.shutil.rmtree", side_effect=OSError("perm")), + ): + result = _remove_game_dirs(install_dir, 440) + assert result is False + + def test_removes_cache_dirs(self, tmp_path: Path) -> None: + for subdir in ("shadercache", "compatdata"): + (tmp_path / subdir / "440").mkdir(parents=True) + with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): + result = _remove_game_dirs(None, 440) + assert result is True + assert not (tmp_path / "shadercache" / "440").exists() + assert not (tmp_path / "compatdata" / "440").exists() + + def test_cache_dir_remove_oserror_suppressed(self, tmp_path: Path) -> None: + (tmp_path / "shadercache" / "440").mkdir(parents=True) + call_count = 0 + + def fake_rmtree(path: object, **_kw: object) -> None: + nonlocal call_count + call_count += 1 + msg = "perm" + raise OSError(msg) + + with ( + patch(f"{PKG}.STEAMAPPS_PATH", tmp_path), + patch(f"{PKG}.shutil.rmtree", side_effect=fake_rmtree), + ): + result = _remove_game_dirs(None, 440) + assert result is True + + +class TestUninstallGame: + """Tests for uninstall_game.""" + + def test_success(self, tmp_path: Path) -> None: + manifest = tmp_path / "appmanifest_440.acf" + manifest.write_text('"installdir"\t\t"TF2"\n', encoding="utf-8") + install_dir = tmp_path / "common" / "TF2" + install_dir.mkdir(parents=True) + with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): + result = uninstall_game(440, "TF2") + assert result is True + + def test_manifest_removal_fails(self) -> None: + mock_manifest = MagicMock() + mock_manifest.exists.return_value = True + mock_manifest.unlink.side_effect = OSError + with ( + patch(f"{PKG}.STEAMAPPS_PATH", MagicMock()), + patch(f"{PKG}._read_install_dir", return_value=None), + patch(f"{PKG}._remove_manifest", return_value=False), + patch(f"{PKG}._remove_game_dirs", return_value=True), + ): + result = uninstall_game(440, "TF2") + assert result is False + + def test_game_dirs_removal_fails(self) -> None: + with ( + patch(f"{PKG}._read_install_dir", return_value=None), + patch(f"{PKG}._remove_manifest", return_value=True), + patch(f"{PKG}._remove_game_dirs", return_value=False), + ): + result = uninstall_game(440, "TF2") + assert result is False + + +class TestUninstallOtherGames: + """Tests for uninstall_other_games.""" + + def test_keeps_allowed(self) -> None: + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(440, "TF2"), (730, "CS")], + ), + patch(f"{PKG}.uninstall_game", return_value=True) as mock_uninstall, + ): + count = uninstall_other_games(440) + assert count == 1 + mock_uninstall.assert_called_once_with(730, "CS") + + def test_skips_protected(self) -> None: + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(228980, "Redist")], + ), + patch(f"{PKG}.uninstall_game") as mock_uninstall, + ): + count = uninstall_other_games(None) + assert count == 0 + mock_uninstall.assert_not_called() + + def test_uninstall_fails(self) -> None: + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(999, "GameX")], + ), + patch(f"{PKG}.uninstall_game", return_value=False), + ): + count = uninstall_other_games(None) + assert count == 0 + + def test_all_allowed_or_protected(self) -> None: + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(440, "TF2"), (228980, "Redist")], + ), + patch(f"{PKG}.uninstall_game") as mock_uninstall, + ): + count = uninstall_other_games(440) + assert count == 0 + mock_uninstall.assert_not_called() diff --git a/steam_backlog_enforcer/tests/test_hltb.py b/steam_backlog_enforcer/tests/test_hltb.py new file mode 100644 index 0000000..9e07c6c --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb.py @@ -0,0 +1,474 @@ +"""Tests for hltb module.""" + +from __future__ import annotations + +import asyncio +import json +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +from typing_extensions import Self + +from python_pkg.steam_backlog_enforcer.hltb import ( + HLTBResult, + _build_search_payload, + _fetch_batch, + _get_auth_token, + _get_hltb_search_url, + _pick_best_hltb_entry, + _search_one, + _SearchCtx, + _similarity, + load_hltb_cache, + save_hltb_cache, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestHltbCache: + """Tests for HLTB cache I/O.""" + + def test_load_cache_exists(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8") + with patch( + "python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file + ): + result = load_hltb_cache() + assert result == {440: 10.5} + + def test_load_cache_missing(self, tmp_path: Path) -> None: + cache_file = tmp_path / "nonexistent.json" + with patch( + "python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file + ): + assert load_hltb_cache() == {} + + def test_load_cache_corrupt(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + cache_file.write_text("not json", encoding="utf-8") + with patch( + "python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file + ): + assert load_hltb_cache() == {} + + def test_save_cache(self, tmp_path: Path) -> None: + cache_file = tmp_path / "hltb_cache.json" + with ( + patch("python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file), + patch("python_pkg.steam_backlog_enforcer.hltb.CONFIG_DIR", tmp_path), + ): + save_hltb_cache({440: 10.5}) + assert cache_file.exists() + + def test_save_cache_os_error(self, tmp_path: Path) -> None: + cache_file = MagicMock() + cache_file.write_text = MagicMock(side_effect=OSError) + with ( + patch("python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file), + patch( + "python_pkg.steam_backlog_enforcer.hltb.CONFIG_DIR", + MagicMock(mkdir=MagicMock()), + ), + ): + save_hltb_cache({440: 10.5}) # Should not raise + + +class TestGetHltbSearchUrl: + """Tests for _get_hltb_search_url.""" + + def test_discovers_url(self) -> None: + mock_info = MagicMock() + mock_info.search_url = "/api/search/abc" + with patch("python_pkg.steam_backlog_enforcer.hltb.HTMLRequests") as mock_html: + mock_html.send_website_request_getcode.return_value = mock_info + mock_html.BASE_URL = "https://howlongtobeat.com" + url = _get_hltb_search_url() + assert url == "https://howlongtobeat.com/api/search/abc" + + def test_fallback_url(self) -> None: + with patch("python_pkg.steam_backlog_enforcer.hltb.HTMLRequests") as mock_html: + mock_html.send_website_request_getcode.return_value = None + url = _get_hltb_search_url() + assert url == "https://howlongtobeat.com/api/finder" + + def test_first_returns_none_second_returns_info(self) -> None: + mock_info = MagicMock() + mock_info.search_url = "/api/search/xyz" + with patch("python_pkg.steam_backlog_enforcer.hltb.HTMLRequests") as mock_html: + mock_html.send_website_request_getcode.side_effect = [None, mock_info] + mock_html.BASE_URL = "https://howlongtobeat.com" + url = _get_hltb_search_url() + assert url == "https://howlongtobeat.com/api/search/xyz" + + def test_exception_fallback(self) -> None: + with patch("python_pkg.steam_backlog_enforcer.hltb.HTMLRequests") as mock_html: + mock_html.send_website_request_getcode.side_effect = RuntimeError + url = _get_hltb_search_url() + assert url == "https://howlongtobeat.com/api/finder" + + +class TestGetAuthToken: + """Tests for _get_auth_token.""" + + def test_success(self) -> None: + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"token": "abc123"}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + + result = asyncio.run( + _get_auth_token("https://howlongtobeat.com/api/finder", mock_session) + ) + assert result == "abc123" + + def test_non_200(self) -> None: + mock_resp = AsyncMock() + mock_resp.status = 500 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + + result = asyncio.run( + _get_auth_token("https://howlongtobeat.com/api/finder", mock_session) + ) + assert result is None + + def test_client_error(self) -> None: + mock_session = MagicMock() + ctx = AsyncMock() + ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError) + ctx.__aexit__ = AsyncMock(return_value=False) + mock_session.get = MagicMock(return_value=ctx) + + result = asyncio.run( + _get_auth_token("https://howlongtobeat.com/api/finder", mock_session) + ) + assert result is None + + +class TestSimilarity: + """Tests for _similarity.""" + + def test_identical(self) -> None: + assert _similarity("hello", "hello") == 1.0 + + def test_different(self) -> None: + assert _similarity("abc", "xyz") < 0.5 + + def test_case_insensitive(self) -> None: + assert _similarity("Hello", "hello") == 1.0 + + +class TestBuildSearchPayload: + """Tests for _build_search_payload.""" + + def test_returns_json(self) -> None: + payload = _build_search_payload("Half-Life 2") + data = json.loads(payload) + assert data["searchType"] == "games" + assert data["searchTerms"] == ["Half-Life", "2"] + + +class TestPickBestHltbEntry: + """Tests for _pick_best_hltb_entry.""" + + def test_empty(self) -> None: + assert _pick_best_hltb_entry("game", []) is None + + def test_single(self) -> None: + entry: dict[str, Any] = {"game_name": "Game", "comp_100": 3600} + result = _pick_best_hltb_entry("Game", [(entry, 1.0)]) + assert result is not None + assert result[0]["game_name"] == "Game" + + def test_prefers_full_edition_colon(self) -> None: + demo: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800} + full: dict[str, Any] = { + "game_name": "FAITH: The Unholy Trinity", + "comp_100": 7200, + } + result = _pick_best_hltb_entry("FAITH", [(demo, 1.0), (full, 0.8)]) + assert result is not None + assert result[0]["game_name"] == "FAITH: The Unholy Trinity" + + def test_prefers_full_edition_dash(self) -> None: + demo: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800} + full: dict[str, Any] = {"game_name": "FAITH - Complete", "comp_100": 7200} + result = _pick_best_hltb_entry("FAITH", [(demo, 1.0), (full, 0.8)]) + assert result is not None + assert result[0]["game_name"] == "FAITH - Complete" + + def test_falls_back_to_highest_similarity(self) -> None: + a: dict[str, Any] = {"game_name": "ABC", "comp_100": 3600} + b: dict[str, Any] = {"game_name": "DEF", "comp_100": 7200} + result = _pick_best_hltb_entry("ABC", [(a, 0.9), (b, 0.7)]) + assert result is not None + assert result[1] == 0.9 + + +class _FakeResponse: + """Async context manager mimicking aiohttp response.""" + + def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: + self.status = status + self._json_data = json_data or {} + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *args: object) -> None: + pass + + async def json(self) -> dict[str, Any]: + return self._json_data + + +def _make_session(resp: _FakeResponse) -> MagicMock: + session = MagicMock() + session.post.return_value = resp + return session + + +def _make_ctx( + session: MagicMock, + *, + cache: dict[int, float] | None = None, + progress_cb: Any = None, +) -> _SearchCtx: + return _SearchCtx( + session=session, + search_url="https://example.com/search", + headers={}, + cache=cache if cache is not None else {}, + counter={"done": 0, "found": 0}, + total=1, + progress_cb=progress_cb, + ) + + +class TestSearchOne: + """Tests for _search_one.""" + + def test_found(self) -> None: + resp = _FakeResponse( + 200, + { + "data": [ + { + "game_name": "TF2", + "game_alias": "", + "comp_100": 180000, + "game_id": 12345, + } + ], + }, + ) + ctx = _make_ctx(_make_session(resp)) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is not None + assert result.app_id == 440 + + def test_not_found(self) -> None: + resp = _FakeResponse(200, {"data": []}) + ctx = _make_ctx(_make_session(resp)) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is None + assert ctx.cache[440] == -1 + + def test_error(self) -> None: + session = MagicMock() + session.post.side_effect = aiohttp.ClientError("fail") + ctx = _make_ctx(session) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is None + + def test_non_200(self) -> None: + resp = _FakeResponse(500) + ctx = _make_ctx(_make_session(resp)) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is None + + def test_with_progress_cb(self) -> None: + resp = _FakeResponse(200, {"data": []}) + cb = MagicMock() + ctx = _make_ctx(_make_session(resp), progress_cb=cb) + asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + cb.assert_called_once() + + def test_low_similarity_skipped(self) -> None: + resp = _FakeResponse( + 200, + { + "data": [ + { + "game_name": "Completely Different Name", + "game_alias": "", + "comp_100": 3600, + "game_id": 1, + } + ], + }, + ) + ctx = _make_ctx(_make_session(resp)) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is None + + def test_zero_comp_100_skipped(self) -> None: + resp = _FakeResponse( + 200, + { + "data": [ + { + "game_name": "TF2", + "game_alias": "", + "comp_100": 0, + "game_id": 1, + } + ], + }, + ) + ctx = _make_ctx(_make_session(resp)) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is None + + def test_alias_match(self) -> None: + resp = _FakeResponse( + 200, + { + "data": [ + { + "game_name": "Team Fortress 2", + "game_alias": "TF2", + "comp_100": 180000, + "game_id": 12345, + } + ], + }, + ) + ctx = _make_ctx(_make_session(resp)) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is not None + + def test_full_edition_colon(self) -> None: + resp = _FakeResponse( + 200, + { + "data": [ + { + "game_name": "TF2: Complete", + "game_alias": "", + "comp_100": 180000, + "game_id": 99, + } + ], + }, + ) + ctx = _make_ctx(_make_session(resp)) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is not None + + def test_full_edition_dash(self) -> None: + resp = _FakeResponse( + 200, + { + "data": [ + { + "game_name": "TF2 - Complete", + "game_alias": "", + "comp_100": 180000, + "game_id": 99, + } + ], + }, + ) + ctx = _make_ctx(_make_session(resp)) + result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + assert result is not None + + def test_save_interval(self) -> None: + """Trigger the _SAVE_INTERVAL branch.""" + resp = _FakeResponse(200, {"data": []}) + ctx = _make_ctx(_make_session(resp)) + # Set done to one less than _SAVE_INTERVAL so it triggers save + from python_pkg.steam_backlog_enforcer.hltb import _SAVE_INTERVAL + + ctx.counter["done"] = _SAVE_INTERVAL - 1 + with patch( + "python_pkg.steam_backlog_enforcer.hltb.save_hltb_cache" + ) as mock_save: + asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + mock_save.assert_called_once() + + +class TestFetchBatchHltb: + """Tests for _fetch_batch (the hltb version).""" + + def test_no_token(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url", + return_value="https://example.com", + ), + patch( + "python_pkg.steam_backlog_enforcer.hltb._get_auth_token", + new_callable=AsyncMock, + return_value=None, + ), + ): + results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None)) + assert results == [] + + def test_with_token(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url", + return_value="https://example.com", + ), + patch( + "python_pkg.steam_backlog_enforcer.hltb._get_auth_token", + new_callable=AsyncMock, + return_value="token123", + ), + patch( + "python_pkg.steam_backlog_enforcer.hltb._search_one", + new_callable=AsyncMock, + return_value=HLTBResult( + app_id=440, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + ), + ), + ): + results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None)) + assert len(results) == 1 + + def test_filters_none_results(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url", + return_value="https://example.com", + ), + patch( + "python_pkg.steam_backlog_enforcer.hltb._get_auth_token", + new_callable=AsyncMock, + return_value="token123", + ), + patch( + "python_pkg.steam_backlog_enforcer.hltb._search_one", + new_callable=AsyncMock, + return_value=None, + ), + ): + results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None)) + assert results == [] diff --git a/steam_backlog_enforcer/tests/test_hltb_part2.py b/steam_backlog_enforcer/tests/test_hltb_part2.py new file mode 100644 index 0000000..2253828 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_part2.py @@ -0,0 +1,135 @@ +"""Tests for hltb module — part 2 (missing coverage).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from python_pkg.steam_backlog_enforcer.hltb import ( + HLTB_BASE_URL, + HLTBResult, + fetch_hltb_times_cached, + get_hltb_submit_url, +) + +PKG = "python_pkg.steam_backlog_enforcer.hltb" + + +class TestFetchHltbTimesCached: + """Tests for fetch_hltb_times_cached.""" + + def test_all_cached(self) -> None: + with ( + patch(f"{PKG}.load_hltb_cache", return_value={440: 50.0}), + ): + result = fetch_hltb_times_cached([(440, "TF2")]) + assert result == {440: 50.0} + + def test_uncached_games_fetched(self) -> None: + with ( + patch(f"{PKG}.load_hltb_cache", return_value={440: 50.0}), + patch(f"{PKG}.fetch_hltb_times") as mock_fetch, + patch(f"{PKG}.save_hltb_cache") as mock_save, + patch(f"{PKG}.time.monotonic", side_effect=[0.0, 2.0]), + ): + # fetch_hltb_times modifies cache in-place + def add_to_cache( + games: object, + cache: dict[int, float] | None = None, + progress_cb: object = None, + ) -> list[object]: + if cache is not None: + cache[730] = 20.0 + return [] + + mock_fetch.side_effect = add_to_cache + result = fetch_hltb_times_cached( + [(440, "TF2"), (730, "CS")], + ) + assert result[440] == 50.0 + assert result[730] == 20.0 + mock_save.assert_called_once() + + def test_uncached_with_progress_cb(self) -> None: + cb = MagicMock() + with ( + patch(f"{PKG}.load_hltb_cache", return_value={}), + patch(f"{PKG}.fetch_hltb_times") as mock_fetch, + patch(f"{PKG}.save_hltb_cache"), + patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]), + ): + mock_fetch.return_value = [] + result = fetch_hltb_times_cached( + [(440, "TF2")], + progress_cb=cb, + ) + assert 440 not in result or result.get(440) == -1 + + def test_uncached_zero_elapsed(self) -> None: + """Covers the elapsed == 0 branch for rate calculation.""" + with ( + patch(f"{PKG}.load_hltb_cache", return_value={}), + patch(f"{PKG}.fetch_hltb_times") as mock_fetch, + patch(f"{PKG}.save_hltb_cache"), + patch(f"{PKG}.time.monotonic", side_effect=[5.0, 5.0]), + ): + mock_fetch.return_value = [] + fetch_hltb_times_cached([(440, "TF2")]) + + def test_found_count(self) -> None: + """Covers the found count in logging.""" + with ( + patch(f"{PKG}.load_hltb_cache", return_value={}), + patch(f"{PKG}.fetch_hltb_times") as mock_fetch, + patch(f"{PKG}.save_hltb_cache"), + patch(f"{PKG}.time.monotonic", side_effect=[0.0, 3.0]), + ): + + def add_found( + games: object, + cache: dict[int, float] | None = None, + progress_cb: object = None, + ) -> list[object]: + if cache is not None: + cache[440] = 50.0 + cache[730] = -1 + return [] + + mock_fetch.side_effect = add_found + result = fetch_hltb_times_cached( + [(440, "TF2"), (730, "CS")], + ) + assert result[440] == 50.0 + assert result[730] == -1 + + +class TestGetHltbSubmitUrl: + """Tests for get_hltb_submit_url.""" + + def test_found(self) -> None: + mock_result = HLTBResult( + app_id=0, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + hltb_game_id=12345, + ) + with patch(f"{PKG}.fetch_hltb_times", return_value=[mock_result]): + url = get_hltb_submit_url("TF2") + assert url == f"{HLTB_BASE_URL}/submit/game/12345" + + def test_not_found_empty(self) -> None: + with patch(f"{PKG}.fetch_hltb_times", return_value=[]): + url = get_hltb_submit_url("Unknown Game") + assert url is None + + def test_not_found_no_id(self) -> None: + mock_result = HLTBResult( + app_id=0, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + hltb_game_id=0, + ) + with patch(f"{PKG}.fetch_hltb_times", return_value=[mock_result]): + url = get_hltb_submit_url("TF2") + assert url is None diff --git a/steam_backlog_enforcer/tests/test_hltb_part3.py b/steam_backlog_enforcer/tests/test_hltb_part3.py new file mode 100644 index 0000000..e17d2c2 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_part3.py @@ -0,0 +1,45 @@ +"""Tests for hltb module - part 3 (fetch_hltb_times).""" + +from __future__ import annotations + +from unittest.mock import patch + +from python_pkg.steam_backlog_enforcer.hltb import ( + HLTBResult, + fetch_hltb_times, +) + + +class TestFetchHltbTimes: + """Tests for fetch_hltb_times.""" + + def test_empty(self) -> None: + assert fetch_hltb_times([]) == [] + + def test_calls_batch(self) -> None: + mock_result = HLTBResult( + app_id=440, game_name="TF2", completionist_hours=50.0, similarity=1.0 + ) + with patch( + "python_pkg.steam_backlog_enforcer.hltb._fetch_batch", + return_value=[mock_result], + ): + results = fetch_hltb_times([(440, "TF2")]) + assert len(results) == 1 + + def test_none_cache(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.hltb._fetch_batch", + return_value=[], + ): + results = fetch_hltb_times([(440, "TF2")]) + assert results == [] + + def test_explicit_cache(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.hltb._fetch_batch", + return_value=[], + ): + cache: dict[int, float] = {440: 10.0} + results = fetch_hltb_times([(440, "TF2")], cache=cache) + assert results == [] diff --git a/steam_backlog_enforcer/tests/test_library_hider.py b/steam_backlog_enforcer/tests/test_library_hider.py new file mode 100644 index 0000000..beb9913 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_library_hider.py @@ -0,0 +1,497 @@ +"""Tests for library_hider module.""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from python_pkg.steam_backlog_enforcer.library_hider import ( + _cdp_result_value, + _evaluate_js, + _evaluate_js_async, + _get_shared_js_ws_url, + _is_steam_running, + _launch_steam_with_debug, + _shutdown_steam, + _steam_has_debug_port, + _wait_for_cdp_ready, + _wait_for_collections_ready, + ensure_steam_debug_port, + hide_other_games, + unhide_all_games, +) + + +class TestGetSharedJsWsUrl: + """Tests for _get_shared_js_ws_url.""" + + def test_finds_url(self) -> None: + targets = [ + { + "title": "SharedJSContext", + "webSocketDebuggerUrl": "ws://127.0.0.1:8080/x", + }, + {"title": "Other", "webSocketDebuggerUrl": "ws://other"}, + ] + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(targets).encode() + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + with patch( + "python_pkg.steam_backlog_enforcer.library_hider.urllib.request.urlopen", + return_value=mock_resp, + ): + result = _get_shared_js_ws_url() + assert result == "ws://127.0.0.1:8080/x" + + def test_no_shared_context(self) -> None: + targets = [{"title": "Other", "webSocketDebuggerUrl": "ws://other"}] + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(targets).encode() + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + with patch( + "python_pkg.steam_backlog_enforcer.library_hider.urllib.request.urlopen", + return_value=mock_resp, + ): + assert _get_shared_js_ws_url() is None + + def test_connection_error(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.library_hider.urllib.request.urlopen", + side_effect=OSError, + ): + assert _get_shared_js_ws_url() is None + + +class TestEvaluateJsAsync: + """Tests for _evaluate_js_async.""" + + def test_success(self) -> None: + mock_ws = AsyncMock() + mock_ws.send = AsyncMock() + mock_ws.recv = AsyncMock( + return_value=json.dumps({"result": {"result": {"value": "ok"}}}) + ) + mock_ws.__aenter__ = AsyncMock(return_value=mock_ws) + mock_ws.__aexit__ = AsyncMock(return_value=False) + + with patch( + "python_pkg.steam_backlog_enforcer.library_hider.websockets.connect", + return_value=mock_ws, + ): + result = asyncio.run(_evaluate_js_async("ws://test", "1+1")) + assert result["result"]["result"]["value"] == "ok" + + +class TestEvaluateJs: + """Tests for _evaluate_js.""" + + def test_success(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url", + return_value="ws://test", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.asyncio.run", + return_value={"result": {"result": {"value": "ok"}}}, + ), + ): + result = _evaluate_js("1+1") + assert result["result"]["result"]["value"] == "ok" + + def test_no_ws_url(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url", + return_value=None, + ), + pytest.raises(RuntimeError, match="SharedJSContext not found"), + ): + _evaluate_js("1+1") + + +class TestCdpResultValue: + """Tests for _cdp_result_value.""" + + def test_extracts_value(self) -> None: + result = {"result": {"result": {"value": "hello"}}} + assert _cdp_result_value(result) == "hello" + + def test_exception(self) -> None: + result = { + "result": { + "result": {"description": "Error!"}, + "exceptionDetails": {}, + } + } + with pytest.raises(RuntimeError, match="JS evaluation error"): + _cdp_result_value(result) + + def test_empty(self) -> None: + assert _cdp_result_value({}) == "" + + +class TestIsSteamRunning: + """Tests for _is_steam_running.""" + + def test_running(self) -> None: + mock_result = MagicMock(returncode=0) + with patch( + "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", + return_value=mock_result, + ): + assert _is_steam_running() is True + + def test_not_running(self) -> None: + mock_result = MagicMock(returncode=1) + with patch( + "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", + return_value=mock_result, + ): + assert _is_steam_running() is False + + +class TestSteamHasDebugPort: + """Tests for _steam_has_debug_port.""" + + def test_has_port(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url", + return_value="ws://test", + ): + assert _steam_has_debug_port() is True + + def test_no_port(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url", + return_value=None, + ): + assert _steam_has_debug_port() is False + + +class TestWaitForCdpReady: + """Tests for _wait_for_cdp_ready.""" + + def test_ready_immediately(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url", + return_value="ws://test", + ): + assert _wait_for_cdp_ready() is True + + def test_timeout(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url", + return_value=None, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.time.sleep", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT", + 2, + ), + ): + assert _wait_for_cdp_ready() is False + + +class TestWaitForCollectionsReady: + """Tests for _wait_for_collections_ready.""" + + def test_ready(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", + return_value={"result": {"result": {"value": "ok"}}}, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", + return_value="ok", + ), + ): + assert _wait_for_collections_ready() is True + + def test_not_ready_then_ready(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", + return_value={"result": {"result": {"value": "not_ready"}}}, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", + side_effect=["not_ready", "ok"], + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.time.sleep", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT", + 2, + ), + ): + assert _wait_for_collections_ready() is True + + def test_timeout(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", + side_effect=RuntimeError, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.time.sleep", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT", + 2, + ), + ): + assert _wait_for_collections_ready() is False + + +class TestShutdownSteam: + """Tests for _shutdown_steam.""" + + def test_exits_immediately(self) -> None: + mock_result = MagicMock(returncode=1) # Not running + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._run_as_user", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", + return_value=mock_result, + ), + ): + _shutdown_steam() + + def test_waits_for_exit(self) -> None: + results = [MagicMock(returncode=0), MagicMock(returncode=1)] + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._run_as_user", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", + side_effect=results, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.time.sleep", + ), + ): + _shutdown_steam() + + def test_file_not_found(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.library_hider._run_as_user", + side_effect=FileNotFoundError, + ): + _shutdown_steam() # Should not raise + + def test_timeout(self) -> None: + mock_result = MagicMock(returncode=0) # Still running + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._run_as_user", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", + return_value=mock_result, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.time.sleep", + ), + ): + _shutdown_steam() # Should complete loop without raising + + +class TestLaunchSteamWithDebug: + """Tests for _launch_steam_with_debug.""" + + def test_launches(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.library_hider._run_as_user", + ) as mock_run: + _launch_steam_with_debug() + mock_run.assert_called_once() + + +class TestEnsureSteamDebugPort: + """Tests for ensure_steam_debug_port.""" + + def test_already_available(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port", + return_value=True, + ): + ensure_steam_debug_port() + + def test_starts_fresh(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._is_steam_running", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._wait_for_collections_ready", + return_value=True, + ), + ): + ensure_steam_debug_port() + + def test_restarts_running_steam(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._is_steam_running", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._shutdown_steam", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._wait_for_collections_ready", + return_value=True, + ), + ): + ensure_steam_debug_port() + + def test_cdp_timeout(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._is_steam_running", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready", + return_value=False, + ), + pytest.raises(RuntimeError, match="Timed out waiting for Steam CDP"), + ): + ensure_steam_debug_port() + + def test_collections_timeout(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._is_steam_running", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._wait_for_collections_ready", + return_value=False, + ), + pytest.raises( + RuntimeError, match="Timed out waiting for Steam collections" + ), + ): + ensure_steam_debug_port() + + +class TestHideOtherGames: + """Tests for hide_other_games.""" + + def test_hides(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", + return_value={"result": {"result": {"value": '{"newlyHidden": 5}'}}}, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", + return_value='{"newlyHidden": 5}', + ), + ): + count = hide_other_games([1, 2, 3], 1) + assert count == 5 + + def test_empty_list(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port", + ): + count = hide_other_games([1], 1) + assert count == 0 + + def test_no_allowed(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", + return_value={"result": {"result": {"value": '{"newlyHidden": 2}'}}}, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", + return_value='{"newlyHidden": 2}', + ), + ): + count = hide_other_games([1, 2], None) + assert count == 2 + + +class TestUnhideAllGames: + """Tests for unhide_all_games.""" + + def test_unhides(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port", + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", + return_value={"result": {"result": {"value": '{"count": 10}'}}}, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", + return_value='{"count": 10}', + ), + ): + count = unhide_all_games([1, 2, 3]) + assert count == 10 diff --git a/steam_backlog_enforcer/tests/test_library_hider_part2.py b/steam_backlog_enforcer/tests/test_library_hider_part2.py new file mode 100644 index 0000000..5c745da --- /dev/null +++ b/steam_backlog_enforcer/tests/test_library_hider_part2.py @@ -0,0 +1,114 @@ +"""Tests for library_hider module — part 2 (missing coverage).""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +from python_pkg.steam_backlog_enforcer.library_hider import ( + _run_as_user, + restart_steam, +) + +PKG = "python_pkg.steam_backlog_enforcer.library_hider" + + +class TestRunAsUser: + """Tests for _run_as_user.""" + + def test_non_root_runs_directly(self) -> None: + with ( + patch(f"{PKG}.os.geteuid", return_value=1000), + patch(f"{PKG}.subprocess.Popen") as mock_popen, + ): + _run_as_user(["steam", "-shutdown"], "alice") + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert cmd == ["steam", "-shutdown"] + + def test_root_drops_to_user(self) -> None: + mock_pw = MagicMock() + mock_pw.pw_uid = 1001 + with ( + patch(f"{PKG}.os.geteuid", return_value=0), + patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw), + patch.dict(os.environ, {"DISPLAY": ":1", "XAUTHORITY": "/tmp/.X"}), + patch(f"{PKG}.subprocess.Popen") as mock_popen, + ): + _run_as_user(["steam", "-shutdown"], "alice") + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + assert cmd[0] == "sudo" + assert "-u" in cmd + assert "alice" in cmd + + def test_root_user_key_error(self) -> None: + with ( + patch(f"{PKG}.os.geteuid", return_value=0), + patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError("no user")), + patch(f"{PKG}.subprocess.Popen") as mock_popen, + ): + _run_as_user(["steam"], "unknownuser") + mock_popen.assert_called_once() + cmd = mock_popen.call_args[0][0] + # Falls back to uid 1000 + assert "sudo" in cmd[0] + + def test_root_user_none(self) -> None: + """When user is None and euid is 0, runs directly.""" + with ( + patch(f"{PKG}.os.geteuid", return_value=0), + patch(f"{PKG}.subprocess.Popen") as mock_popen, + ): + _run_as_user(["steam"], None) + cmd = mock_popen.call_args[0][0] + assert cmd == ["steam"] + + def test_root_user_is_root(self) -> None: + """When user is 'root', runs directly.""" + with ( + patch(f"{PKG}.os.geteuid", return_value=0), + patch(f"{PKG}.subprocess.Popen") as mock_popen, + ): + _run_as_user(["steam"], "root") + cmd = mock_popen.call_args[0][0] + assert cmd == ["steam"] + + def test_root_uses_env_defaults(self) -> None: + """When DBUS/XAUTHORITY/DISPLAY not in env, uses defaults.""" + mock_pw = MagicMock() + mock_pw.pw_uid = 1000 + env_copy = os.environ.copy() + env_copy.pop("DBUS_SESSION_BUS_ADDRESS", None) + env_copy.pop("XAUTHORITY", None) + env_copy.pop("DISPLAY", None) + with ( + patch(f"{PKG}.os.geteuid", return_value=0), + patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw), + patch.dict(os.environ, env_copy, clear=True), + patch(f"{PKG}.subprocess.Popen") as mock_popen, + ): + _run_as_user(["steam"], "bob") + cmd = mock_popen.call_args[0][0] + assert any("DISPLAY=:0" in arg for arg in cmd) + assert any("/home/bob/.Xauthority" in arg for arg in cmd) + + +class TestRestartSteam: + """Tests for restart_steam.""" + + def test_cdp_ready(self) -> None: + with ( + patch(f"{PKG}._shutdown_steam"), + patch(f"{PKG}._launch_steam_with_debug"), + patch(f"{PKG}._wait_for_cdp_ready", return_value=True), + ): + restart_steam() + + def test_cdp_not_ready(self) -> None: + with ( + patch(f"{PKG}._shutdown_steam"), + patch(f"{PKG}._launch_steam_with_debug"), + patch(f"{PKG}._wait_for_cdp_ready", return_value=False), + ): + restart_steam() diff --git a/steam_backlog_enforcer/tests/test_main.py b/steam_backlog_enforcer/tests/test_main.py new file mode 100644 index 0000000..e5b7c38 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main.py @@ -0,0 +1,483 @@ +"""Tests for main CLI module.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +from python_pkg.steam_backlog_enforcer.config import Config, State +from python_pkg.steam_backlog_enforcer.main import ( + _try_reassign_shorter_game, + cmd_buy_dlc, + cmd_hide, + cmd_install, + cmd_installed, + cmd_list, + cmd_reset, + cmd_setup, + cmd_status, + cmd_unblock, + cmd_unhide, + cmd_uninstall, +) +from python_pkg.steam_backlog_enforcer.steam_api import GameInfo + +PKG = "python_pkg.steam_backlog_enforcer.main" + + +def _snap( + app_id: int = 1, + name: str = "G", + total: int = 10, + unlocked: int = 0, + hours: float = -1, +) -> dict[str, Any]: + return { + "app_id": app_id, + "name": name, + "total_achievements": total, + "unlocked_achievements": unlocked, + "playtime_minutes": 60, + "completionist_hours": hours, + } + + +class TestCmdStatus: + """Tests for cmd_status.""" + + def test_with_game(self) -> None: + state = State(current_app_id=440, current_game_name="TF2") + with ( + patch(f"{PKG}.is_store_blocked", return_value=True), + patch(f"{PKG}.get_installed_games", return_value=[(440, "TF2")]), + patch(f"{PKG}._echo"), + ): + cmd_status(Config(), state) + + def test_no_game(self) -> None: + with ( + patch(f"{PKG}.is_store_blocked", return_value=False), + patch(f"{PKG}.get_installed_games", return_value=[]), + patch(f"{PKG}._echo"), + ): + cmd_status(Config(), State()) + + +class TestCmdList: + """Tests for cmd_list.""" + + def test_no_snapshot(self) -> None: + with ( + patch(f"{PKG}.load_snapshot", return_value=None), + patch(f"{PKG}._echo") as mock_echo, + ): + cmd_list(Config(), State()) + assert any("No snapshot" in str(c) for c in mock_echo.call_args_list) + + def test_with_games(self) -> None: + snap = [ + _snap(1, "A", 10, 5, 20.0), + _snap(2, "B", 10, 10, 10.0), + _snap(3, "C", 10, 3, -1), + ] + state = State(current_app_id=1) + with ( + patch(f"{PKG}.load_snapshot", return_value=snap), + patch(f"{PKG}._echo"), + ): + cmd_list(Config(), state) + + def test_many_games(self) -> None: + snap = [_snap(i, f"Game{i}") for i in range(60)] + with ( + patch(f"{PKG}.load_snapshot", return_value=snap), + patch(f"{PKG}._echo") as mock_echo, + ): + cmd_list(Config(), State()) + assert any("more" in str(c) for c in mock_echo.call_args_list) + + +class TestCmdUnblock: + """Tests for cmd_unblock.""" + + def test_success(self) -> None: + with ( + patch(f"{PKG}.unblock_store", return_value=True), + patch(f"{PKG}._echo"), + ): + cmd_unblock(Config(), State()) + + def test_fail(self) -> None: + with ( + patch(f"{PKG}.unblock_store", return_value=False), + patch(f"{PKG}._echo") as mock_echo, + ): + cmd_unblock(Config(), State()) + assert any("Failed" in str(c) for c in mock_echo.call_args_list) + + +class TestCmdBuyDlc: + """Tests for cmd_buy_dlc.""" + + def test_no_game(self) -> None: + with patch(f"{PKG}._echo") as mock_echo: + cmd_buy_dlc(Config(), State()) + assert any("No game" in str(c) for c in mock_echo.call_args_list) + + def test_unblock_fails(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.unblock_store", return_value=False), + patch(f"{PKG}._echo"), + ): + cmd_buy_dlc(Config(), state) + + def test_success_reblock(self) -> None: + state = State(current_app_id=1, current_game_name="G") + config = Config(block_store=True) + with ( + patch(f"{PKG}.unblock_store", return_value=True), + patch(f"{PKG}.block_store", return_value=True), + patch(f"{PKG}.restart_steam"), + patch(f"{PKG}._echo"), + patch("builtins.input", return_value=""), + ): + cmd_buy_dlc(config, state) + + def test_reblock_fails(self) -> None: + state = State(current_app_id=1, current_game_name="G") + config = Config(block_store=True) + with ( + patch(f"{PKG}.unblock_store", return_value=True), + patch(f"{PKG}.block_store", return_value=False), + patch(f"{PKG}._echo") as mock_echo, + patch("builtins.input", return_value=""), + ): + cmd_buy_dlc(config, state) + assert any("Warning" in str(c) for c in mock_echo.call_args_list) + + def test_no_reblock(self) -> None: + state = State(current_app_id=1, current_game_name="G") + config = Config(block_store=False) + with ( + patch(f"{PKG}.unblock_store", return_value=True), + patch(f"{PKG}._echo"), + patch("builtins.input", return_value=""), + ): + cmd_buy_dlc(config, state) + + +class TestCmdReset: + """Tests for cmd_reset.""" + + def test_normal_reset(self) -> None: + state = State(current_app_id=1, current_game_name="G", finished_app_ids=[1]) + with ( + patch(f"{PKG}.unblock_store"), + patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]), + patch(f"{PKG}.unhide_all_games", return_value=2), + patch(f"{PKG}._echo"), + patch.object(State, "save"), + ): + cmd_reset(Config(), state) + assert state.current_app_id is None + assert state.finished_app_ids == [] + + def test_unhide_fails(self) -> None: + state = State(current_app_id=1) + with ( + patch(f"{PKG}.unblock_store"), + patch( + f"{PKG}.get_all_owned_app_ids", + side_effect=OSError("fail"), + ), + patch(f"{PKG}._echo"), + patch.object(State, "save"), + ): + cmd_reset(Config(), state) + + def test_unhide_returns_zero(self) -> None: + state = State(current_app_id=1) + with ( + patch(f"{PKG}.unblock_store"), + patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]), + patch(f"{PKG}.unhide_all_games", return_value=0), + patch(f"{PKG}._echo"), + patch.object(State, "save"), + ): + cmd_reset(Config(), state) + + def test_no_owned_ids(self) -> None: + state = State(current_app_id=1) + with ( + patch(f"{PKG}.unblock_store"), + patch(f"{PKG}.get_all_owned_app_ids", return_value=[]), + patch(f"{PKG}._echo"), + patch.object(State, "save"), + ): + cmd_reset(Config(), state) + + +class TestCmdInstalled: + """Tests for cmd_installed.""" + + def test_shows_games(self) -> None: + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(440, "TF2"), (228980, "RT")], + ), + patch(f"{PKG}.PROTECTED_APP_IDS", {228980}), + patch(f"{PKG}._echo"), + ): + cmd_installed(Config(), State(current_app_id=440)) + + +class TestCmdUninstall: + """Tests for cmd_uninstall.""" + + def test_no_game(self) -> None: + with patch(f"{PKG}._echo") as mock_echo: + cmd_uninstall(Config(), State()) + assert any("No game" in str(c) for c in mock_echo.call_args_list) + + def test_nothing_to_remove(self) -> None: + state = State(current_app_id=440) + with ( + patch(f"{PKG}.get_installed_games", return_value=[(440, "TF2")]), + patch(f"{PKG}._echo"), + ): + cmd_uninstall(Config(), state) + + def test_confirms_yes(self) -> None: + state = State(current_app_id=440) + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(440, "TF2"), (730, "CS")], + ), + patch(f"{PKG}.uninstall_other_games", return_value=1), + patch("builtins.input", return_value="YES"), + patch(f"{PKG}._echo"), + ): + cmd_uninstall(Config(), state) + + def test_aborts(self) -> None: + state = State(current_app_id=440) + with ( + patch( + f"{PKG}.get_installed_games", + return_value=[(440, "TF2"), (730, "CS")], + ), + patch("builtins.input", return_value="no"), + patch(f"{PKG}._echo") as mock_echo, + ): + cmd_uninstall(Config(), state) + assert any("Aborted" in str(c) for c in mock_echo.call_args_list) + + +class TestCmdSetup: + """Tests for cmd_setup.""" + + def test_calls_interactive(self) -> None: + with patch(f"{PKG}.interactive_setup") as mock_setup: + cmd_setup(Config(), State()) + mock_setup.assert_called_once() + + +class TestCmdInstall: + """Tests for cmd_install.""" + + def test_no_game(self) -> None: + with patch(f"{PKG}._echo") as mock_echo: + cmd_install(Config(), State()) + assert any("No game" in str(c) for c in mock_echo.call_args_list) + + def test_already_installed(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{PKG}._echo"), + ): + cmd_install(Config(), state) + + def test_installs_ok(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.is_game_installed", return_value=False), + patch(f"{PKG}.install_game", return_value=True), + patch(f"{PKG}._echo"), + ): + cmd_install(Config(steam_id="i"), state) + + def test_install_fails(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.is_game_installed", return_value=False), + patch(f"{PKG}.install_game", return_value=False), + patch(f"{PKG}._echo"), + ): + cmd_install(Config(steam_id="i"), state) + + +class TestCmdHide: + """Tests for cmd_hide.""" + + def test_no_game(self) -> None: + with patch(f"{PKG}._echo"): + cmd_hide(Config(), State()) + + def test_no_owned(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.get_all_owned_app_ids", return_value=[]), + patch(f"{PKG}._echo"), + ): + cmd_hide(Config(), state) + + def test_hides(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]), + patch(f"{PKG}.hide_other_games", return_value=1), + patch(f"{PKG}._echo"), + ): + cmd_hide(Config(), state) + + def test_hides_zero(self) -> None: + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]), + patch(f"{PKG}.hide_other_games", return_value=0), + patch(f"{PKG}._echo"), + ): + cmd_hide(Config(), state) + + +class TestCmdUnhide: + """Tests for cmd_unhide.""" + + def test_no_owned(self) -> None: + with ( + patch(f"{PKG}.get_all_owned_app_ids", return_value=[]), + patch(f"{PKG}._echo"), + ): + cmd_unhide(Config(), State()) + + def test_unhides(self) -> None: + with ( + patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]), + patch(f"{PKG}.unhide_all_games", return_value=1), + patch(f"{PKG}._echo"), + ): + cmd_unhide(Config(), State()) + + def test_unhides_zero(self) -> None: + with ( + patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]), + patch(f"{PKG}.unhide_all_games", return_value=0), + patch(f"{PKG}._echo"), + ): + cmd_unhide(Config(), State()) + + +class TestTryReassignShorterGame: + """Tests for _try_reassign_shorter_game.""" + + def test_no_snapshot(self) -> None: + with patch(f"{PKG}.load_snapshot", return_value=None): + assert not _try_reassign_shorter_game({}, 1, 10.0, State(), Config()) + + def test_no_shorter_candidate(self) -> None: + snap = [_snap(1, "G", 10, 5, 10.0), _snap(2, "H", 10, 5, -1)] + with ( + patch(f"{PKG}.load_snapshot", return_value=snap), + patch(f"{PKG}._echo"), + ): + result = _try_reassign_shorter_game( + {1: 10.0}, + 1, + 10.0, + State(), + Config(), + ) + assert not result + + def test_reassigns(self) -> None: + snap = [ + _snap(1, "Long", 10, 5, 100.0), + _snap(2, "Short", 10, 5, 5.0), + ] + state = State(current_app_id=1, current_game_name="Long") + short_game = GameInfo( + app_id=2, + name="Short", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + completionist_hours=5.0, + ) + with ( + patch(f"{PKG}.load_snapshot", return_value=snap), + patch(f"{PKG}._echo"), + patch( + f"{PKG}._pick_playable_candidate", + return_value=short_game, + ), + patch(f"{PKG}.pick_next_game"), + ): + result = _try_reassign_shorter_game( + {1: 100.0, 2: 5.0}, + 1, + 100.0, + state, + Config(), + ) + assert result + + def test_playable_none(self) -> None: + snap = [ + _snap(1, "Long", 10, 5, 100.0), + _snap(2, "Short", 10, 5, 5.0), + ] + with ( + patch(f"{PKG}.load_snapshot", return_value=snap), + patch(f"{PKG}._pick_playable_candidate", return_value=None), + patch(f"{PKG}._echo"), + ): + result = _try_reassign_shorter_game( + {1: 100.0, 2: 5.0}, + 1, + 100.0, + State(), + Config(), + ) + assert not result + + def test_playable_longer(self) -> None: + """Playable candidate is longer than current — no reassign.""" + snap = [ + _snap(1, "Short", 10, 5, 10.0), + _snap(2, "Long", 10, 5, 200.0), + ] + long_game = GameInfo( + app_id=2, + name="Long", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + completionist_hours=200.0, + ) + with ( + patch(f"{PKG}.load_snapshot", return_value=snap), + patch(f"{PKG}._pick_playable_candidate", return_value=long_game), + patch(f"{PKG}._echo"), + ): + result = _try_reassign_shorter_game( + {1: 10.0, 2: 200.0}, + 1, + 10.0, + State(), + Config(), + ) + assert not result diff --git a/steam_backlog_enforcer/tests/test_main_part2.py b/steam_backlog_enforcer/tests/test_main_part2.py new file mode 100644 index 0000000..10939f3 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_part2.py @@ -0,0 +1,376 @@ +"""Tests for main CLI module — part 2 (missing coverage).""" + +from __future__ import annotations + +import sys +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from python_pkg.steam_backlog_enforcer.config import Config, State +from python_pkg.steam_backlog_enforcer.main import ( + _enforce_on_done, + _finalize_completion, + cmd_done, + main, +) +from python_pkg.steam_backlog_enforcer.steam_api import GameInfo + +PKG = "python_pkg.steam_backlog_enforcer.main" + + +def _snap( + app_id: int = 1, + name: str = "G", + total: int = 10, + unlocked: int = 0, + hours: float = -1, +) -> dict[str, Any]: + return { + "app_id": app_id, + "name": name, + "total_achievements": total, + "unlocked_achievements": unlocked, + "playtime_minutes": 60, + "completionist_hours": hours, + } + + +class TestFinalizeCompletion: + """Tests for _finalize_completion.""" + + def test_with_snapshot_and_hiding(self) -> None: + config = Config(steam_api_key="k", steam_id="i") + state = State(current_app_id=1, current_game_name="G") + snap = [_snap(2, "NewGame", 10, 0, 5.0)] + with ( + patch(f"{PKG}._echo"), + patch(f"{PKG}.load_snapshot", return_value=snap), + patch(f"{PKG}.pick_next_game") as mock_pick, + patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]), + patch(f"{PKG}.hide_other_games", return_value=2), + patch(f"{PKG}.send_notification"), + patch.object(State, "save"), + ): + + def set_next( + games: object, + s: State, + c: object, + ) -> None: + s.current_app_id = 2 + s.current_game_name = "NewGame" + + mock_pick.side_effect = set_next + _finalize_completion(config, state, "G", 1) + assert 1 in state.finished_app_ids + + def test_no_snapshot(self) -> None: + config = Config() + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}._echo"), + patch(f"{PKG}.load_snapshot", return_value=None), + patch.object(State, "save"), + ): + _finalize_completion(config, state, "G", 1) + assert state.current_app_id is None + + def test_no_next_game(self) -> None: + config = Config() + state = State(current_app_id=1, current_game_name="G") + snap = [_snap(1, "G", 10, 10)] + with ( + patch(f"{PKG}._echo"), + patch(f"{PKG}.load_snapshot", return_value=snap), + patch(f"{PKG}.pick_next_game") as mock_pick, + patch.object(State, "save"), + ): + + def set_none( + games: object, + s: State, + c: object, + ) -> None: + s.current_app_id = None + + mock_pick.side_effect = set_none + _finalize_completion(config, state, "G", 1) + + def test_no_owned_ids(self) -> None: + config = Config() + state = State(current_app_id=1, current_game_name="G") + snap = [_snap(2, "Next", 10, 0)] + with ( + patch(f"{PKG}._echo"), + patch(f"{PKG}.load_snapshot", return_value=snap), + patch(f"{PKG}.pick_next_game") as mock_pick, + patch(f"{PKG}.get_all_owned_app_ids", return_value=[]), + patch(f"{PKG}.send_notification"), + patch.object(State, "save"), + ): + + def set_2( + games: object, + s: State, + c: object, + ) -> None: + s.current_app_id = 2 + s.current_game_name = "Next" + + mock_pick.side_effect = set_2 + _finalize_completion(config, state, "G", 1) + + def test_hide_returns_zero(self) -> None: + config = Config() + state = State(current_app_id=1, current_game_name="G") + snap = [_snap(2, "Next", 10, 0)] + with ( + patch(f"{PKG}._echo"), + patch(f"{PKG}.load_snapshot", return_value=snap), + patch(f"{PKG}.pick_next_game") as mock_pick, + patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]), + patch(f"{PKG}.hide_other_games", return_value=0), + patch(f"{PKG}.send_notification"), + patch.object(State, "save"), + ): + + def set_2( + games: object, + s: State, + c: object, + ) -> None: + s.current_app_id = 2 + s.current_game_name = "Next" + + mock_pick.side_effect = set_2 + _finalize_completion(config, state, "G", 1) + + +class TestEnforceOnDone: + """Tests for _enforce_on_done.""" + + def test_no_current_game(self) -> None: + _enforce_on_done(Config(), State()) + + def test_kills_and_uninstalls(self) -> None: + config = Config( + kill_unauthorized_games=True, + uninstall_other_games=True, + ) + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}._echo"), + patch( + f"{PKG}.enforce_allowed_game", + return_value=[(1234, 999)], + ), + patch(f"{PKG}.uninstall_other_games", return_value=2), + patch(f"{PKG}.is_game_installed", return_value=True), + ): + _enforce_on_done(config, state) + + def test_no_violations_no_uninstalls(self) -> None: + config = Config( + kill_unauthorized_games=True, + uninstall_other_games=True, + ) + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}._echo"), + patch(f"{PKG}.enforce_allowed_game", return_value=[]), + patch(f"{PKG}.uninstall_other_games", return_value=0), + patch(f"{PKG}.is_game_installed", return_value=True), + ): + _enforce_on_done(config, state) + + def test_reinstall_when_not_installed(self) -> None: + config = Config( + kill_unauthorized_games=False, + uninstall_other_games=False, + steam_id="s1", + ) + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}._echo"), + patch(f"{PKG}.is_game_installed", return_value=False), + patch(f"{PKG}.install_game") as mock_install, + ): + _enforce_on_done(config, state) + mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True) + + +class TestCmdDone: + """Tests for cmd_done.""" + + def test_no_game_assigned(self) -> None: + with patch(f"{PKG}._echo") as mock_echo: + cmd_done(Config(), State()) + assert any("No game" in str(c) for c in mock_echo.call_args_list) + + def test_fetch_fails(self) -> None: + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = None + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.SteamAPIClient", return_value=mock_client), + patch(f"{PKG}._echo"), + ): + cmd_done(Config(steam_api_key="k", steam_id="i"), state) + + def test_not_complete_enforces(self) -> None: + game = GameInfo( + app_id=1, + name="G", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + ) + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = game + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.SteamAPIClient", return_value=mock_client), + patch(f"{PKG}._echo"), + patch(f"{PKG}.load_hltb_cache", return_value={1: 20.0}), + patch(f"{PKG}._try_reassign_shorter_game", return_value=False), + patch(f"{PKG}._enforce_on_done"), + ): + cmd_done(Config(steam_api_key="k", steam_id="i"), state) + + def test_complete_finalizes(self) -> None: + game = GameInfo( + app_id=1, + name="G", + total_achievements=10, + unlocked_achievements=10, + playtime_minutes=60, + ) + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = game + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.SteamAPIClient", return_value=mock_client), + patch(f"{PKG}._echo"), + patch(f"{PKG}.load_hltb_cache", return_value={1: 10.0}), + patch(f"{PKG}._try_reassign_shorter_game", return_value=False), + patch(f"{PKG}._finalize_completion") as mock_final, + ): + cmd_done(Config(steam_api_key="k", steam_id="i"), state) + mock_final.assert_called_once() + + def test_hltb_cache_miss_fetches(self) -> None: + game = GameInfo( + app_id=1, + name="G", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + ) + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = game + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.SteamAPIClient", return_value=mock_client), + patch(f"{PKG}._echo"), + patch(f"{PKG}.load_hltb_cache", return_value={}), + patch( + f"{PKG}.fetch_hltb_times_cached", + return_value={1: 15.0}, + ), + patch(f"{PKG}._try_reassign_shorter_game", return_value=False), + patch(f"{PKG}._enforce_on_done"), + ): + cmd_done(Config(steam_api_key="k", steam_id="i"), state) + + def test_hltb_negative_no_display(self) -> None: + """Covers the hours <= 0 branch (no HLTB estimate display).""" + game = GameInfo( + app_id=1, + name="G", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + ) + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = game + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.SteamAPIClient", return_value=mock_client), + patch(f"{PKG}._echo"), + patch(f"{PKG}.load_hltb_cache", return_value={1: -1.0}), + patch(f"{PKG}._try_reassign_shorter_game", return_value=False), + patch(f"{PKG}._enforce_on_done"), + ): + cmd_done(Config(steam_api_key="k", steam_id="i"), state) + + def test_reassign_returns_true(self) -> None: + game = GameInfo( + app_id=1, + name="G", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + ) + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = game + state = State(current_app_id=1, current_game_name="G") + with ( + patch(f"{PKG}.SteamAPIClient", return_value=mock_client), + patch(f"{PKG}._echo"), + patch(f"{PKG}.load_hltb_cache", return_value={1: 50.0}), + patch(f"{PKG}._try_reassign_shorter_game", return_value=True), + ): + cmd_done(Config(steam_api_key="k", steam_id="i"), state) + + +class TestMain: + """Tests for main CLI entry point.""" + + def test_no_args_exits(self) -> None: + with ( + patch.object(sys, "argv", ["prog"]), + patch(f"{PKG}._echo"), + pytest.raises(SystemExit, match="1"), + ): + main() + + def test_unknown_command_exits(self) -> None: + with ( + patch.object(sys, "argv", ["prog", "bogus"]), + patch(f"{PKG}._echo"), + pytest.raises(SystemExit, match="1"), + ): + main() + + def test_valid_command_runs(self) -> None: + mock_cmd = MagicMock() + with ( + patch.object(sys, "argv", ["prog", "status"]), + patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")), + patch(f"{PKG}.State.load", return_value=State()), + patch.dict(f"{PKG}.COMMANDS", {"status": ("s", mock_cmd)}), + ): + main() + mock_cmd.assert_called_once() + + def test_setup_no_key_required(self) -> None: + mock_cmd = MagicMock() + with ( + patch.object(sys, "argv", ["prog", "setup"]), + patch(f"{PKG}.Config.load", return_value=Config()), + patch(f"{PKG}.State.load", return_value=State()), + patch.dict(f"{PKG}.COMMANDS", {"setup": ("s", mock_cmd)}), + ): + main() + mock_cmd.assert_called_once() + + def test_no_api_key_exits(self) -> None: + with ( + patch.object(sys, "argv", ["prog", "status"]), + patch(f"{PKG}.Config.load", return_value=Config()), + patch(f"{PKG}._echo"), + pytest.raises(SystemExit, match="1"), + ): + main() diff --git a/steam_backlog_enforcer/tests/test_protondb.py b/steam_backlog_enforcer/tests/test_protondb.py new file mode 100644 index 0000000..7c4c7bd --- /dev/null +++ b/steam_backlog_enforcer/tests/test_protondb.py @@ -0,0 +1,248 @@ +"""Tests for protondb module.""" + +from __future__ import annotations + +import asyncio +import json +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp + +from python_pkg.steam_backlog_enforcer.protondb import ( + HTTP_NOT_FOUND, + ProtonDBRating, + _fetch_batch, + _fetch_one, + _load_cache, + _rating_from_cache, + _rating_to_dict, + _save_cache, + fetch_protondb_ratings, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestProtonDBRating: + """Tests for ProtonDBRating.""" + + def test_playable_native(self) -> None: + r = ProtonDBRating(app_id=1, tier="native") + assert r.is_playable is True + + def test_playable_platinum(self) -> None: + r = ProtonDBRating(app_id=1, tier="platinum") + assert r.is_playable is True + + def test_playable_gold(self) -> None: + r = ProtonDBRating(app_id=1, tier="gold") + assert r.is_playable is True + + def test_not_playable_silver(self) -> None: + r = ProtonDBRating(app_id=1, tier="silver") + assert r.is_playable is False + + def test_not_playable_bronze(self) -> None: + r = ProtonDBRating(app_id=1, tier="bronze") + assert r.is_playable is False + + def test_not_playable_borked(self) -> None: + r = ProtonDBRating(app_id=1, tier="borked") + assert r.is_playable is False + + def test_playable_no_data(self) -> None: + r = ProtonDBRating(app_id=1, tier="") + assert r.is_playable is True + + def test_playable_pending(self) -> None: + r = ProtonDBRating(app_id=1, tier="pending") + assert r.is_playable is True + + def test_gold_trending_silver(self) -> None: + r = ProtonDBRating(app_id=1, tier="gold", trending_tier="silver") + assert r.is_playable is False + + def test_gold_trending_gold(self) -> None: + r = ProtonDBRating(app_id=1, tier="gold", trending_tier="gold") + assert r.is_playable is True + + def test_gold_no_trending(self) -> None: + r = ProtonDBRating(app_id=1, tier="gold", trending_tier="") + assert r.is_playable is True + + def test_gold_trending_platinum(self) -> None: + r = ProtonDBRating(app_id=1, tier="gold", trending_tier="platinum") + assert r.is_playable is True + + def test_gold_trending_unknown(self) -> None: + r = ProtonDBRating(app_id=1, tier="gold", trending_tier="unknown") + assert r.is_playable is False + + def test_unknown_tier(self) -> None: + r = ProtonDBRating(app_id=1, tier="unknown_tier") + assert r.is_playable is False + + +class TestProtonDBCache: + """Tests for cache I/O.""" + + def test_load_cache_exists(self, tmp_path: Path) -> None: + cache_file = tmp_path / "protondb_cache.json" + cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8") + with patch( + "python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", + cache_file, + ): + result = _load_cache() + assert result == {"440": {"tier": "gold"}} + + def test_load_cache_missing(self, tmp_path: Path) -> None: + cache_file = tmp_path / "nonexistent.json" + with patch( + "python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", + cache_file, + ): + assert _load_cache() == {} + + def test_save_cache(self, tmp_path: Path) -> None: + cache_file = tmp_path / "protondb_cache.json" + config_dir = tmp_path + with ( + patch( + "python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", + cache_file, + ), + patch("python_pkg.steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir), + ): + _save_cache({"440": {"tier": "gold"}}) + assert cache_file.exists() + + +class TestRatingConversion: + """Tests for rating serialization.""" + + def test_to_dict(self) -> None: + r = ProtonDBRating( + app_id=1, + tier="gold", + trending_tier="platinum", + score=0.9, + confidence="high", + total_reports=100, + ) + d = _rating_to_dict(r) + assert d["tier"] == "gold" + assert d["total_reports"] == 100 + + def test_from_cache(self) -> None: + data: dict[str, Any] = { + "tier": "silver", + "trending_tier": "bronze", + "score": 0.5, + } + r = _rating_from_cache(440, data) + assert r.app_id == 440 + assert r.tier == "silver" + assert r.trending_tier == "bronze" + + def test_from_cache_defaults(self) -> None: + r = _rating_from_cache(440, {}) + assert r.tier == "" + assert r.total_reports == 0 + + +class TestFetchOne: + """Tests for _fetch_one.""" + + def test_success(self) -> None: + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.raise_for_status = MagicMock() + mock_resp.json = AsyncMock( + return_value={"tier": "gold", "trendingTier": "platinum"} + ) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + + sem = asyncio.Semaphore(1) + result = asyncio.run(_fetch_one(mock_session, sem, 440)) + assert result.tier == "gold" + + def test_not_found(self) -> None: + mock_resp = AsyncMock() + mock_resp.status = HTTP_NOT_FOUND + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + + sem = asyncio.Semaphore(1) + result = asyncio.run(_fetch_one(mock_session, sem, 440)) + assert result.tier == "" + + def test_client_error(self) -> None: + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.raise_for_status = MagicMock(side_effect=aiohttp.ClientError) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + + sem = asyncio.Semaphore(1) + result = asyncio.run(_fetch_one(mock_session, sem, 440)) + assert result.tier == "" + + +class TestFetchBatch: + """Tests for _fetch_batch.""" + + def test_returns_ratings(self) -> None: + rating = ProtonDBRating(app_id=440, tier="gold") + with patch( + "python_pkg.steam_backlog_enforcer.protondb._fetch_one", + new_callable=AsyncMock, + return_value=rating, + ): + result = asyncio.run(_fetch_batch([440])) + assert len(result) == 1 + assert result[0].tier == "gold" + + +class TestFetchProtondbRatings: + """Tests for fetch_protondb_ratings.""" + + def test_all_cached(self, tmp_path: Path) -> None: + cache_file = tmp_path / "protondb_cache.json" + cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8") + with patch( + "python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", + cache_file, + ): + result = fetch_protondb_ratings([440]) + assert 440 in result + assert result[440].tier == "gold" + + def test_fetch_uncached(self, tmp_path: Path) -> None: + cache_file = tmp_path / "protondb_cache.json" + config_dir = tmp_path + with ( + patch( + "python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", + cache_file, + ), + patch("python_pkg.steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir), + patch( + "python_pkg.steam_backlog_enforcer.protondb._fetch_batch", + return_value=[ProtonDBRating(app_id=440, tier="platinum")], + ), + ): + result = fetch_protondb_ratings([440]) + assert result[440].tier == "platinum" diff --git a/steam_backlog_enforcer/tests/test_scanning.py b/steam_backlog_enforcer/tests/test_scanning.py new file mode 100644 index 0000000..2efbe96 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_scanning.py @@ -0,0 +1,439 @@ +"""Tests for scanning module.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from python_pkg.steam_backlog_enforcer.config import Config, State +from python_pkg.steam_backlog_enforcer.protondb import ProtonDBRating +from python_pkg.steam_backlog_enforcer.scanning import ( + _pick_playable_candidate, + do_check, + do_scan, + pick_next_game, +) +from python_pkg.steam_backlog_enforcer.steam_api import GameInfo + + +def _game( + app_id: int = 1, + name: str = "G", + total: int = 10, + unlocked: int = 0, + hours: float = -1, +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=total, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + ) + + +class TestDoScan: + """Tests for do_scan.""" + + def test_scans_and_picks(self) -> None: + game = _game(app_id=440, name="TF2", total=10, unlocked=5) + mock_client = MagicMock() + + def build_game_list( + skip_app_ids: Any = None, + progress_callback: Any = None, + ) -> list[GameInfo]: + # Trigger progress callback to cover those lines. + if progress_callback: + progress_callback(50, 100) + progress_callback(100, 100) + return [game] + + mock_client.build_game_list.side_effect = build_game_list + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", + return_value=mock_client, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.fetch_hltb_times_cached", + side_effect=lambda games, progress_cb=None: ( + progress_cb(1, 1, 1, "TF2") if progress_cb else None, + {440: 20.0}, + )[1], + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.save_snapshot", + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.pick_next_game", + ) as mock_pick, + patch( + "python_pkg.steam_backlog_enforcer.scanning._echo", + ), + ): + config = Config(steam_api_key="k", steam_id="i") + state = State() + result = do_scan(config, state) + assert len(result) == 1 + mock_pick.assert_called_once() + + def test_scan_all_complete(self) -> None: + game = _game(app_id=440, name="TF2", total=10, unlocked=10) + mock_client = MagicMock() + + def build_game_list( + skip_app_ids: Any = None, + progress_callback: Any = None, + ) -> list[GameInfo]: + if progress_callback: + # current=1, total=2 → not %50 and not ==total → covers False branch + progress_callback(1, 2) + return [game] + + mock_client.build_game_list.side_effect = build_game_list + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", + return_value=mock_client, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.save_snapshot", + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.pick_next_game", + ) as mock_pick, + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + ): + config = Config(steam_api_key="k", steam_id="i") + state = State() + result = do_scan(config, state) + assert len(result) == 1 + mock_pick.assert_called_once() + + def test_scan_already_assigned(self) -> None: + game = _game(app_id=440, total=10, unlocked=5) + mock_client = MagicMock() + mock_client.build_game_list.return_value = [game] + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", + return_value=mock_client, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.fetch_hltb_times_cached", + return_value={440: 20.0}, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.save_snapshot", + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.pick_next_game", + ) as mock_pick, + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + ): + config = Config(steam_api_key="k", steam_id="i") + state = State(current_app_id=440) + result = do_scan(config, state) + assert len(result) == 1 + mock_pick.assert_not_called() + + +class TestPickPlayableCandidate: + """Tests for _pick_playable_candidate.""" + + def test_finds_playable(self) -> None: + game = _game(app_id=440, name="TF2") + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings", + return_value={ + 440: ProtonDBRating(app_id=440, tier="gold"), + }, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + ): + result = _pick_playable_candidate([game]) + assert result is not None + assert result.app_id == 440 + + def test_skips_bad_rating(self) -> None: + bad = _game(app_id=1, name="Bad") + good = _game(app_id=2, name="Good") + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings", + return_value={ + 1: ProtonDBRating(app_id=1, tier="borked"), + 2: ProtonDBRating(app_id=2, tier="platinum"), + }, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + ): + result = _pick_playable_candidate([bad, good]) + assert result is not None + assert result.app_id == 2 + + def test_all_unplayable(self) -> None: + game = _game(app_id=1, name="Bad") + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings", + return_value={ + 1: ProtonDBRating(app_id=1, tier="borked"), + }, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + ): + assert _pick_playable_candidate([game]) is None + + def test_empty_list(self) -> None: + assert _pick_playable_candidate([]) is None + + def test_first_in_batch_playable(self) -> None: + """First game in first batch is playable — no skip message.""" + game = _game(app_id=440, name="TF2") + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings", + return_value={ + 440: ProtonDBRating(app_id=440, tier="platinum"), + }, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + ): + result = _pick_playable_candidate([game]) + assert result is not None + + +class TestPickNextGame: + """Tests for pick_next_game.""" + + def test_picks_shortest(self) -> None: + g1 = _game(app_id=1, name="Long", hours=100.0) + g2 = _game(app_id=2, name="Short", hours=10.0) + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + patch( + "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + ): + pick_next_game([g1, g2], state, config) + assert state.current_app_id == 2 + + def test_no_candidates(self) -> None: + g1 = _game(app_id=1, total=5, unlocked=5) + config = Config(steam_api_key="k", steam_id="i") + state = State() + with patch("python_pkg.steam_backlog_enforcer.scanning._echo"): + pick_next_game([g1], state, config) + assert state.current_app_id is None + + def test_skips_finished(self) -> None: + g1 = _game(app_id=1, name="G1", hours=10.0) + g2 = _game(app_id=2, name="G2", hours=20.0) + config = Config(steam_api_key="k", steam_id="i") + state = State(finished_app_ids=[1]) + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + patch( + "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + ): + pick_next_game([g1, g2], state, config) + assert state.current_app_id == 2 + + def test_no_playable(self) -> None: + g1 = _game(app_id=1, name="G1") + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + return_value=None, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + ): + pick_next_game([g1], state, config) + assert state.current_app_id is None + + def test_uninstalls_others(self) -> None: + g1 = _game(app_id=1, name="G1", hours=10.0) + config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=True) + state = State() + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + patch( + "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", + return_value=2, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + ): + pick_next_game([g1], state, config) + assert state.current_app_id == 1 + + def test_auto_installs(self) -> None: + g1 = _game(app_id=1, name="G1", hours=10.0) + config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=False) + state = State() + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + patch( + "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.install_game" + ) as mock_install, + ): + pick_next_game([g1], state, config) + mock_install.assert_called_once() + + def test_unknown_hours(self) -> None: + g1 = _game(app_id=1, name="G1", hours=-1) + g2 = _game(app_id=2, name="G2", hours=10.0) + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + patch( + "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + ): + pick_next_game([g1, g2], state, config) + assert state.current_app_id == 2 + + def test_picks_game_no_hours(self) -> None: + """Chosen game has no HLTB hours — covers no-hours output branch.""" + g1 = _game(app_id=1, name="G1", hours=-1) + config = Config(steam_api_key="k", steam_id="i") + state = State() + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", + side_effect=lambda c: c[0] if c else None, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + patch( + "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", + return_value=True, + ), + ): + pick_next_game([g1], state, config) + assert state.current_app_id == 1 + + +class TestDoCheck: + """Tests for do_check.""" + + def test_no_assignment(self) -> None: + with patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo: + do_check(Config(), State()) + mock_echo.assert_called() + + def test_fetch_fails(self) -> None: + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = None + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", + return_value=mock_client, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"), + ): + state = State(current_app_id=440, current_game_name="TF2") + do_check(Config(steam_api_key="k", steam_id="i"), state) + + def test_complete(self) -> None: + game = _game(app_id=440, name="TF2", total=5, unlocked=5) + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = game + snap = [game.to_snapshot()] + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", + return_value=mock_client, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + patch( + "python_pkg.steam_backlog_enforcer.scanning.send_notification", + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.load_snapshot", + return_value=snap, + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.pick_next_game", + ), + patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"), + ): + state = State(current_app_id=440, current_game_name="TF2") + do_check(Config(steam_api_key="k", steam_id="i"), state) + assert 440 in state.finished_app_ids + + def test_complete_no_snapshot(self) -> None: + game = _game(app_id=440, name="TF2", total=5, unlocked=5) + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = game + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", + return_value=mock_client, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + patch( + "python_pkg.steam_backlog_enforcer.scanning.send_notification", + ), + patch( + "python_pkg.steam_backlog_enforcer.scanning.load_snapshot", + return_value=None, + ), + patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"), + ): + state = State(current_app_id=440, current_game_name="TF2") + do_check(Config(steam_api_key="k", steam_id="i"), state) + + def test_not_complete(self) -> None: + game = _game(app_id=440, name="TF2", total=10, unlocked=5) + mock_client = MagicMock() + mock_client.refresh_single_game.return_value = game + with ( + patch( + "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", + return_value=mock_client, + ), + patch("python_pkg.steam_backlog_enforcer.scanning._echo"), + patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"), + ): + state = State(current_app_id=440, current_game_name="TF2") + do_check(Config(steam_api_key="k", steam_id="i"), state) diff --git a/steam_backlog_enforcer/tests/test_scanning_part2.py b/steam_backlog_enforcer/tests/test_scanning_part2.py new file mode 100644 index 0000000..ef74fe4 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_scanning_part2.py @@ -0,0 +1,134 @@ +"""Tests for scanning module — part 2 (missing coverage).""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from python_pkg.steam_backlog_enforcer.config import Config, State +from python_pkg.steam_backlog_enforcer.scanning import ( + _check_game_tampering, + detect_tampering, +) + +PKG = "python_pkg.steam_backlog_enforcer.scanning" + + +def _entry( + app_id: int = 1, + name: str = "G", + total: int = 10, + unlocked: int = 5, + playtime: int = 60, +) -> dict[str, Any]: + return { + "app_id": app_id, + "name": name, + "total_achievements": total, + "unlocked_achievements": unlocked, + "playtime_minutes": playtime, + } + + +class TestCheckGameTampering: + """Tests for _check_game_tampering.""" + + def test_current_game_skipped(self) -> None: + state = State(current_app_id=1) + result = _check_game_tampering(MagicMock(), _entry(app_id=1), state) + assert result is None + + def test_already_complete_skipped(self) -> None: + state = State() + result = _check_game_tampering( + MagicMock(), + _entry(unlocked=10, total=10), + state, + ) + assert result is None + + def test_zero_playtime_skipped(self) -> None: + state = State() + result = _check_game_tampering( + MagicMock(), + _entry(playtime=0), + state, + ) + assert result is None + + def test_no_new_achievements(self) -> None: + client = MagicMock() + game = MagicMock() + game.unlocked_achievements = 5 + client.refresh_single_game.return_value = game + state = State() + result = _check_game_tampering(client, _entry(unlocked=5), state) + assert result is None + + def test_tampering_detected(self) -> None: + client = MagicMock() + game = MagicMock() + game.unlocked_achievements = 8 + client.refresh_single_game.return_value = game + state = State() + entry = _entry(app_id=99, name="Cheated", unlocked=5) + result = _check_game_tampering(client, entry, state) + assert result is not None + assert result == ("Cheated", 99, 3) + + def test_refresh_returns_none(self) -> None: + client = MagicMock() + client.refresh_single_game.return_value = None + state = State() + result = _check_game_tampering(client, _entry(), state) + assert result is None + + +class TestDetectTampering: + """Tests for detect_tampering.""" + + def test_no_snapshot(self) -> None: + with patch(f"{PKG}.load_snapshot", return_value=None): + detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) + + def test_no_tampering(self) -> None: + entries = [_entry(app_id=1)] + with ( + patch(f"{PKG}.load_snapshot", return_value=entries), + patch(f"{PKG}.SteamAPIClient"), + patch(f"{PKG}._check_game_tampering", return_value=None), + patch(f"{PKG}._echo"), + ): + detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) + + def test_tampering_found(self) -> None: + entries = [_entry(app_id=1, name="BadGame")] + with ( + patch(f"{PKG}.load_snapshot", return_value=entries), + patch(f"{PKG}.SteamAPIClient"), + patch( + f"{PKG}._check_game_tampering", + return_value=("BadGame", 1, 3), + ), + patch(f"{PKG}._echo") as mock_echo, + patch(f"{PKG}.send_notification"), + ): + detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) + assert any("TAMPERING" in str(c) for c in mock_echo.call_args_list) + + def test_stops_at_limit(self) -> None: + """Stops after _TAMPER_CHECK_LIMIT suspicious games.""" + entries = [_entry(app_id=i, name=f"G{i}") for i in range(10)] + with ( + patch(f"{PKG}.load_snapshot", return_value=entries), + patch(f"{PKG}.SteamAPIClient"), + patch( + f"{PKG}._check_game_tampering", + return_value=("Game", 1, 1), + ) as mock_check, + patch(f"{PKG}._echo"), + patch(f"{PKG}.send_notification"), + ): + detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) + # Should stop after 3 (_TAMPER_CHECK_LIMIT) + assert mock_check.call_count == 3 diff --git a/steam_backlog_enforcer/tests/test_steam_api.py b/steam_backlog_enforcer/tests/test_steam_api.py new file mode 100644 index 0000000..527c419 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_steam_api.py @@ -0,0 +1,335 @@ +"""Tests for steam_api module.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from python_pkg.steam_backlog_enforcer.steam_api import ( + AchievementInfo, + GameInfo, + SteamAPIClient, + SteamAPIError, +) + + +class TestAchievementInfo: + """Tests for AchievementInfo.""" + + def test_create(self) -> None: + a = AchievementInfo( + api_name="ACH_1", display_name="First", achieved=True, unlock_time=1000 + ) + assert a.api_name == "ACH_1" + assert a.achieved is True + + +class TestGameInfo: + """Tests for GameInfo.""" + + def test_completion_pct_zero_achievements(self) -> None: + g = GameInfo( + app_id=1, + name="G", + total_achievements=0, + unlocked_achievements=0, + playtime_minutes=0, + ) + assert g.completion_pct == 100.0 + + def test_completion_pct_partial(self) -> None: + g = GameInfo( + app_id=1, + name="G", + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=0, + ) + assert g.completion_pct == 50.0 + + def test_is_complete_true(self) -> None: + g = GameInfo( + app_id=1, + name="G", + total_achievements=5, + unlocked_achievements=5, + playtime_minutes=0, + ) + assert g.is_complete is True + + def test_is_complete_false(self) -> None: + g = GameInfo( + app_id=1, + name="G", + total_achievements=5, + unlocked_achievements=3, + playtime_minutes=0, + ) + assert g.is_complete is False + + def test_is_complete_zero(self) -> None: + g = GameInfo( + app_id=1, + name="G", + total_achievements=0, + unlocked_achievements=0, + playtime_minutes=0, + ) + assert g.is_complete is False + + def test_to_snapshot(self) -> None: + ach = AchievementInfo( + api_name="A1", display_name="Ach1", achieved=True, unlock_time=99 + ) + g = GameInfo( + app_id=1, + name="G", + total_achievements=1, + unlocked_achievements=1, + playtime_minutes=60, + achievements=[ach], + completionist_hours=5.0, + ) + snap = g.to_snapshot() + assert snap["app_id"] == 1 + assert snap["achievements"][0]["api_name"] == "A1" + assert snap["completionist_hours"] == 5.0 + + def test_from_snapshot(self) -> None: + data: dict[str, Any] = { + "app_id": 2, + "name": "G2", + "total_achievements": 3, + "unlocked_achievements": 1, + "playtime_minutes": 120, + "completionist_hours": 10.0, + "achievements": [ + { + "api_name": "A1", + "display_name": "First", + "achieved": False, + "unlock_time": 0, + }, + ], + } + g = GameInfo.from_snapshot(data) + assert g.app_id == 2 + assert g.completionist_hours == 10.0 + assert len(g.achievements) == 1 + + def test_from_snapshot_defaults(self) -> None: + data: dict[str, Any] = { + "app_id": 3, + "name": "G3", + "total_achievements": 0, + "unlocked_achievements": 0, + } + g = GameInfo.from_snapshot(data) + assert g.playtime_minutes == 0 + assert g.completionist_hours == -1 + assert g.achievements == [] + + def test_from_snapshot_achievement_defaults(self) -> None: + data: dict[str, Any] = { + "app_id": 4, + "name": "G4", + "total_achievements": 1, + "unlocked_achievements": 0, + "achievements": [{"api_name": "X", "achieved": False}], + } + g = GameInfo.from_snapshot(data) + assert g.achievements[0].display_name == "X" + assert g.achievements[0].unlock_time == 0 + + +class TestSteamAPIClient: + """Tests for SteamAPIClient.""" + + def test_init(self) -> None: + client = SteamAPIClient("key", "id") + assert client.api_key == "key" + assert client.steam_id == "id" + + def test_rate_limit(self) -> None: + client = SteamAPIClient("key", "id") + # Should not block on first call + client._rate_limit() + + def test_rate_limit_throttle(self) -> None: + client = SteamAPIClient("key", "id") + # Fill up the rate limit window + client._request_times = [__import__("time").time()] * client._max_rps + with patch( + "python_pkg.steam_backlog_enforcer.steam_api.time.sleep", + ) as mock_sleep: + # Next call should trigger sleep then succeed + client._rate_limit() + mock_sleep.assert_called() + + def test_get_success(self) -> None: + client = SteamAPIClient("key", "id") + mock_resp = MagicMock() + mock_resp.json.return_value = {"data": "value"} + client.session.get = MagicMock(return_value=mock_resp) + result = client._get("https://example.com/api") + assert result == {"data": "value"} + + def test_get_with_params(self) -> None: + client = SteamAPIClient("key", "id") + mock_resp = MagicMock() + mock_resp.json.return_value = {"data": "value"} + client.session.get = MagicMock(return_value=mock_resp) + result = client._get("https://example.com/api", params={"foo": "bar"}) + assert result == {"data": "value"} + # Verify key was added to existing params dict + call_kwargs = client.session.get.call_args + assert call_kwargs[1]["params"]["foo"] == "bar" + assert call_kwargs[1]["params"]["key"] == "key" + + def test_get_failure(self) -> None: + client = SteamAPIClient("key", "id") + client.session.get = MagicMock(side_effect=requests.RequestException("fail")) + with pytest.raises(SteamAPIError): + client._get("https://example.com/api") + + def test_get_owned_games(self) -> None: + client = SteamAPIClient("key", "id") + with patch.object( + client, + "_get", + return_value={"response": {"games": [{"appid": 440}]}}, + ): + games = client.get_owned_games() + assert len(games) == 1 + assert games[0]["appid"] == 440 + + def test_get_owned_games_empty(self) -> None: + client = SteamAPIClient("key", "id") + with patch.object(client, "_get", return_value={"response": {}}): + games = client.get_owned_games() + assert games == [] + + def test_get_achievement_details(self) -> None: + client = SteamAPIClient("key", "id") + with patch.object( + client, + "_get", + return_value={ + "playerstats": { + "success": True, + "achievements": [ + { + "apiname": "ACH_1", + "name": "First", + "achieved": 1, + "unlocktime": 1000, + }, + ], + }, + }, + ): + result = client.get_achievement_details(440) + assert len(result) == 1 + assert result[0].achieved is True + + def test_get_achievement_details_failure(self) -> None: + client = SteamAPIClient("key", "id") + with patch.object(client, "_get", side_effect=SteamAPIError("fail")): + result = client.get_achievement_details(440) + assert result == [] + + def test_get_achievement_details_not_success(self) -> None: + client = SteamAPIClient("key", "id") + with patch.object( + client, + "_get", + return_value={"playerstats": {"success": False}}, + ): + result = client.get_achievement_details(440) + assert result == [] + + def test_fetch_one_game(self) -> None: + client = SteamAPIClient("key", "id") + ach = AchievementInfo("A1", "Ach1", True, 100) + with patch.object(client, "get_achievement_details", return_value=[ach]): + result = client._fetch_one_game( + {"appid": 440, "name": "TF2", "playtime_forever": 60}, + set(), + ) + assert result is not None + assert result.app_id == 440 + + def test_fetch_one_game_skipped(self) -> None: + client = SteamAPIClient("key", "id") + result = client._fetch_one_game({"appid": 440}, {440}) + assert result is None + + def test_fetch_one_game_no_achievements(self) -> None: + client = SteamAPIClient("key", "id") + with patch.object(client, "get_achievement_details", return_value=[]): + result = client._fetch_one_game({"appid": 440}, set()) + assert result is None + + def test_build_game_list(self) -> None: + client = SteamAPIClient("key", "id") + ach = AchievementInfo("A1", "Ach1", True, 100) + with ( + patch.object( + client, + "get_owned_games", + return_value=[{"appid": 440, "name": "TF2", "playtime_forever": 60}], + ), + patch.object(client, "get_achievement_details", return_value=[ach]), + ): + progress_calls: list[tuple[int, int]] = [] + + def progress(c: int, t: int) -> None: + progress_calls.append((c, t)) + + games = client.build_game_list(progress_callback=progress) + assert len(games) == 1 + assert len(progress_calls) > 0 + + def test_build_game_list_with_skip(self) -> None: + client = SteamAPIClient("key", "id") + with patch.object( + client, + "get_owned_games", + return_value=[{"appid": 440, "name": "TF2"}], + ): + games = client.build_game_list(skip_app_ids=[440]) + assert games == [] + + def test_build_game_list_exception_in_future(self) -> None: + client = SteamAPIClient("key", "id") + with ( + patch.object( + client, + "get_owned_games", + return_value=[{"appid": 440, "name": "TF2"}], + ), + patch.object( + client, + "get_achievement_details", + side_effect=SteamAPIError("err"), + ), + ): + games = client.build_game_list() + assert games == [] + + def test_refresh_single_game(self) -> None: + client = SteamAPIClient("key", "id") + ach = AchievementInfo("A1", "Ach1", True, 100) + with patch.object(client, "get_achievement_details", return_value=[ach]): + result = client.refresh_single_game(440, "TF2", 60) + assert result is not None + assert result.unlocked_achievements == 1 + + def test_refresh_single_game_no_achievements(self) -> None: + client = SteamAPIClient("key", "id") + with patch.object(client, "get_achievement_details", return_value=[]): + result = client.refresh_single_game(440, "TF2") + assert result is None diff --git a/steam_backlog_enforcer/tests/test_store_blocker.py b/steam_backlog_enforcer/tests/test_store_blocker.py new file mode 100644 index 0000000..7a97c69 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_store_blocker.py @@ -0,0 +1,470 @@ +"""Tests for store_blocker module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +from python_pkg.steam_backlog_enforcer.store_blocker import ( + _block_store_iptables, + _block_via_hosts_install, + _is_iptables_blocked, + _unblock_store_iptables, + block_store, + is_store_blocked, + unblock_store, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestIsStoreBlocked: + """Tests for is_store_blocked.""" + + def test_blocked_in_hosts(self, tmp_path: Path) -> None: + hosts_file = tmp_path / "hosts" + hosts_file.write_text("0.0.0.0 store.steampowered.com\n", encoding="utf-8") + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", + hosts_file, + ), + ): + assert is_store_blocked() is True + + def test_commented_in_hosts(self, tmp_path: Path) -> None: + hosts_file = tmp_path / "hosts" + hosts_file.write_text("# 0.0.0.0 store.steampowered.com\n", encoding="utf-8") + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", + hosts_file, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked", + return_value=False, + ), + ): + assert is_store_blocked() is False + + def test_not_in_hosts_iptables_blocked(self, tmp_path: Path) -> None: + hosts_file = tmp_path / "hosts" + hosts_file.write_text("127.0.0.1 localhost\n", encoding="utf-8") + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", + hosts_file, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked", + return_value=True, + ), + ): + assert is_store_blocked() is True + + def test_hosts_read_error(self, tmp_path: Path) -> None: + hosts_file = tmp_path / "nonexistent" + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", + hosts_file, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked", + return_value=False, + ), + ): + assert is_store_blocked() is False + + def test_wrong_redirect_ip(self, tmp_path: Path) -> None: + hosts_file = tmp_path / "hosts" + hosts_file.write_text("127.0.0.1 store.steampowered.com\n", encoding="utf-8") + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", + hosts_file, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked", + return_value=False, + ), + ): + assert is_store_blocked() is False + + +class TestBlockStore: + """Tests for block_store.""" + + def test_already_blocked(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=True, + ): + assert block_store() is True + + def test_reblock_succeeds(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", + side_effect=[False, True], + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables", + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache", + ), + ): + assert block_store() is True + + def test_fallback_to_install_script(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", + side_effect=[False, False], + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._block_via_hosts_install", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache", + ), + ): + assert block_store() is True + + def test_all_fail(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", + side_effect=[False, False], + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._block_via_hosts_install", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables", + return_value=False, + ), + ): + assert block_store() is False + + def test_iptables_only_succeeds(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", + side_effect=[False, False], + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._block_via_hosts_install", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache", + ), + ): + assert block_store() is True + + +class TestBlockViaHostsInstall: + """Tests for _block_via_hosts_install.""" + + def test_already_blocked(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=True, + ): + assert _block_via_hosts_install() is True + + def test_script_missing(self, tmp_path: Path) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", + tmp_path / "nonexistent.sh", + ), + ): + assert _block_via_hosts_install() is False + + def test_script_succeeds(self, tmp_path: Path) -> None: + script = tmp_path / "install.sh" + script.touch() + mock_result = MagicMock(returncode=0) + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", + script, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ), + ): + assert _block_via_hosts_install() is True + + def test_script_fails(self, tmp_path: Path) -> None: + script = tmp_path / "install.sh" + script.touch() + mock_result = MagicMock(returncode=1, stderr="error", stdout="") + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", + script, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ), + ): + assert _block_via_hosts_install() is False + + def test_script_fails_no_stderr(self, tmp_path: Path) -> None: + script = tmp_path / "install.sh" + script.touch() + mock_result = MagicMock(returncode=1, stderr="", stdout="out") + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", + script, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ), + ): + assert _block_via_hosts_install() is False + + def test_script_os_error(self, tmp_path: Path) -> None: + script = tmp_path / "install.sh" + script.touch() + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", + script, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + side_effect=OSError, + ), + ): + assert _block_via_hosts_install() is False + + +class TestIsIptablesBlocked: + """Tests for _is_iptables_blocked.""" + + def test_blocked(self) -> None: + mock_result = MagicMock(returncode=0, stdout="DROP blah") + with patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ): + assert _is_iptables_blocked() is True + + def test_not_blocked_no_drop(self) -> None: + mock_result = MagicMock(returncode=0, stdout="ACCEPT") + with patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ): + assert _is_iptables_blocked() is False + + def test_not_blocked_error(self) -> None: + mock_result = MagicMock(returncode=1, stdout="") + with patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ): + assert _is_iptables_blocked() is False + + def test_os_error(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + side_effect=OSError, + ): + assert _is_iptables_blocked() is False + + +class TestBlockStoreIptables: + """Tests for _block_store_iptables.""" + + def test_success(self) -> None: + mock_result = MagicMock(returncode=0) + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.socket.getaddrinfo", + return_value=[ + (None, None, None, None, ("1.2.3.4", 443)), + ], + ), + ): + assert _block_store_iptables() is True + + def test_os_error(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + side_effect=OSError, + ): + assert _block_store_iptables() is False + + def test_dns_resolution_fails(self) -> None: + import socket + + mock_result = MagicMock(returncode=0) + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + return_value=mock_result, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.socket.getaddrinfo", + side_effect=socket.gaierror, + ), + ): + # Should succeed even if DNS fails (just no IPs to block) + assert _block_store_iptables() is True + + def test_chain_hook_needed(self) -> None: + results = [ + MagicMock(returncode=0), # -N + MagicMock(returncode=0), # -F + MagicMock(returncode=1), # -C OUTPUT (not hooked) + MagicMock(returncode=0), # -I OUTPUT + ] + call_count = 0 + + def side_effect(*args: Any, **kwargs: Any) -> MagicMock: + nonlocal call_count + idx = min(call_count, len(results) - 1) + call_count += 1 + return results[idx] + + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + side_effect=side_effect, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.socket.getaddrinfo", + side_effect=__import__("socket").gaierror, + ), + ): + assert _block_store_iptables() is True + + +class TestUnblockStore: + """Tests for unblock_store.""" + + def test_both_succeed(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._unblock_store_iptables", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._unblock_hosts", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache", + ), + ): + assert unblock_store() is True + + def test_iptables_fails(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._unblock_store_iptables", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._unblock_hosts", + return_value=True, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache", + ), + ): + assert unblock_store() is True + + def test_both_fail(self) -> None: + with ( + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._unblock_store_iptables", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker._unblock_hosts", + return_value=False, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache", + ), + ): + assert unblock_store() is False + + +class TestUnblockStoreIptables: + """Tests for _unblock_store_iptables.""" + + def test_success(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + ): + assert _unblock_store_iptables() is True + + def test_os_error(self) -> None: + with patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + side_effect=OSError, + ): + assert _unblock_store_iptables() is False diff --git a/steam_backlog_enforcer/tests/test_store_blocker_part2.py b/steam_backlog_enforcer/tests/test_store_blocker_part2.py new file mode 100644 index 0000000..aeddd43 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_store_blocker_part2.py @@ -0,0 +1,200 @@ +"""Tests for store_blocker module — part 2 (missing coverage).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from python_pkg.steam_backlog_enforcer.store_blocker import ( + _disable_hosts_protection, + _enable_hosts_protection, + _reblock_hosts, + _sudo_write_hosts, + _unblock_hosts, + flush_dns_cache, +) + +if TYPE_CHECKING: + from pathlib import Path + +PKG = "python_pkg.steam_backlog_enforcer.store_blocker" + + +class TestSudoWriteHosts: + """Tests for _sudo_write_hosts.""" + + def test_writes_content(self) -> None: + with patch(f"{PKG}.subprocess.run") as mock_run: + _sudo_write_hosts("127.0.0.1 localhost\n") + mock_run.assert_called_once() + assert mock_run.call_args.kwargs["input"] == b"127.0.0.1 localhost\n" + + +class TestDisableHostsProtection: + """Tests for _disable_hosts_protection.""" + + def test_stops_services_unmounts_chattr(self) -> None: + findmnt_found = MagicMock(returncode=0) + + def run_side_effect( + cmd: list[str], + **kwargs: object, + ) -> MagicMock: + if any("findmnt" in str(c) for c in cmd): + return findmnt_found + return MagicMock(returncode=0) + + with patch(f"{PKG}.subprocess.run", side_effect=run_side_effect): + _disable_hosts_protection() + + def test_no_bind_mount(self) -> None: + findmnt_missing = MagicMock(returncode=1) + + def run_side_effect( + cmd: list[str], + **kwargs: object, + ) -> MagicMock: + if any("findmnt" in str(c) for c in cmd): + return findmnt_missing + return MagicMock(returncode=0) + + with patch(f"{PKG}.subprocess.run", side_effect=run_side_effect): + _disable_hosts_protection() + + +class TestEnableHostsProtection: + """Tests for _enable_hosts_protection.""" + + def test_with_locked_copy(self, tmp_path: Path) -> None: + locked_copy = tmp_path / "locked-hosts" + locked_copy.touch() + with ( + patch(f"{PKG}.subprocess.run"), + patch(f"{PKG}._LOCKED_HOSTS_COPY", locked_copy), + ): + _enable_hosts_protection() + + def test_without_locked_copy(self, tmp_path: Path) -> None: + locked_copy = tmp_path / "nonexistent" + with ( + patch(f"{PKG}.subprocess.run"), + patch(f"{PKG}._LOCKED_HOSTS_COPY", locked_copy), + ): + _enable_hosts_protection() + + +class TestUnblockHosts: + """Tests for _unblock_hosts.""" + + def test_not_blocked(self) -> None: + with patch(f"{PKG}.is_store_blocked", return_value=False): + result = _unblock_hosts() + assert result is True + + def test_comments_out_entries(self, tmp_path: Path) -> None: + hosts_file = tmp_path / "hosts" + hosts_file.write_text( + "127.0.0.1 localhost\n" + "0.0.0.0 store.steampowered.com\n" + "0.0.0.0 checkout.steampowered.com\n", + encoding="utf-8", + ) + with ( + patch(f"{PKG}.is_store_blocked", return_value=True), + patch(f"{PKG}.HOSTS_FILE", hosts_file), + patch(f"{PKG}._disable_hosts_protection"), + patch(f"{PKG}._enable_hosts_protection"), + patch(f"{PKG}._sudo_write_hosts") as mock_write, + ): + result = _unblock_hosts() + assert result is True + written = mock_write.call_args[0][0] + assert "# 0.0.0.0 store.steampowered.com" in written + + def test_no_change_needed(self, tmp_path: Path) -> None: + hosts_file = tmp_path / "hosts" + hosts_file.write_text( + "# 0.0.0.0 store.steampowered.com\n", + encoding="utf-8", + ) + with ( + patch(f"{PKG}.is_store_blocked", return_value=True), + patch(f"{PKG}.HOSTS_FILE", hosts_file), + patch(f"{PKG}._disable_hosts_protection"), + patch(f"{PKG}._enable_hosts_protection"), + patch(f"{PKG}._sudo_write_hosts") as mock_write, + ): + result = _unblock_hosts() + assert result is True + mock_write.assert_not_called() + + def test_os_error(self) -> None: + with ( + patch(f"{PKG}.is_store_blocked", return_value=True), + patch(f"{PKG}._disable_hosts_protection", side_effect=OSError), + ): + result = _unblock_hosts() + assert result is False + + +class TestReblockHosts: + """Tests for _reblock_hosts.""" + + def test_uncomments_entries(self, tmp_path: Path) -> None: + hosts_file = tmp_path / "hosts" + hosts_file.write_text( + "127.0.0.1 localhost\n" + "# 0.0.0.0 store.steampowered.com\n" + "# 0.0.0.0 checkout.steampowered.com\n", + encoding="utf-8", + ) + with ( + patch(f"{PKG}.HOSTS_FILE", hosts_file), + patch(f"{PKG}._disable_hosts_protection"), + patch(f"{PKG}._enable_hosts_protection"), + patch(f"{PKG}._sudo_write_hosts") as mock_write, + ): + result = _reblock_hosts() + assert result is True + written = mock_write.call_args[0][0] + # Should have uncommented lines + assert "0.0.0.0 store.steampowered.com" in written + assert "# 0.0.0.0 store.steampowered.com" not in written + + def test_no_change(self, tmp_path: Path) -> None: + hosts_file = tmp_path / "hosts" + hosts_file.write_text("127.0.0.1 localhost\n", encoding="utf-8") + with ( + patch(f"{PKG}.HOSTS_FILE", hosts_file), + patch(f"{PKG}._disable_hosts_protection"), + patch(f"{PKG}._enable_hosts_protection"), + patch(f"{PKG}._sudo_write_hosts") as mock_write, + ): + result = _reblock_hosts() + assert result is True + mock_write.assert_not_called() + + def test_os_error(self) -> None: + with patch(f"{PKG}._disable_hosts_protection", side_effect=OSError): + result = _reblock_hosts() + assert result is False + + +class TestFlushDnsCache: + """Tests for flush_dns_cache.""" + + def test_runs_commands(self) -> None: + with patch(f"{PKG}.subprocess.run") as mock_run: + flush_dns_cache() + assert mock_run.call_count == 3 + + def test_file_not_found_suppressed(self) -> None: + with patch( + f"{PKG}.subprocess.run", + side_effect=FileNotFoundError, + ): + flush_dns_cache() + + def test_os_error_suppressed(self) -> None: + with patch(f"{PKG}.subprocess.run", side_effect=OSError): + flush_dns_cache()