diff --git a/src/client.py b/src/client.py index ecaf509..ee77945 100644 --- a/src/client.py +++ b/src/client.py @@ -20,7 +20,7 @@ from pathlib import Path from .app_log import log from .config import load_config, update_config -from .platform_ops import display_info +from .platform_ops import display_info, ensure_dir def remove_wdb(client_dir: str): """Delete the client's WDB folder (server-data cache, safe to drop).""" diff --git a/src/config.py b/src/config.py index 99d6122..df84f96 100644 --- a/src/config.py +++ b/src/config.py @@ -112,8 +112,6 @@ def update_config(mutator): return cfg -def ensure_dir(path): - Path(path).mkdir(parents=True, exist_ok=True) # ── logging ───────────────────────────────────────────────────────────────── diff --git a/src/mods.py b/src/mods.py index ea41917..a29345f 100644 --- a/src/mods.py +++ b/src/mods.py @@ -22,7 +22,8 @@ import urllib.error import zipfile import io from .app_log import log -from .config import SERVER, UA, UPDATER_VERSION, load_config, update_config, ensure_dir +from .config import SERVER, UA, UPDATER_VERSION, load_config, update_config +from .platform_ops import ensure_dir from .net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen MODS_REGISTRY = [ diff --git a/src/mpq_patches.py b/src/mpq_patches.py index bd4a45c..6f9a9fd 100644 --- a/src/mpq_patches.py +++ b/src/mpq_patches.py @@ -4,7 +4,8 @@ import hashlib import os import urllib.request -from .config import UA, DOWNLOAD_TIMEOUT, ensure_dir +from .config import UA, DOWNLOAD_TIMEOUT +from .platform_ops import ensure_dir from .net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen # Downloadable MPQ content patches (not shipped with the client). Each is diff --git a/src/platform_ops.py b/src/platform_ops.py index d5bc782..60c411a 100644 --- a/src/platform_ops.py +++ b/src/platform_ops.py @@ -10,6 +10,10 @@ import platform from .app_log import log +# NOTE: Creates dir and verifies it was made +def ensure_dir(path): + Path(path).mkdir(parents=True, exist_ok=True) + # Windows: %LOCALAPPDATA%\octo-updater\ # Linux: $XDG_DATA_HOME/octo-updater or $HOME/.local/share/octo-updater def user_data_dir() -> Path: diff --git a/src/tests/test_platform_ops.py b/src/tests/test_platform_ops.py index bcfee9f..fb80de8 100644 --- a/src/tests/test_platform_ops.py +++ b/src/tests/test_platform_ops.py @@ -1,75 +1,28 @@ - -from src import platform_ops -import hashlib import io import os import zipfile from pathlib import Path -from unittest.mock import Mock +from unittest.mock import Mock, call + import pytest -from src import config -from src import net + +from src import platform_ops - - - -def test_user_config_dir_windows(monkeypatch): - monkeypatch.setattr(platform_ops.sys, "platform", "win32") - monkeypatch.setenv("APPDATA", r"C:\Users\test\AppData\Roaming") - - result = platform_ops.user_config_dir() - - assert result == Path(r"C:\Users\test\AppData\Roaming") - - -def test_user_config_dir_windows_without_appdata(monkeypatch): - monkeypatch.setattr(platform_ops.sys, "platform", "win32") - monkeypatch.delenv("APPDATA", raising=False) - - with pytest.raises(RuntimeError, match="APPDATA is not set"): - platform_ops.user_config_dir() - - -def test_user_config_dir_linux_with_xdg_config_home(monkeypatch): - monkeypatch.setattr(platform_ops.sys, "platform", "linux") - monkeypatch.setenv("XDG_CONFIG_HOME", "/tmp/test-config") - - result = platform_ops.user_config_dir() - - assert result == Path("/tmp/test-config") / "OctoUpdater" - - -def test_user_config_dir_linux_without_xdg_config_home(monkeypatch): - monkeypatch.setattr(platform_ops.sys, "platform", "linux") - monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) - monkeypatch.setattr( - platform_ops.Path, - "home", - lambda: Path("/home/testuser"), - ) - - result = platform_ops.user_config_dir() - - assert result == Path("/home/testuser/.config/OctoUpdater") - - - - -# install_aria2c_windows() currently keeps this checksum inside the function, -# so successful tests mock hashlib.sha256() to return the checksum expected by -# the implementation. -ARIA2_EXPECTED_SHA256 = ( +EXPECTED_ARIA2_SHA256 = ( "35f6514cc5dd7e98a87b3c4c2d25a075" "4b9b063dbe59bc0f22d483464f61e5b6" ) +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + def make_aria2_zip( exe_data=b"fake aria2c executable", exe_name="aria2-1.37.0-win-32bit-build1/aria2c.exe", ): - """Create an in-memory ZIP containing a fake aria2c.exe.""" buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as zf: @@ -79,851 +32,752 @@ def make_aria2_zip( def make_response(data): - """Create a mocked context-manager HTTP response.""" response = Mock() response.read.return_value = data response.__enter__ = Mock(return_value=response) response.__exit__ = Mock(return_value=False) - return response def allow_fake_zip_checksum(monkeypatch): - """ - Make hashlib.sha256(...).hexdigest() return the checksum expected by - install_aria2c_windows(). - """ digest = Mock() - digest.hexdigest.return_value = ARIA2_EXPECTED_SHA256 + digest.hexdigest.return_value = EXPECTED_ARIA2_SHA256 - sha_mock = Mock(return_value=digest) + sha256 = Mock(return_value=digest) monkeypatch.setattr( platform_ops.hashlib, "sha256", - sha_mock, + sha256, ) - return sha_mock + return sha256 -class TestInstallAria2cWindows: - def test_downloads_and_installs_aria2c( - self, - monkeypatch, - tmp_path, +# --------------------------------------------------------------------------- +# ensure_dir +# --------------------------------------------------------------------------- + +def test_ensure_dir_creates_directory(tmp_path): + target = tmp_path / "test-directory" + + platform_ops.ensure_dir(target) + + assert target.is_dir() + + +def test_ensure_dir_creates_parent_directories(tmp_path): + target = tmp_path / "one" / "two" / "three" + + platform_ops.ensure_dir(target) + + assert target.is_dir() + + +def test_ensure_dir_existing_directory_does_not_fail(tmp_path): + target = tmp_path / "existing" + target.mkdir() + + platform_ops.ensure_dir(target) + + assert target.is_dir() + + +def test_ensure_dir_accepts_string_path(tmp_path): + target = tmp_path / "string-path" + + platform_ops.ensure_dir(str(target)) + + assert target.is_dir() + + +# --------------------------------------------------------------------------- +# find_aria2c +# --------------------------------------------------------------------------- + +def test_find_aria2c_returns_discovered_path(monkeypatch): + expected = "/usr/bin/aria2c" + + which = Mock(return_value=expected) + monkeypatch.setattr(platform_ops.shutil, "which", which) + + result = platform_ops.find_aria2c() + + assert result == expected + which.assert_called_once_with("aria2c") + + +def test_find_aria2c_returns_none_when_missing(monkeypatch): + which = Mock(return_value=None) + monkeypatch.setattr(platform_ops.shutil, "which", which) + + result = platform_ops.find_aria2c() + + assert result is None + which.assert_called_once_with("aria2c") + + +def test_find_aria2c_returns_windows_path_unchanged(monkeypatch): + expected = r"C:\Program Files\aria2\aria2c.exe" + + monkeypatch.setattr( + platform_ops.shutil, + "which", + Mock(return_value=expected), + ) + + assert platform_ops.find_aria2c() == expected + + +# --------------------------------------------------------------------------- +# install_aria2c_windows +# --------------------------------------------------------------------------- + +def test_install_aria2c_windows_installs_executable( + tmp_path, + monkeypatch, +): + exe_data = b"fake aria2 executable" + archive = make_aria2_zip(exe_data) + + allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + result = platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + expected = tmp_path / "aria2c.exe" + + assert Path(result) == expected + assert expected.read_bytes() == exe_data + + +def test_install_aria2c_windows_returns_aria2_path( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() + + allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + result = platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + assert Path(result) == tmp_path / "aria2c.exe" + + +def test_install_aria2c_windows_uses_expected_url( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() + + allow_fake_zip_checksum(monkeypatch) + + captured = {} + + def fake_urlopen(req, **kwargs): + captured["url"] = req.full_url + return make_response(archive) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + fake_urlopen, + ) + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + assert captured["url"] == ( + "https://github.com/aria2/aria2/releases/download/" + "release-1.37.0/aria2-1.37.0-win-32bit-build1.zip" + ) + + +def test_install_aria2c_windows_uses_60_second_timeout( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() + + allow_fake_zip_checksum(monkeypatch) + + urlopen = Mock(return_value=make_response(archive)) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + urlopen, + ) + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + _, kwargs = urlopen.call_args + + assert kwargs["timeout"] == 60 + + +def test_install_aria2c_windows_passes_allowed_hosts( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() + + allow_fake_zip_checksum(monkeypatch) + + urlopen = Mock(return_value=make_response(archive)) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + urlopen, + ) + + hosts = { + "github.com", + "objects.githubusercontent.com", + } + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS=hosts, + log_fn=Mock(), + ) + + _, kwargs = urlopen.call_args + + assert kwargs["allowed_hosts"] is hosts + + +def test_install_aria2c_windows_reads_response( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() + + allow_fake_zip_checksum(monkeypatch) + + response = make_response(archive) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=response), + ) + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + response.read.assert_called_once_with() + + +def test_install_aria2c_windows_hashes_downloaded_archive( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() + + sha256 = allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + sha256.assert_called_once_with(archive) + + +def test_install_aria2c_windows_rejects_bad_checksum( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() + + digest = Mock() + digest.hexdigest.return_value = "0" * 64 + + monkeypatch.setattr( + platform_ops.hashlib, + "sha256", + Mock(return_value=digest), + ) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + with pytest.raises( + RuntimeError, + match="aria2 checksum mismatch", ): - exe_data = b"fake aria2c executable" - zip_data = make_aria2_zip(exe_data) - - response = make_response(zip_data) - urlopen_mock = Mock(return_value=response) - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - net, - "secure_urlopen", - urlopen_mock, - ) - - allow_fake_zip_checksum(monkeypatch) - - result = platform_ops.install_aria2c_windows() - - expected = tmp_path / "aria2c.exe" - - assert Path(result) == expected - assert expected.exists() - assert expected.read_bytes() == exe_data - - def test_returns_installed_aria2c_path( - self, - monkeypatch, - tmp_path, - ): - zip_data = make_aria2_zip() - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - net, - "secure_urlopen", - Mock(return_value=make_response(zip_data)), - ) - - allow_fake_zip_checksum(monkeypatch) - - result = platform_ops.install_aria2c_windows() - - assert Path(result) == tmp_path / "aria2c.exe" - - def test_download_request_uses_user_agent( - self, - monkeypatch, - tmp_path, - ): - zip_data = make_aria2_zip() - - urlopen_mock = Mock( - return_value=make_response(zip_data) - ) - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - net, - "secure_urlopen", - urlopen_mock, - ) - - allow_fake_zip_checksum(monkeypatch) - - platform_ops.install_aria2c_windows() - - request = urlopen_mock.call_args.args[0] - - assert ( - request.get_header("User-agent") - == platform_ops.UA - ) - - def test_download_request_uses_expected_url( - self, - monkeypatch, - tmp_path, - ): - zip_data = make_aria2_zip() - - urlopen_mock = Mock( - return_value=make_response(zip_data) - ) - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - urlopen_mock, - ) - - allow_fake_zip_checksum(monkeypatch) - - platform_ops.install_aria2c_windows() - - request = urlopen_mock.call_args.args[0] - - assert request.full_url == ( - "https://github.com/aria2/aria2/releases/download/" - "release-1.37.0/" - "aria2-1.37.0-win-32bit-build1.zip" - ) - - def test_uses_sixty_second_timeout( - self, - monkeypatch, - tmp_path, - ): - zip_data = make_aria2_zip() - - urlopen_mock = Mock( - return_value=make_response(zip_data) - ) - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - urlopen_mock, - ) - - allow_fake_zip_checksum(monkeypatch) - - platform_ops.install_aria2c_windows() - - assert ( - urlopen_mock.call_args.kwargs["timeout"] - == 60 - ) - - def test_restricts_download_hosts( - self, - monkeypatch, - tmp_path, - ): - zip_data = make_aria2_zip() - - urlopen_mock = Mock( - return_value=make_response(zip_data) - ) - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - urlopen_mock, - ) - - allow_fake_zip_checksum(monkeypatch) - - platform_ops.install_aria2c_windows() - - assert ( - urlopen_mock.call_args.kwargs["allowed_hosts"] - == platform_ops.ALLOWED_DOWNLOAD_HOSTS - ) - - def test_hashes_downloaded_archive( - self, - monkeypatch, - tmp_path, - ): - zip_data = make_aria2_zip() - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - sha_mock = allow_fake_zip_checksum( - monkeypatch - ) - - platform_ops.install_aria2c_windows() - - sha_mock.assert_called_once_with(zip_data) - - def test_rejects_checksum_mismatch( - self, - monkeypatch, - tmp_path, - ): - zip_data = make_aria2_zip() - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - bad_digest = Mock() - bad_digest.hexdigest.return_value = "0" * 64 - - monkeypatch.setattr( - platform_ops.hashlib, - "sha256", - Mock(return_value=bad_digest), - ) - - with pytest.raises( - RuntimeError, - match="aria2 checksum mismatch", - ): - platform_ops.install_aria2c_windows() - - assert not ( - tmp_path / "aria2c.exe" - ).exists() - - assert not ( - tmp_path / "aria2c.exe.part" - ).exists() - - def test_checksum_failure_happens_before_extraction( - self, - monkeypatch, - tmp_path, - ): - zip_data = b"not even a valid zip" - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - bad_digest = Mock() - bad_digest.hexdigest.return_value = "0" * 64 - - monkeypatch.setattr( - platform_ops.hashlib, - "sha256", - Mock(return_value=bad_digest), - ) - - # If checksum validation occurs first, we get RuntimeError - # instead of zipfile.BadZipFile. - with pytest.raises( - RuntimeError, - match="aria2 checksum mismatch", - ): - platform_ops.install_aria2c_windows() - - def test_rejects_archive_without_aria2c( - self, - monkeypatch, - tmp_path, - ): - buffer = io.BytesIO() - - with zipfile.ZipFile(buffer, "w") as zf: - zf.writestr( - "README.txt", - b"no executable here", - ) - - zip_data = buffer.getvalue() - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - allow_fake_zip_checksum(monkeypatch) - - with pytest.raises( - RuntimeError, - match="aria2c.exe not found", - ): - platform_ops.install_aria2c_windows() - - assert not ( - tmp_path / "aria2c.exe" - ).exists() - - def test_finds_executable_inside_nested_directory( - self, - monkeypatch, - tmp_path, - ): - exe_data = b"nested executable" - - zip_data = make_aria2_zip( - exe_data=exe_data, - exe_name=( - "aria2-1.37.0-win-32bit-build1/" - "some/nested/path/aria2c.exe" - ), - ) - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - allow_fake_zip_checksum(monkeypatch) - - result = platform_ops.install_aria2c_windows() - - assert Path(result).read_bytes() == exe_data - - def test_finds_exe_case_insensitively( - self, - monkeypatch, - tmp_path, - ): - exe_data = b"aria binary" - - zip_data = make_aria2_zip( - exe_data=exe_data, - exe_name="aria2-build/ARIA2C.EXE", - ) - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - allow_fake_zip_checksum(monkeypatch) - - result = platform_ops.install_aria2c_windows() - - assert Path(result).read_bytes() == exe_data - - def test_only_extracts_aria2c_executable( - self, - monkeypatch, - tmp_path, - ): - exe_data = b"aria executable" - - buffer = io.BytesIO() - - with zipfile.ZipFile(buffer, "w") as zf: - zf.writestr( - "aria2-build/aria2c.exe", - exe_data, - ) - zf.writestr( - "aria2-build/README.txt", - b"readme", - ) - zf.writestr( - "aria2-build/other.dll", - b"dll", - ) - - zip_data = buffer.getvalue() - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - allow_fake_zip_checksum(monkeypatch) - - platform_ops.install_aria2c_windows() - - assert ( - tmp_path / "aria2c.exe" - ).read_bytes() == exe_data - - assert not ( - tmp_path / "README.txt" - ).exists() - - assert not ( - tmp_path / "other.dll" - ).exists() - - def test_creates_app_data_directory( - self, - monkeypatch, - tmp_path, - ): - app_dir = tmp_path / "new-app-data" - - zip_data = make_aria2_zip() - - ensure_mock = Mock( - side_effect=lambda path: os.makedirs( - path, - exist_ok=True, - ) - ) - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(app_dir), - ) - - monkeypatch.setattr( - platform_ops, - "ensure_dir", - ensure_mock, - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - allow_fake_zip_checksum(monkeypatch) - - platform_ops.install_aria2c_windows() - - ensure_mock.assert_called_once_with( - str(app_dir) - ) - - assert ( - app_dir / "aria2c.exe" - ).exists() - - def test_writes_to_part_file_before_replace( - self, - monkeypatch, - tmp_path, - ): - zip_data = make_aria2_zip() - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - allow_fake_zip_checksum(monkeypatch) - - real_replace = os.replace - replace_mock = Mock( - side_effect=real_replace - ) - - monkeypatch.setattr( - platform_ops.os, - "replace", - replace_mock, - ) - - platform_ops.install_aria2c_windows() - - replace_mock.assert_called_once_with( - str(tmp_path / "aria2c.exe.part"), - str(tmp_path / "aria2c.exe"), - ) - - def test_part_file_removed_after_success( - self, - monkeypatch, - tmp_path, - ): - zip_data = make_aria2_zip() - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - allow_fake_zip_checksum(monkeypatch) - - platform_ops.install_aria2c_windows() - - assert not ( - tmp_path / "aria2c.exe.part" - ).exists() - - assert ( - tmp_path / "aria2c.exe" - ).exists() - - def test_replaces_existing_aria2c( - self, - monkeypatch, - tmp_path, - ): - old_exe = tmp_path / "aria2c.exe" - old_exe.write_bytes(b"old executable") - - new_data = b"new executable" - zip_data = make_aria2_zip(new_data) - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - allow_fake_zip_checksum(monkeypatch) - - platform_ops.install_aria2c_windows() - - assert old_exe.read_bytes() == new_data - - def test_logs_fetch_and_ready_messages( - self, - monkeypatch, - tmp_path, - ): - zip_data = make_aria2_zip() - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - allow_fake_zip_checksum(monkeypatch) - - logger = Mock() - platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + assert not (tmp_path / "aria2c.exe").exists() + + +def test_install_aria2c_windows_checksum_error_contains_digest_prefix( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() + + bad_digest = "123456789abc" + ("0" * 52) + + digest = Mock() + digest.hexdigest.return_value = bad_digest + + monkeypatch.setattr( + platform_ops.hashlib, + "sha256", + Mock(return_value=digest), + ) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + with pytest.raises( + RuntimeError, + match="123456789abc", + ): + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + +def test_install_aria2c_windows_rejects_archive_without_executable( + tmp_path, + monkeypatch, +): + buffer = io.BytesIO() + + with zipfile.ZipFile(buffer, "w") as zf: + zf.writestr("README.txt", b"no executable") + + archive = buffer.getvalue() + + allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + with pytest.raises( + RuntimeError, + match="aria2c.exe not found", + ): + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + +def test_install_aria2c_windows_finds_nested_executable( + tmp_path, + monkeypatch, +): + exe_data = b"nested aria2 executable" + + archive = make_aria2_zip( + exe_data=exe_data, + exe_name="release/bin/aria2c.exe", + ) + + allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + assert ( + tmp_path / "aria2c.exe" + ).read_bytes() == exe_data + + +def test_install_aria2c_windows_executable_search_is_case_insensitive( + tmp_path, + monkeypatch, +): + exe_data = b"uppercase executable" + + archive = make_aria2_zip( + exe_data=exe_data, + exe_name="release/ARIA2C.EXE", + ) + + allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + assert ( + tmp_path / "aria2c.exe" + ).read_bytes() == exe_data + + +def test_install_aria2c_windows_calls_ensure_dir( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() + + allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + real_ensure_dir = platform_ops.ensure_dir + ensure_dir = Mock(side_effect=real_ensure_dir) + + monkeypatch.setattr( + platform_ops, + "ensure_dir", + ensure_dir, + ) + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + ensure_dir.assert_called_once_with(tmp_path) + + +def test_install_aria2c_windows_ensure_dir_creates_destination( + tmp_path, + monkeypatch, +): + destination = tmp_path / "nested" / "app-data" + + archive = make_aria2_zip() + + allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=destination, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + assert destination.is_dir() + assert (destination / "aria2c.exe").is_file() + + +def test_install_aria2c_windows_writes_part_file_before_replace( + tmp_path, + monkeypatch, +): + exe_data = b"aria2 executable" + archive = make_aria2_zip(exe_data) + + allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + real_replace = os.replace + observed = {} + + def inspect_replace(src, dst): + observed["src"] = src + observed["dst"] = dst + observed["part_data"] = Path(src).read_bytes() + return real_replace(src, dst) + + monkeypatch.setattr( + platform_ops.os, + "replace", + inspect_replace, + ) + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + assert Path(observed["src"]) == tmp_path / "aria2c.exe.part" + assert Path(observed["dst"]) == tmp_path / "aria2c.exe" + assert observed["part_data"] == exe_data + + +def test_install_aria2c_windows_removes_part_file_after_success( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() + + allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + assert not (tmp_path / "aria2c.exe.part").exists() + + +def test_install_aria2c_windows_replaces_existing_executable( + tmp_path, + monkeypatch, +): + executable = tmp_path / "aria2c.exe" + executable.write_bytes(b"old") + + archive = make_aria2_zip( + exe_data=b"new", + ) + + allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), + ) + + assert executable.read_bytes() == b"new" + + +def test_install_aria2c_windows_logs_download_and_success( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() + + allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + logger = Mock() + + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=logger, + ) + + assert logger.call_args_list == [ + call( + "Fetching aria2c (one-time, ~2.5 MB)…", + "acct", + ), + call( + "aria2c ready.", + "ok", + ), + ] + + +def test_install_aria2c_windows_does_not_log_ready_after_checksum_failure( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() + + digest = Mock() + digest.hexdigest.return_value = "0" * 64 + + monkeypatch.setattr( + platform_ops.hashlib, + "sha256", + Mock(return_value=digest), + ) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + logger = Mock() + + with pytest.raises(RuntimeError): + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, log_fn=logger, ) - assert logger.call_count == 2 + assert call("aria2c ready.", "ok") not in logger.call_args_list - logger.assert_any_call( - "Fetching aria2c (one-time, ~2.5 MB)…", - "acct", - ) - logger.assert_any_call( - "aria2c ready.", - "ok", - ) - - def test_does_not_log_ready_on_checksum_failure( - self, - monkeypatch, - tmp_path, - ): - zip_data = make_aria2_zip() - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - bad_digest = Mock() - bad_digest.hexdigest.return_value = "0" * 64 - - monkeypatch.setattr( - platform_ops.hashlib, - "sha256", - Mock(return_value=bad_digest), - ) - - logger = Mock() - - with pytest.raises(RuntimeError): - platform_ops.install_aria2c_windows( - log_fn=logger, +def test_install_aria2c_windows_propagates_network_failure( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock( + side_effect=OSError( + "network unavailable" ) + ), + ) - logger.assert_any_call( - "Fetching aria2c (one-time, ~2.5 MB)…", - "acct", - ) - - assert ( - ("aria2c ready.", "ok") - not in [ - call.args - for call in logger.call_args_list - ] - ) - - def test_propagates_network_failure( - self, - monkeypatch, - tmp_path, + with pytest.raises( + OSError, + match="network unavailable", ): - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), ) - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - side_effect=OSError( - "network unavailable" - ) - ), + +def test_install_aria2c_windows_rejects_invalid_zip( + tmp_path, + monkeypatch, +): + invalid_zip = b"this is not a zip archive" + + allow_fake_zip_checksum(monkeypatch) + + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(invalid_zip)), + ) + + with pytest.raises(zipfile.BadZipFile): + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), ) - with pytest.raises( - OSError, - match="network unavailable", - ): - platform_ops.install_aria2c_windows() - assert not ( - tmp_path / "aria2c.exe" - ).exists() +def test_install_aria2c_windows_does_not_call_ensure_dir_on_bad_checksum( + tmp_path, + monkeypatch, +): + archive = make_aria2_zip() - assert not ( - tmp_path / "aria2c.exe.part" - ).exists() + digest = Mock() + digest.hexdigest.return_value = "0" * 64 - def test_propagates_invalid_zip_error( - self, - monkeypatch, - tmp_path, - ): - invalid_zip = b"not a zip archive" + monkeypatch.setattr( + platform_ops.hashlib, + "sha256", + Mock(return_value=digest), + ) - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(tmp_path), + monkeypatch.setattr( + platform_ops, + "secure_urlopen", + Mock(return_value=make_response(archive)), + ) + + ensure_dir = Mock() + + monkeypatch.setattr( + platform_ops, + "ensure_dir", + ensure_dir, + ) + + with pytest.raises(RuntimeError): + platform_ops.install_aria2c_windows( + APP_DATA_DIR=tmp_path, + ALLOWED_DOWNLOAD_HOSTS={"github.com"}, + log_fn=Mock(), ) - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response( - invalid_zip - ) - ), - ) - - # Allow the invalid data through checksum verification so - # ZipFile itself gets exercised. - allow_fake_zip_checksum(monkeypatch) - - with pytest.raises( - zipfile.BadZipFile - ): - platform_ops.install_aria2c_windows() - - assert not ( - tmp_path / "aria2c.exe" - ).exists() - - def test_does_not_create_destination_before_validation( - self, - monkeypatch, - tmp_path, - ): - app_dir = tmp_path / "app-data" - - zip_data = make_aria2_zip() - - monkeypatch.setattr( - config, - "APP_DATA_DIR", - str(app_dir), - ) - - monkeypatch.setattr( - platform_ops, - "secure_urlopen", - Mock( - return_value=make_response(zip_data) - ), - ) - - bad_digest = Mock() - bad_digest.hexdigest.return_value = "0" * 64 - - monkeypatch.setattr( - platform_ops.hashlib, - "sha256", - Mock(return_value=bad_digest), - ) - - with pytest.raises(RuntimeError): - platform_ops.install_aria2c_windows() - - # ensure_dir() occurs after checksum + ZIP validation in the - # implementation shown, so a rejected download should not - # create the application directory. - assert not app_dir.exists() + ensure_dir.assert_not_called() diff --git a/src/tests/test_torrent.py b/src/tests/test_torrent.py index 48498f9..6f7dd54 100644 --- a/src/tests/test_torrent.py +++ b/src/tests/test_torrent.py @@ -1,352 +1,485 @@ import hashlib import os -from unittest.mock import Mock, patch +from unittest.mock import Mock + import pytest + from src import torrent -from pathlib import Path - -def bencode(value): - if isinstance(value, int): - return b"i" + str(value).encode() + b"e" - if isinstance(value, bytes): - return str(len(value)).encode() + b":" + value - if isinstance(value, list): - return b"l" + b"".join(bencode(v) for v in value) + b"e" - if isinstance(value, dict): - return b"d" + b"".join(bencode(k) + bencode(v) - for k, v in sorted(value.items())) + b"e" - raise TypeError(type(value)) -def test_bdecode_nested_values(): - raw = bencode({b"a": 3, b"b": [b"x", 7]}) - decoded, pos = torrent._bdecode(raw) - assert decoded == {b"a": 3, b"b": [b"x", 7]} - assert pos == len(raw) +# --------------------------------------------------------------------------- +# ensure_aria2c +# --------------------------------------------------------------------------- + +def test_ensure_aria2c_returns_existing_executable(monkeypatch): + expected = "/usr/bin/aria2c" + + monkeypatch.setattr( + torrent, + "find_aria2c", + Mock(return_value=expected), + ) + + installer = Mock() + + monkeypatch.setattr( + torrent.platform_ops, + "install_aria2c_windows", + installer, + ) + + result = torrent.ensure_aria2c() + + assert result == expected + installer.assert_not_called() -def test_torrent_version_is_sha1_of_raw_bytes(): - raw = b"torrent bytes" +def test_ensure_aria2c_returns_windows_path(monkeypatch): + expected = r"C:\aria2\aria2c.exe" + + monkeypatch.setattr( + torrent, + "find_aria2c", + Mock(return_value=expected), + ) + + assert torrent.ensure_aria2c() == expected + + +def test_ensure_aria2c_installs_when_not_found(monkeypatch): + monkeypatch.setattr( + torrent, + "find_aria2c", + Mock(return_value=None), + ) + + installer = Mock( + return_value="/fake/aria2c.exe" + ) + + monkeypatch.setattr( + torrent.platform_ops, + "install_aria2c_windows", + installer, + ) + + result = torrent.ensure_aria2c() + + assert result == "/fake/aria2c.exe" + + installer.assert_called_once_with( + APP_DATA_DIR=torrent.APP_DATA_DIR, + ALLOWED_DOWNLOAD_HOSTS=torrent.ALLOWED_DOWNLOAD_HOSTS, + log_fn=torrent.log, + ) + + +def test_ensure_aria2c_passes_custom_logger(monkeypatch): + monkeypatch.setattr( + torrent, + "find_aria2c", + Mock(return_value=None), + ) + + installer = Mock( + return_value="/fake/aria2c.exe" + ) + + monkeypatch.setattr( + torrent.platform_ops, + "install_aria2c_windows", + installer, + ) + + logger = Mock() + + torrent.ensure_aria2c(log_fn=logger) + + installer.assert_called_once_with( + APP_DATA_DIR=torrent.APP_DATA_DIR, + ALLOWED_DOWNLOAD_HOSTS=torrent.ALLOWED_DOWNLOAD_HOSTS, + log_fn=logger, + ) + + +def test_ensure_aria2c_empty_path_triggers_install(monkeypatch): + monkeypatch.setattr( + torrent, + "find_aria2c", + Mock(return_value=""), + ) + + installer = Mock( + return_value="/fake/aria2c.exe" + ) + + monkeypatch.setattr( + torrent.platform_ops, + "install_aria2c_windows", + installer, + ) + + result = torrent.ensure_aria2c() + + assert result == "/fake/aria2c.exe" + installer.assert_called_once() + + +def test_ensure_aria2c_propagates_installer_failure(monkeypatch): + monkeypatch.setattr( + torrent, + "find_aria2c", + Mock(return_value=None), + ) + + monkeypatch.setattr( + torrent.platform_ops, + "install_aria2c_windows", + Mock( + side_effect=RuntimeError( + "installation failed" + ) + ), + ) + + with pytest.raises( + RuntimeError, + match="installation failed", + ): + torrent.ensure_aria2c() + + +# --------------------------------------------------------------------------- +# torrent_version +# --------------------------------------------------------------------------- + +def test_torrent_version_returns_sha1(): + raw = b"torrent data" + assert torrent.torrent_version(raw) == hashlib.sha1(raw).hexdigest() -def test_fetch_torrent_extracts_paths_and_lengths(): - raw = bencode({ - b"info": { - b"files": [ - {b"length": 10, b"path": [b"WoW.exe"]}, - {b"length": 20, b"path": [b"Data", b"patch.MPQ"]}, - ] - } - }) - response = Mock() - response.read.return_value = raw - cm = Mock() - cm.__enter__ = Mock(return_value=response) - cm.__exit__ = Mock(return_value=False) - - with patch.object(torrent, "secure_urlopen", return_value=cm): - got_raw, files = torrent.fetch_torrent() - - assert got_raw == raw - assert files == [(["WoW.exe"], 10), (["Data", "patch.MPQ"], 20)] +def test_torrent_version_changes_when_data_changes(): + assert ( + torrent.torrent_version(b"one") + != torrent.torrent_version(b"two") + ) -def test_torrent_selection_finds_missing_and_wrong_size(tmp_path): - files = [(["a.bin"], 3), (["b.bin"], 4), (["c.bin"], 5)] - (tmp_path / "a.bin").write_bytes(b"abc") - (tmp_path / "b.bin").write_bytes(b"xx") +# --------------------------------------------------------------------------- +# torrent_selection +# --------------------------------------------------------------------------- - with patch.object(torrent, "_torrent_skip", return_value=False): - need, missing = torrent.torrent_selection(str(tmp_path), files) +def test_torrent_selection_empty_when_files_match(tmp_path): + file = tmp_path / "file.bin" + file.write_bytes(b"1234") - assert need == [2, 3] + files = [ + (["file.bin"], 4), + ] + + need, missing = torrent.torrent_selection( + str(tmp_path), + files, + ) + + assert need == [] + assert missing is False + + +def test_torrent_selection_marks_missing_file(tmp_path): + files = [ + (["missing.bin"], 10), + ] + + need, missing = torrent.torrent_selection( + str(tmp_path), + files, + ) + + assert need == [1] assert missing is True -def test_torrent_selection_removes_oversized_file(tmp_path): - p = tmp_path / "a.bin" - p.write_bytes(b"too long") - with patch.object(torrent, "_torrent_skip", return_value=False): - need, missing = torrent.torrent_selection( - str(tmp_path), [(["a.bin"], 2)] - ) +def test_torrent_selection_marks_short_file(tmp_path): + file = tmp_path / "file.bin" + file.write_bytes(b"12") + + files = [ + (["file.bin"], 10), + ] + + need, missing = torrent.torrent_selection( + str(tmp_path), + files, + ) + assert need == [1] assert missing is False - assert not p.exists() + assert file.exists() -def test_torrent_all_selection_skips_protected(tmp_path): - files = [(["a"], 1), (["b"], 1), (["c"], 1)] - with patch.object(torrent, "_torrent_skip", - side_effect=[False, True, False]): - assert torrent.torrent_all_selection(str(tmp_path), files) == [1, 3] +def test_torrent_selection_removes_oversized_file(tmp_path): + file = tmp_path / "file.bin" + file.write_bytes(b"123456") + + files = [ + (["file.bin"], 4), + ] + + need, missing = torrent.torrent_selection( + str(tmp_path), + files, + ) + + assert need == [1] + assert missing is False + assert not file.exists() -def test_torrent_tree_intact(tmp_path): - (tmp_path / "a").write_bytes(b"123") - with patch.object(torrent, "_torrent_skip", return_value=False): - assert torrent.torrent_tree_intact( - str(tmp_path), [(["a"], 3)] - ) - assert not torrent.torrent_tree_intact( - str(tmp_path), [(["a"], 4)] - ) +def test_torrent_selection_drop_mismatched_removes_short_file(tmp_path): + file = tmp_path / "file.bin" + file.write_bytes(b"12") + + files = [ + (["file.bin"], 10), + ] + + need, missing = torrent.torrent_selection( + str(tmp_path), + files, + drop_mismatched=True, + ) + + assert need == [1] + assert missing is False + assert not file.exists() -def test_shield_and_unshield_existing_file(tmp_path): - original = tmp_path / "mod.dll" - original.write_bytes(b"ours") - files = [(["mod.dll"], 4)] +# --------------------------------------------------------------------------- +# torrent_tree_intact +# --------------------------------------------------------------------------- - with patch.object(torrent, "_torrent_skip", return_value=True): - shielded = torrent.shield_protected_files( - str(tmp_path), files, False - ) +def test_torrent_tree_intact_returns_true_for_matching_files(tmp_path): + (tmp_path / "one.bin").write_bytes(b"123") + (tmp_path / "two.bin").write_bytes(b"12345") - assert not original.exists() - assert (tmp_path / "mod.dll.octobak").exists() + files = [ + (["one.bin"], 3), + (["two.bin"], 5), + ] - # Simulate aria2 creating/replacing the original path. - original.write_bytes(b"aria") - restored = torrent.unshield_protected_files(shielded) + assert torrent.torrent_tree_intact( + str(tmp_path), + files, + ) is True - assert original.read_bytes() == b"ours" - assert restored == ["mod.dll"] + +def test_torrent_tree_intact_returns_false_for_missing_file(tmp_path): + files = [ + (["missing.bin"], 5), + ] + + assert torrent.torrent_tree_intact( + str(tmp_path), + files, + ) is False + + +def test_torrent_tree_intact_returns_false_for_wrong_size(tmp_path): + (tmp_path / "file.bin").write_bytes(b"12") + + files = [ + (["file.bin"], 10), + ] + + assert torrent.torrent_tree_intact( + str(tmp_path), + files, + ) is False + + +# --------------------------------------------------------------------------- +# shield / unshield +# --------------------------------------------------------------------------- + +def test_unshield_protected_files_restores_backup(tmp_path): + original = tmp_path / "protected.dll" + backup = tmp_path / "protected.dll.octobak" + + backup.write_bytes(b"original") + + restored = torrent.unshield_protected_files([ + (str(original), str(backup)), + ]) + + assert original.read_bytes() == b"original" + assert not backup.exists() + assert restored == ["protected.dll"] def test_unshield_removes_stub_for_previously_absent_file(tmp_path): - p = tmp_path / "mod.dll" - shielded = [(str(p), None)] - p.write_bytes(b"stub") + stub = tmp_path / "protected.dll" + stub.write_bytes(b"aria partial data") - assert torrent.unshield_protected_files(shielded) == [] - assert not p.exists() + restored = torrent.unshield_protected_files([ + (str(stub), None), + ]) + + assert not stub.exists() + assert restored == [] -def test_recover_protected_files(tmp_path): - p = tmp_path / "x.dll" - bak = tmp_path / "x.dll.octobak" - bak.write_bytes(b"saved") - torrent.recover_protected_files(str(tmp_path), [(["x.dll"], 5)]) - assert p.read_bytes() == b"saved" - assert not bak.exists() +def test_recover_protected_files_restores_octobak(tmp_path): + backup = tmp_path / "file.bin.octobak" + backup.write_bytes(b"protected") + + files = [ + (["file.bin"], 9), + ] + + torrent.recover_protected_files( + str(tmp_path), + files, + ) + + assert ( + tmp_path / "file.bin" + ).read_bytes() == b"protected" + + assert not backup.exists() -@pytest.mark.parametrize( - ("line", "checking", "progress", "bps"), - [ - ("1MiB/4MiB(25%) DL:2MiB", False, .25, 2 * 1024**2), - ("0B/4MiB(0%) [Checksum:#abc 2MiB/4MiB(50%)]", - True, .50, 0), - ], -) -def test_parse_aria_progress(line, checking, progress, bps): - got = torrent.parse_aria_progress(line) - assert got["checking"] is checking - assert got["progress"] == progress - assert got["bps"] == bps +# --------------------------------------------------------------------------- +# clear_torrent_resume_state +# --------------------------------------------------------------------------- + +def test_clear_torrent_resume_state_removes_aria2_files( + tmp_path, + monkeypatch, +): + monkeypatch.setattr( + torrent, + "TORRENT_STAGING_DIR", + str(tmp_path), + ) + + aria = tmp_path / "client.aria2" + metadata = tmp_path / "client.torrent" + unrelated = tmp_path / "keep.txt" + + aria.write_bytes(b"a") + metadata.write_bytes(b"b") + unrelated.write_bytes(b"c") + + torrent.clear_torrent_resume_state() + + assert not aria.exists() + assert not metadata.exists() + assert unrelated.exists() -def test_parse_aria_progress_ignores_non_progress_line(): - assert torrent.parse_aria_progress("download started") is None +def test_clear_torrent_resume_state_handles_missing_directory( + tmp_path, + monkeypatch, +): + missing = tmp_path / "does-not-exist" + + monkeypatch.setattr( + torrent, + "TORRENT_STAGING_DIR", + str(missing), + ) + + torrent.clear_torrent_resume_state() -def test_clear_torrent_resume_state(tmp_path): - (tmp_path / "a.aria2").write_text("x") - (tmp_path / "b.torrent").write_text("x") - (tmp_path / "keep.txt").write_text("x") +# --------------------------------------------------------------------------- +# parse_aria_progress +# --------------------------------------------------------------------------- - with patch.object(torrent, "TORRENT_STAGING_DIR", str(tmp_path)): - torrent.clear_torrent_resume_state() +def test_parse_aria_progress_download(): + result = torrent.parse_aria_progress( + "1.2GiB/8.8GiB(13%) DL:5.0MiB" + ) - assert not (tmp_path / "a.aria2").exists() - assert not (tmp_path / "b.torrent").exists() - assert (tmp_path / "keep.txt").exists() + assert result["progress"] == 0.13 + assert result["bps"] == 5 * 1024 ** 2 + assert result["checking"] is False -def test_ensure_aria2c_returns_existing_binary(tmp_path): - exe = tmp_path / "aria2c.exe" - exe.write_bytes(b"x") - with patch.object(torrent, "ARIA2C_PATH", str(exe)): - assert torrent.ensure_aria2c() == str(exe) +def test_parse_aria_progress_checksum(): + result = torrent.parse_aria_progress( + "0B/1.5GiB(0%) " + "[Checksum:#f66a3e 236MiB/1.5GiB(15%)]" + ) + + assert result["progress"] == 0.15 + assert result["done"] == 236 * 1024 ** 2 + assert result["checking"] is True -def test_ensure_aria2c_rejects_bad_checksum(tmp_path): - response = Mock() - response.read.return_value = b"not the expected archive" - cm = Mock() - cm.__enter__ = Mock(return_value=response) - cm.__exit__ = Mock(return_value=False) +def test_parse_aria_progress_without_download_speed(): + result = torrent.parse_aria_progress( + "512MiB/1GiB(50%)" + ) - with patch.object(torrent, "ARIA2C_PATH", str(tmp_path / "aria2c.exe")), \ - patch.object(torrent, "secure_urlopen", return_value=cm): - with pytest.raises(RuntimeError, match="checksum mismatch"): - torrent.ensure_aria2c(log_fn=Mock()) + assert result["progress"] == 0.5 + assert result["bps"] == 0.0 - - -class TestEnsureAria2c: - def test_returns_existing_aria2c(self, monkeypatch): - """An aria2c already discoverable on PATH should be used directly.""" - expected = Path("/usr/bin/aria2c") - - find_mock = Mock(return_value=expected) - monkeypatch.setattr(torrent, "find_aria2c", find_mock) - - result = torrent.ensure_aria2c() - - assert result == expected - find_mock.assert_called_once_with() - - def test_returns_existing_windows_aria2c(self, monkeypatch): - """A Windows aria2c.exe discovered on PATH should also be returned.""" - expected = Path(r"C:\Program Files\aria2\aria2c.exe") - - find_mock = Mock(return_value=expected) - monkeypatch.setattr(torrent, "find_aria2c", find_mock) - - result = torrent.ensure_aria2c() - - assert result == expected - find_mock.assert_called_once_with() - - def test_falls_back_to_windows_installer(self, monkeypatch): - """Missing aria2c should invoke install_aria2c_windows().""" - expected = Path(r"C:\AppData\OctoUpdater\aria2c.exe") - - find_mock = Mock(return_value=None) - monkeypatch.setattr(torrent, "find_aria2c", find_mock) - - # ensure_aria2c imports install_aria2c_windows inside the function, - # so patch the source module rather than torrent. - from src import platform_ops - - installer = Mock(return_value=expected) - monkeypatch.setattr( - platform_ops, - "install_aria2c_windows", - installer, +def test_parse_aria_progress_returns_none_for_unrelated_line(): + assert ( + torrent.parse_aria_progress( + "Download complete" ) + is None + ) - result = torrent.ensure_aria2c() - assert result == expected - find_mock.assert_called_once_with() - installer.assert_called_once() +# --------------------------------------------------------------------------- +# stop_aria2c +# --------------------------------------------------------------------------- - def test_passes_custom_logger_to_installer(self, monkeypatch): - """The caller's logger should be forwarded to the installer.""" - monkeypatch.setattr( - torrent, - "find_aria2c", - Mock(return_value=None), - ) +def test_stop_aria2c_terminates_running_process(monkeypatch): + proc = Mock() + proc.poll.return_value = None - from src import platform_ops + monkeypatch.setattr( + torrent, + "_active_aria2", + proc, + ) - logger = Mock() - installer = Mock(return_value=Path("aria2c.exe")) + torrent.stop_aria2c() - monkeypatch.setattr( - platform_ops, - "install_aria2c_windows", - installer, - ) + proc.terminate.assert_called_once_with() + assert torrent._active_aria2 is None - torrent.ensure_aria2c(log_fn=logger) - installer.assert_called_once_with(log_fn=logger) +def test_stop_aria2c_does_not_terminate_finished_process(monkeypatch): + proc = Mock() + proc.poll.return_value = 0 - def test_does_not_install_when_aria2c_exists(self, monkeypatch): - """Finding aria2c should short-circuit before installation.""" - expected = Path("/usr/bin/aria2c") + monkeypatch.setattr( + torrent, + "_active_aria2", + proc, + ) - monkeypatch.setattr( - torrent, - "find_aria2c", - Mock(return_value=expected), - ) + torrent.stop_aria2c() - from src import platform_ops + proc.terminate.assert_not_called() + assert torrent._active_aria2 is None - installer = Mock() - monkeypatch.setattr( - platform_ops, - "install_aria2c_windows", - installer, - ) - result = torrent.ensure_aria2c() +def test_stop_aria2c_handles_no_process(monkeypatch): + monkeypatch.setattr( + torrent, + "_active_aria2", + None, + ) - assert result == expected - installer.assert_not_called() + torrent.stop_aria2c() - def test_propagates_find_aria2c_error(self, monkeypatch): - """Unexpected errors from executable discovery should propagate.""" - find_mock = Mock( - side_effect=OSError("PATH lookup failed") - ) - - monkeypatch.setattr( - torrent, - "find_aria2c", - find_mock, - ) - - with pytest.raises( - OSError, - match="PATH lookup failed", - ): - torrent.ensure_aria2c() - - def test_propagates_installer_error(self, monkeypatch): - """Installation failures should propagate to the caller.""" - monkeypatch.setattr( - torrent, - "find_aria2c", - Mock(return_value=None), - ) - - from src import platform_ops - - installer = Mock( - side_effect=RuntimeError("download failed") - ) - - monkeypatch.setattr( - platform_ops, - "install_aria2c_windows", - installer, - ) - - with pytest.raises( - RuntimeError, - match="download failed", - ): - torrent.ensure_aria2c() - - def test_empty_string_from_find_triggers_install(self, monkeypatch): - """Any false-y discovery result should trigger installation.""" - monkeypatch.setattr( - torrent, - "find_aria2c", - Mock(return_value=""), - ) - - from src import platform_ops - - expected = Path("aria2c.exe") - installer = Mock(return_value=expected) - - monkeypatch.setattr( - platform_ops, - "install_aria2c_windows", - installer, - ) - - result = torrent.ensure_aria2c() - - assert result == expected - installer.assert_called_once() + assert torrent._active_aria2 is None diff --git a/src/torrent.py b/src/torrent.py index 6d0474f..78e077f 100644 --- a/src/torrent.py +++ b/src/torrent.py @@ -9,10 +9,10 @@ import urllib.request from functools import cache from .app_log import log -from .config import APP_DATA_DIR, UA, DOWNLOAD_TIMEOUT, ensure_dir +from .config import APP_DATA_DIR, UA, DOWNLOAD_TIMEOUT from .mods import MODS_REGISTRY from .net import ALLOWED_DOWNLOAD_HOSTS, secure_urlopen -from .platform_ops import create_directory_link +from .platform_ops import create_directory_link, ensure_dir CLIENT_TORRENT_URL = "https://dl.octowow.st/download/client.torrent"