Files
kleinanzeigen-bot/tests/unit/test_xdg_paths.py

409 lines
20 KiB
Python

# SPDX-FileCopyrightText: © Jens Bergmann and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
"""Unit tests for workspace/path resolution."""
import io
import re
from pathlib import Path
from unittest.mock import patch
import pytest
from kleinanzeigen_bot.utils import xdg_paths
pytestmark = pytest.mark.unit
class TestGetXdgBaseDir:
def test_returns_state_dir(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
state_dir = tmp_path / "state"
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(state_dir / app_name))
resolved = xdg_paths.get_xdg_base_dir("state")
assert resolved == state_dir / "kleinanzeigen-bot"
def test_raises_for_unknown_category(self) -> None:
with pytest.raises(ValueError, match = "Unsupported XDG category"):
xdg_paths.get_xdg_base_dir("invalid") # type: ignore[arg-type]
def test_raises_when_base_dir_is_none(self, monkeypatch:pytest.MonkeyPatch) -> None:
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: None)
with pytest.raises(RuntimeError, match = "Failed to resolve XDG base directory for category: state"):
xdg_paths.get_xdg_base_dir("state")
class TestDetectInstallationMode:
def test_detects_portable_mode_when_config_exists_in_cwd(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "config.yaml").touch()
assert xdg_paths.detect_installation_mode() == "portable"
def test_detects_xdg_mode_when_config_exists_in_xdg_location(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
xdg_config = tmp_path / "config" / "kleinanzeigen-bot"
xdg_config.mkdir(parents = True)
(xdg_config / "config.yaml").touch()
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "config" / app_name))
cwd = tmp_path / "cwd"
cwd.mkdir()
monkeypatch.chdir(cwd)
assert xdg_paths.detect_installation_mode() == "xdg"
def test_returns_none_when_no_config_found(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg" / app_name))
assert xdg_paths.detect_installation_mode() is None
class TestPromptInstallationMode:
@pytest.fixture(autouse = True)
def _force_identity_translation(self, monkeypatch:pytest.MonkeyPatch) -> None:
monkeypatch.setattr(xdg_paths, "_", lambda message: message)
def test_returns_portable_for_non_interactive_mode(self, monkeypatch:pytest.MonkeyPatch) -> None:
monkeypatch.setattr("sys.stdin", None)
assert xdg_paths.prompt_installation_mode() == "portable"
def test_returns_portable_for_non_interactive_mode_not_tty(self, monkeypatch:pytest.MonkeyPatch) -> None:
mock_stdin = io.StringIO()
mock_stdin.isatty = lambda: False # type: ignore[method-assign]
monkeypatch.setattr("sys.stdin", mock_stdin)
assert xdg_paths.prompt_installation_mode() == "portable"
def test_returns_portable_when_user_enters_1(self, monkeypatch:pytest.MonkeyPatch) -> None:
mock_stdin = io.StringIO()
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
monkeypatch.setattr("sys.stdin", mock_stdin)
monkeypatch.setattr("builtins.input", lambda _: "1")
assert xdg_paths.prompt_installation_mode() == "portable"
def test_returns_xdg_when_user_enters_2(self, monkeypatch:pytest.MonkeyPatch, capsys:pytest.CaptureFixture[str]) -> None:
mock_stdin = io.StringIO()
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
monkeypatch.setattr("sys.stdin", mock_stdin)
monkeypatch.setattr("builtins.input", lambda _: "2")
mode = xdg_paths.prompt_installation_mode()
assert mode == "xdg"
captured = capsys.readouterr()
assert "Choose installation type:" in captured.out
assert "[2] User directories" in captured.out
def test_reprompts_on_invalid_input_then_accepts_valid(
self,
monkeypatch:pytest.MonkeyPatch,
capsys:pytest.CaptureFixture[str],
) -> None:
mock_stdin = io.StringIO()
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
monkeypatch.setattr("sys.stdin", mock_stdin)
inputs = iter(["invalid", "2"])
monkeypatch.setattr("builtins.input", lambda _: next(inputs))
mode = xdg_paths.prompt_installation_mode()
assert mode == "xdg"
captured = capsys.readouterr()
assert "Invalid choice. Please enter 1 or 2." in captured.out
def test_returns_portable_on_eof_error(self, monkeypatch:pytest.MonkeyPatch) -> None:
mock_stdin = io.StringIO()
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
monkeypatch.setattr("sys.stdin", mock_stdin)
def raise_eof(_prompt:str) -> str:
raise EOFError
monkeypatch.setattr("builtins.input", raise_eof)
assert xdg_paths.prompt_installation_mode() == "portable"
def test_returns_portable_on_keyboard_interrupt(self, monkeypatch:pytest.MonkeyPatch) -> None:
mock_stdin = io.StringIO()
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
monkeypatch.setattr("sys.stdin", mock_stdin)
def raise_keyboard_interrupt(_prompt:str) -> str:
raise KeyboardInterrupt
monkeypatch.setattr("builtins.input", raise_keyboard_interrupt)
assert xdg_paths.prompt_installation_mode() == "portable"
class TestWorkspace:
def test_ensure_directory_raises_when_target_is_not_directory(self, tmp_path:Path) -> None:
target = tmp_path / "created"
with patch.object(Path, "is_dir", return_value = False), pytest.raises(NotADirectoryError, match = re.escape(str(target))):
xdg_paths.ensure_directory(target, "test directory")
def test_for_config_derives_portable_layout(self, tmp_path:Path) -> None:
config_file = tmp_path / "custom" / "config.yaml"
ws = xdg_paths.Workspace.for_config(config_file, "mybot")
assert ws.config_file == config_file.resolve()
assert ws.config_dir == config_file.parent.resolve()
assert ws.log_file == config_file.parent.resolve() / "mybot.log"
assert ws.state_dir == config_file.parent.resolve() / ".temp"
assert ws.download_dir == config_file.parent.resolve() / "downloaded-ads"
assert ws.browser_profile_dir == config_file.parent.resolve() / ".temp" / "browser-profile"
assert ws.diagnostics_dir == config_file.parent.resolve() / ".temp" / "diagnostics"
def test_resolve_workspace_uses_config_arg(self, tmp_path:Path) -> None:
config_path = tmp_path / "cfg" / "config.yaml"
ws = xdg_paths.resolve_workspace(
config_arg = str(config_path),
logfile_arg = None,
workspace_mode = "portable",
logfile_explicitly_provided = False,
log_basename = "kleinanzeigen-bot",
)
assert ws.config_file == config_path.resolve()
assert ws.log_file == config_path.parent.resolve() / "kleinanzeigen-bot.log"
def test_resolve_workspace_uses_detected_xdg_layout(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
monkeypatch.setattr(xdg_paths, "detect_installation_mode", lambda: "xdg")
monkeypatch.setattr(
xdg_paths,
"get_xdg_base_dir",
lambda category: {
"config": tmp_path / "xdg-config" / xdg_paths.APP_NAME,
"state": tmp_path / "xdg-state" / xdg_paths.APP_NAME,
"cache": tmp_path / "xdg-cache" / xdg_paths.APP_NAME,
}[category],
)
ws = xdg_paths.resolve_workspace(None, None, workspace_mode = None, logfile_explicitly_provided = False, log_basename = "kleinanzeigen-bot")
assert ws.config_file == (tmp_path / "xdg-config" / xdg_paths.APP_NAME / "config.yaml").resolve()
assert ws.log_file == (tmp_path / "xdg-state" / xdg_paths.APP_NAME / "kleinanzeigen-bot.log").resolve()
assert ws.state_dir == (tmp_path / "xdg-state" / xdg_paths.APP_NAME).resolve()
assert ws.browser_profile_dir == (tmp_path / "xdg-cache" / xdg_paths.APP_NAME / "browser-profile").resolve()
assert ws.diagnostics_dir == (tmp_path / "xdg-cache" / xdg_paths.APP_NAME / "diagnostics").resolve()
def test_resolve_workspace_first_run_uses_prompt_choice(self, monkeypatch:pytest.MonkeyPatch) -> None:
monkeypatch.setattr(xdg_paths, "detect_installation_mode", lambda: None)
monkeypatch.setattr(xdg_paths, "prompt_installation_mode", lambda: "portable")
ws = xdg_paths.resolve_workspace(None, None, workspace_mode = None, logfile_explicitly_provided = False, log_basename = "kleinanzeigen-bot")
assert ws.config_file == (Path.cwd() / "config.yaml").resolve()
def test_resolve_workspace_honors_logfile_override(self, tmp_path:Path) -> None:
config_path = tmp_path / "cfg" / "config.yaml"
explicit_log = tmp_path / "logs" / "my.log"
ws = xdg_paths.resolve_workspace(
config_arg = str(config_path),
logfile_arg = str(explicit_log),
workspace_mode = "portable",
logfile_explicitly_provided = True,
log_basename = "kleinanzeigen-bot",
)
assert ws.log_file == explicit_log.resolve()
def test_resolve_workspace_disables_logfile_when_empty_flag(self, tmp_path:Path) -> None:
ws = xdg_paths.resolve_workspace(
config_arg = str(tmp_path / "config.yaml"),
logfile_arg = "",
workspace_mode = "portable",
logfile_explicitly_provided = True,
log_basename = "kleinanzeigen-bot",
)
assert ws.log_file is None
def test_resolve_workspace_fails_when_config_mode_is_ambiguous(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
config_path = tmp_path / "cfg" / "config.yaml"
config_path.parent.mkdir(parents = True, exist_ok = True)
config_path.touch()
(config_path.parent / ".temp").mkdir(parents = True, exist_ok = True)
cwd_config = tmp_path / "cwd" / "config.yaml"
cwd_config.parent.mkdir(parents = True, exist_ok = True)
cwd_config.touch()
monkeypatch.chdir(cwd_config.parent)
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name))
(tmp_path / "xdg-config" / xdg_paths.APP_NAME / "config.yaml").parent.mkdir(parents = True, exist_ok = True)
(tmp_path / "xdg-config" / xdg_paths.APP_NAME / "config.yaml").touch()
with pytest.raises(ValueError, match = "Detected both portable and XDG footprints") as exc_info:
xdg_paths.resolve_workspace(
config_arg = str(config_path),
logfile_arg = None,
workspace_mode = None,
logfile_explicitly_provided = False,
log_basename = "kleinanzeigen-bot",
)
assert str((config_path.parent / ".temp").resolve()) in str(exc_info.value)
assert str((tmp_path / "xdg-config" / xdg_paths.APP_NAME / "config.yaml").resolve()) in str(exc_info.value)
def test_resolve_workspace_detects_portable_mode_from_custom_config_footprint(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
config_path = tmp_path / "cfg" / "config.yaml"
config_path.parent.mkdir(parents = True, exist_ok = True)
config_path.touch()
(config_path.parent / ".temp").mkdir(parents = True, exist_ok = True)
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name))
ws = xdg_paths.resolve_workspace(
config_arg = str(config_path),
logfile_arg = None,
workspace_mode = None,
logfile_explicitly_provided = False,
log_basename = "kleinanzeigen-bot",
)
assert ws.mode == "portable"
assert ws.config_file == config_path.resolve()
assert ws.state_dir == (config_path.parent / ".temp").resolve()
def test_resolve_workspace_detects_xdg_mode_from_xdg_footprint(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
xdg_config_dir = tmp_path / "xdg-config" / xdg_paths.APP_NAME
xdg_cache_dir = tmp_path / "xdg-cache" / xdg_paths.APP_NAME
xdg_state_dir = tmp_path / "xdg-state" / xdg_paths.APP_NAME
xdg_config_dir.mkdir(parents = True, exist_ok = True)
xdg_cache_dir.mkdir(parents = True, exist_ok = True)
xdg_state_dir.mkdir(parents = True, exist_ok = True)
(xdg_cache_dir / "browser-profile").mkdir(parents = True, exist_ok = True)
(xdg_config_dir / "downloaded-ads").mkdir(parents = True, exist_ok = True)
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name))
config_path = xdg_config_dir / "config-alt.yaml"
config_path.touch()
ws = xdg_paths.resolve_workspace(
config_arg = str(config_path),
logfile_arg = None,
workspace_mode = None,
logfile_explicitly_provided = False,
log_basename = "kleinanzeigen-bot",
)
assert ws.mode == "xdg"
assert ws.config_file == config_path.resolve()
assert ws.browser_profile_dir == (xdg_cache_dir / "browser-profile").resolve()
def test_detect_mode_from_footprints_collects_portable_and_xdg_hit_paths(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
config_path = tmp_path / "config.yaml"
config_path.touch()
(tmp_path / "downloaded-ads").mkdir(parents = True, exist_ok = True)
xdg_config_dir = tmp_path / "xdg-config" / xdg_paths.APP_NAME
xdg_cache_dir = tmp_path / "xdg-cache" / xdg_paths.APP_NAME
xdg_state_dir = tmp_path / "xdg-state" / xdg_paths.APP_NAME
xdg_config_dir.mkdir(parents = True, exist_ok = True)
xdg_cache_dir.mkdir(parents = True, exist_ok = True)
xdg_state_dir.mkdir(parents = True, exist_ok = True)
(xdg_cache_dir / "diagnostics").mkdir(parents = True, exist_ok = True)
(xdg_state_dir / "update_check_state.json").touch()
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name))
detected_mode, portable_hits, xdg_hits = xdg_paths._detect_mode_from_footprints_with_hits(config_path) # noqa: SLF001
assert detected_mode == "ambiguous"
assert config_path.resolve() in portable_hits
assert (tmp_path / "downloaded-ads").resolve() in portable_hits
assert (xdg_cache_dir / "diagnostics").resolve() in xdg_hits
assert (xdg_state_dir / "update_check_state.json").resolve() in xdg_hits
def test_resolve_workspace_ignores_unrelated_cwd_config_when_config_is_elsewhere(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
cwd = tmp_path / "cwd"
cwd.mkdir(parents = True, exist_ok = True)
(cwd / "config.yaml").touch()
monkeypatch.chdir(cwd)
xdg_config_dir = tmp_path / "xdg-config" / xdg_paths.APP_NAME
xdg_cache_dir = tmp_path / "xdg-cache" / xdg_paths.APP_NAME
xdg_state_dir = tmp_path / "xdg-state" / xdg_paths.APP_NAME
xdg_config_dir.mkdir(parents = True, exist_ok = True)
xdg_cache_dir.mkdir(parents = True, exist_ok = True)
xdg_state_dir.mkdir(parents = True, exist_ok = True)
(xdg_config_dir / "config.yaml").touch()
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name))
custom_config = tmp_path / "external" / "config.yaml"
custom_config.parent.mkdir(parents = True, exist_ok = True)
custom_config.touch()
ws = xdg_paths.resolve_workspace(
config_arg = str(custom_config),
logfile_arg = None,
workspace_mode = None,
logfile_explicitly_provided = False,
log_basename = "kleinanzeigen-bot",
)
assert ws.mode == "xdg"
def test_resolve_workspace_fails_when_config_mode_is_unknown(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
config_path = tmp_path / "cfg" / "config.yaml"
config_path.parent.mkdir(parents = True, exist_ok = True)
config_path.touch()
(tmp_path / "cwd").mkdir(parents = True, exist_ok = True)
monkeypatch.chdir(tmp_path / "cwd")
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-config" / app_name))
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-state" / app_name))
monkeypatch.setattr("platformdirs.user_cache_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg-cache" / app_name))
with pytest.raises(ValueError, match = "Detected neither portable nor XDG footprints") as exc_info:
xdg_paths.resolve_workspace(
config_arg = str(config_path),
logfile_arg = None,
workspace_mode = None,
logfile_explicitly_provided = False,
log_basename = "kleinanzeigen-bot",
)
assert "Portable footprint hits: none" in str(exc_info.value)
assert "XDG footprint hits: none" in str(exc_info.value)
def test_resolve_workspace_raises_when_config_path_is_unresolved(self, tmp_path:Path) -> None:
config_path = (tmp_path / "config.yaml").resolve()
original_resolve = Path.resolve
def patched_resolve(self:Path, strict:bool = False) -> object:
if self == config_path:
return None
return original_resolve(self, strict)
with patch.object(Path, "resolve", patched_resolve), pytest.raises(
RuntimeError, match = "Workspace mode and config path must be resolved"
):
xdg_paths.resolve_workspace(
config_arg = str(config_path),
logfile_arg = None,
workspace_mode = "portable",
logfile_explicitly_provided = False,
log_basename = "kleinanzeigen-bot",
)