mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 10:31:50 +01:00
fix: add explicit workspace mode resolution for --config (#818)
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||
# 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 XDG paths module."""
|
||||
"""Unit tests for workspace/path resolution."""
|
||||
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -15,10 +17,7 @@ pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class TestGetXdgBaseDir:
|
||||
"""Tests for get_xdg_base_dir function."""
|
||||
|
||||
def test_returns_state_dir(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test resolving XDG state directory."""
|
||||
state_dir = tmp_path / "state"
|
||||
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: str(state_dir / app_name))
|
||||
|
||||
@@ -27,459 +26,383 @@ class TestGetXdgBaseDir:
|
||||
assert resolved == state_dir / "kleinanzeigen-bot"
|
||||
|
||||
def test_raises_for_unknown_category(self) -> None:
|
||||
"""Test invalid category handling."""
|
||||
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:
|
||||
"""Test runtime error when platformdirs returns None."""
|
||||
monkeypatch.setattr("platformdirs.user_state_dir", lambda _app_name, *args, **kwargs: None)
|
||||
monkeypatch.setattr("platformdirs.user_state_dir", lambda app_name, *args, **kwargs: None)
|
||||
|
||||
with pytest.raises(RuntimeError, match = "Failed to resolve XDG base directory"):
|
||||
with pytest.raises(RuntimeError, match = "Failed to resolve XDG base directory for category: state"):
|
||||
xdg_paths.get_xdg_base_dir("state")
|
||||
|
||||
|
||||
class TestDetectInstallationMode:
|
||||
"""Tests for detect_installation_mode function."""
|
||||
|
||||
def test_detects_portable_mode_when_config_exists_in_cwd(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode is detected when config.yaml exists in CWD."""
|
||||
# Setup: Create config.yaml in CWD
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "config.yaml").touch()
|
||||
|
||||
# Execute
|
||||
mode = xdg_paths.detect_installation_mode()
|
||||
|
||||
# Verify
|
||||
assert mode == "portable"
|
||||
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:
|
||||
"""Test that XDG mode is detected when config exists in XDG location."""
|
||||
# Setup: Create config in mock XDG directory
|
||||
xdg_config = tmp_path / "config" / "kleinanzeigen-bot"
|
||||
xdg_config.mkdir(parents = True)
|
||||
(xdg_config / "config.yaml").touch()
|
||||
|
||||
# Mock platformdirs to return our test directory
|
||||
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "config" / app_name))
|
||||
|
||||
# Change to a different directory (no local config)
|
||||
cwd = tmp_path / "cwd"
|
||||
cwd.mkdir()
|
||||
monkeypatch.chdir(cwd)
|
||||
|
||||
# Execute
|
||||
mode = xdg_paths.detect_installation_mode()
|
||||
|
||||
# Verify
|
||||
assert mode == "xdg"
|
||||
assert xdg_paths.detect_installation_mode() == "xdg"
|
||||
|
||||
def test_returns_none_when_no_config_found(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that None is returned when no config exists anywhere."""
|
||||
# Setup: Empty directories
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# Mock XDG to return a non-existent path
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
lambda app_name, *args, **kwargs: str(tmp_path / "nonexistent-xdg" / app_name),
|
||||
)
|
||||
monkeypatch.setattr("platformdirs.user_config_dir", lambda app_name, *args, **kwargs: str(tmp_path / "xdg" / app_name))
|
||||
|
||||
# Execute
|
||||
mode = xdg_paths.detect_installation_mode()
|
||||
|
||||
# Verify
|
||||
assert mode is None
|
||||
|
||||
|
||||
class TestGetConfigFilePath:
|
||||
"""Tests for get_config_file_path function."""
|
||||
|
||||
def test_returns_cwd_path_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode returns ./config.yaml."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
path = xdg_paths.get_config_file_path("portable")
|
||||
|
||||
assert path == tmp_path / "config.yaml"
|
||||
|
||||
def test_returns_xdg_path_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode returns XDG config path."""
|
||||
xdg_config = tmp_path / "config"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_config / app_name),
|
||||
)
|
||||
|
||||
path = xdg_paths.get_config_file_path("xdg")
|
||||
|
||||
assert "kleinanzeigen-bot" in str(path)
|
||||
assert path.name == "config.yaml"
|
||||
|
||||
|
||||
class TestGetAdFilesSearchDir:
|
||||
"""Tests for get_ad_files_search_dir function."""
|
||||
|
||||
def test_returns_cwd_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode searches in CWD."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
search_dir = xdg_paths.get_ad_files_search_dir("portable")
|
||||
|
||||
assert search_dir == tmp_path
|
||||
|
||||
def test_returns_xdg_config_dir_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode searches in XDG config directory (same as config file)."""
|
||||
xdg_config = tmp_path / "config"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_config / app_name),
|
||||
)
|
||||
|
||||
search_dir = xdg_paths.get_ad_files_search_dir("xdg")
|
||||
|
||||
assert "kleinanzeigen-bot" in str(search_dir)
|
||||
# Ad files searched in same directory as config file, not separate ads/ subdirectory
|
||||
assert search_dir.name == "kleinanzeigen-bot"
|
||||
|
||||
|
||||
class TestGetDownloadedAdsPath:
|
||||
"""Tests for get_downloaded_ads_path function."""
|
||||
|
||||
def test_returns_cwd_downloaded_ads_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode uses ./downloaded-ads/."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
ads_path = xdg_paths.get_downloaded_ads_path("portable")
|
||||
|
||||
assert ads_path == tmp_path / "downloaded-ads"
|
||||
|
||||
def test_creates_directory_if_not_exists(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that directory is created if it doesn't exist."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
ads_path = xdg_paths.get_downloaded_ads_path("portable")
|
||||
|
||||
assert ads_path.exists()
|
||||
assert ads_path.is_dir()
|
||||
|
||||
def test_returns_xdg_downloaded_ads_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode uses XDG config/downloaded-ads/."""
|
||||
xdg_config = tmp_path / "config"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_config / app_name),
|
||||
)
|
||||
|
||||
ads_path = xdg_paths.get_downloaded_ads_path("xdg")
|
||||
|
||||
assert "kleinanzeigen-bot" in str(ads_path)
|
||||
assert ads_path.name == "downloaded-ads"
|
||||
|
||||
|
||||
class TestGetBrowserProfilePath:
|
||||
"""Tests for get_browser_profile_path function."""
|
||||
|
||||
def test_returns_cwd_temp_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode uses ./.temp/browser-profile."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
profile_path = xdg_paths.get_browser_profile_path("portable")
|
||||
|
||||
assert profile_path == tmp_path / ".temp" / "browser-profile"
|
||||
|
||||
def test_returns_xdg_cache_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode uses XDG cache directory."""
|
||||
xdg_cache = tmp_path / "cache"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_cache_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_cache / app_name),
|
||||
)
|
||||
|
||||
profile_path = xdg_paths.get_browser_profile_path("xdg")
|
||||
|
||||
assert "kleinanzeigen-bot" in str(profile_path)
|
||||
assert profile_path.name == "browser-profile"
|
||||
|
||||
def test_creates_directory_if_not_exists(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that browser profile directory is created."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
profile_path = xdg_paths.get_browser_profile_path("portable")
|
||||
|
||||
assert profile_path.exists()
|
||||
assert profile_path.is_dir()
|
||||
|
||||
|
||||
class TestGetLogFilePath:
|
||||
"""Tests for get_log_file_path function."""
|
||||
|
||||
def test_returns_cwd_log_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode uses ./{basename}.log."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
log_path = xdg_paths.get_log_file_path("test", "portable")
|
||||
|
||||
assert log_path == tmp_path / "test.log"
|
||||
|
||||
def test_returns_xdg_state_log_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode uses XDG state directory."""
|
||||
xdg_state = tmp_path / "state"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_state_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_state / app_name),
|
||||
)
|
||||
|
||||
log_path = xdg_paths.get_log_file_path("test", "xdg")
|
||||
|
||||
assert "kleinanzeigen-bot" in str(log_path)
|
||||
assert log_path.name == "test.log"
|
||||
|
||||
|
||||
class TestGetUpdateCheckStatePath:
|
||||
"""Tests for get_update_check_state_path function."""
|
||||
|
||||
def test_returns_cwd_temp_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode uses ./.temp/update_check_state.json."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
state_path = xdg_paths.get_update_check_state_path("portable")
|
||||
|
||||
assert state_path == tmp_path / ".temp" / "update_check_state.json"
|
||||
|
||||
def test_returns_xdg_state_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode uses XDG state directory."""
|
||||
xdg_state = tmp_path / "state"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_state_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_state / app_name),
|
||||
)
|
||||
|
||||
state_path = xdg_paths.get_update_check_state_path("xdg")
|
||||
|
||||
assert "kleinanzeigen-bot" in str(state_path)
|
||||
assert state_path.name == "update_check_state.json"
|
||||
assert xdg_paths.detect_installation_mode() is None
|
||||
|
||||
|
||||
class TestPromptInstallationMode:
|
||||
"""Tests for prompt_installation_mode function."""
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def _force_identity_translation(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Ensure prompt strings are stable regardless of locale."""
|
||||
monkeypatch.setattr(xdg_paths, "_", lambda message: message)
|
||||
|
||||
def test_returns_portable_for_non_interactive_mode_no_stdin(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that non-interactive mode (no stdin) defaults to portable."""
|
||||
# Mock sys.stdin to be None (simulates non-interactive environment)
|
||||
def test_returns_portable_for_non_interactive_mode(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("sys.stdin", None)
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
|
||||
assert mode == "portable"
|
||||
assert xdg_paths.prompt_installation_mode() == "portable"
|
||||
|
||||
def test_returns_portable_for_non_interactive_mode_not_tty(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that non-interactive mode (not a TTY) defaults to portable."""
|
||||
# Mock sys.stdin.isatty() to return False (simulates piped input or file redirect)
|
||||
mock_stdin = io.StringIO()
|
||||
mock_stdin.isatty = lambda: False # type: ignore[method-assign]
|
||||
monkeypatch.setattr("sys.stdin", mock_stdin)
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
assert xdg_paths.prompt_installation_mode() == "portable"
|
||||
|
||||
assert mode == "portable"
|
||||
|
||||
def test_returns_portable_when_user_enters_1(self, monkeypatch:pytest.MonkeyPatch, capsys:pytest.CaptureFixture[str]) -> None:
|
||||
"""Test that user entering '1' selects portable mode."""
|
||||
# Mock sys.stdin to simulate interactive terminal
|
||||
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)
|
||||
|
||||
# Mock interactive input
|
||||
monkeypatch.setattr("builtins.input", lambda _: "1")
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
|
||||
assert mode == "portable"
|
||||
# Verify prompt was shown
|
||||
captured = capsys.readouterr()
|
||||
assert "Choose installation type:" in captured.out
|
||||
assert "[1] Portable" in captured.out
|
||||
assert xdg_paths.prompt_installation_mode() == "portable"
|
||||
|
||||
def test_returns_xdg_when_user_enters_2(self, monkeypatch:pytest.MonkeyPatch, capsys:pytest.CaptureFixture[str]) -> None:
|
||||
"""Test that user entering '2' selects XDG mode."""
|
||||
# Mock sys.stdin to simulate interactive terminal
|
||||
mock_stdin = io.StringIO()
|
||||
mock_stdin.isatty = lambda: True # type: ignore[method-assign]
|
||||
monkeypatch.setattr("sys.stdin", mock_stdin)
|
||||
|
||||
# Mock interactive input
|
||||
monkeypatch.setattr("builtins.input", lambda _: "2")
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
|
||||
assert mode == "xdg"
|
||||
# Verify prompt was shown
|
||||
captured = capsys.readouterr()
|
||||
assert "Choose installation type:" in captured.out
|
||||
assert "[2] System-wide" 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:
|
||||
"""Test that invalid input causes re-prompt, then valid input is accepted."""
|
||||
# Mock sys.stdin to simulate interactive terminal
|
||||
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)
|
||||
|
||||
# Mock sequence of inputs: invalid, then valid
|
||||
inputs = iter(["3", "invalid", "1"])
|
||||
inputs = iter(["invalid", "2"])
|
||||
monkeypatch.setattr("builtins.input", lambda _: next(inputs))
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
|
||||
assert mode == "portable"
|
||||
# Verify error message was shown
|
||||
assert mode == "xdg"
|
||||
captured = capsys.readouterr()
|
||||
assert "Invalid choice" in captured.out
|
||||
assert "Invalid choice. Please enter 1 or 2." in captured.out
|
||||
|
||||
def test_returns_portable_on_eof_error(self, monkeypatch:pytest.MonkeyPatch, capsys:pytest.CaptureFixture[str]) -> None:
|
||||
"""Test that EOFError (Ctrl+D) defaults to portable mode."""
|
||||
# Mock sys.stdin to simulate interactive terminal
|
||||
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)
|
||||
|
||||
# Mock input raising EOFError
|
||||
def mock_input(_:str) -> str:
|
||||
def raise_eof(_prompt:str) -> str:
|
||||
raise EOFError
|
||||
|
||||
monkeypatch.setattr("builtins.input", mock_input)
|
||||
monkeypatch.setattr("builtins.input", raise_eof)
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
assert xdg_paths.prompt_installation_mode() == "portable"
|
||||
|
||||
assert mode == "portable"
|
||||
# Verify newline was printed after EOF
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out.endswith("\n")
|
||||
|
||||
def test_returns_portable_on_keyboard_interrupt(self, monkeypatch:pytest.MonkeyPatch, capsys:pytest.CaptureFixture[str]) -> None:
|
||||
"""Test that KeyboardInterrupt (Ctrl+C) defaults to portable mode."""
|
||||
# Mock sys.stdin to simulate interactive terminal
|
||||
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)
|
||||
|
||||
# Mock input raising KeyboardInterrupt
|
||||
def mock_input(_:str) -> str:
|
||||
def raise_keyboard_interrupt(_prompt:str) -> str:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr("builtins.input", mock_input)
|
||||
monkeypatch.setattr("builtins.input", raise_keyboard_interrupt)
|
||||
|
||||
mode = xdg_paths.prompt_installation_mode()
|
||||
|
||||
assert mode == "portable"
|
||||
# Verify newline was printed after interrupt
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out.endswith("\n")
|
||||
assert xdg_paths.prompt_installation_mode() == "portable"
|
||||
|
||||
|
||||
class TestGetBrowserProfilePathWithOverride:
|
||||
"""Tests for get_browser_profile_path config_override parameter."""
|
||||
class TestWorkspace:
|
||||
def test_ensure_directory_raises_when_target_is_not_directory(self, tmp_path:Path) -> None:
|
||||
target = tmp_path / "created"
|
||||
|
||||
def test_respects_config_override_in_portable_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that config_override takes precedence in portable mode."""
|
||||
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)
|
||||
|
||||
custom_path = str(tmp_path / "custom" / "browser")
|
||||
profile_path = xdg_paths.get_browser_profile_path("portable", config_override = custom_path)
|
||||
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()
|
||||
|
||||
assert profile_path == Path(custom_path)
|
||||
assert profile_path.exists() # Verify directory was created
|
||||
assert profile_path.is_dir()
|
||||
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))
|
||||
|
||||
def test_respects_config_override_in_xdg_mode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that config_override takes precedence in XDG mode."""
|
||||
xdg_cache = tmp_path / "cache"
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_cache_dir",
|
||||
lambda app_name, *args, **kwargs: str(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",
|
||||
)
|
||||
|
||||
custom_path = str(tmp_path / "custom" / "browser")
|
||||
profile_path = xdg_paths.get_browser_profile_path("xdg", config_override = custom_path)
|
||||
assert ws.mode == "xdg"
|
||||
|
||||
assert profile_path == Path(custom_path)
|
||||
# Verify it didn't use XDG cache directory
|
||||
assert str(profile_path) != str(xdg_cache / "kleinanzeigen-bot" / "browser-profile")
|
||||
assert profile_path.exists()
|
||||
assert profile_path.is_dir()
|
||||
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))
|
||||
|
||||
class TestUnicodeHandling:
|
||||
"""Tests for Unicode path handling (NFD vs NFC normalization)."""
|
||||
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_portable_mode_handles_unicode_in_cwd(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that portable mode works with Unicode characters in CWD path.
|
||||
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
|
||||
|
||||
This tests the edge case where the current directory contains Unicode
|
||||
characters (e.g., user names with umlauts), which may be stored in
|
||||
different normalization forms (NFD on macOS, NFC on Linux/Windows).
|
||||
"""
|
||||
# Create directory with German umlaut in composed (NFC) form
|
||||
# ä = U+00E4 (NFC) vs a + ̈ = U+0061 + U+0308 (NFD)
|
||||
unicode_dir = tmp_path / "Müller_config"
|
||||
unicode_dir.mkdir()
|
||||
monkeypatch.chdir(unicode_dir)
|
||||
def patched_resolve(self:Path, strict:bool = False) -> object:
|
||||
if self == config_path:
|
||||
return None
|
||||
return original_resolve(self, strict)
|
||||
|
||||
# Get paths - should work regardless of normalization
|
||||
config_path = xdg_paths.get_config_file_path("portable")
|
||||
log_path = xdg_paths.get_log_file_path("test", "portable")
|
||||
|
||||
# Verify paths are within the Unicode directory
|
||||
assert config_path.parent == unicode_dir
|
||||
assert log_path.parent == unicode_dir
|
||||
assert config_path.name == "config.yaml"
|
||||
assert log_path.name == "test.log"
|
||||
|
||||
def test_xdg_mode_handles_unicode_in_paths(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that XDG mode handles Unicode in XDG directory paths.
|
||||
|
||||
This tests the edge case where XDG directories contain Unicode
|
||||
characters (e.g., /Users/Müller/.config/), which may be in NFD
|
||||
form on macOS filesystems.
|
||||
"""
|
||||
# Create XDG directory with umlaut
|
||||
xdg_base = tmp_path / "Users" / "Müller" / ".config"
|
||||
xdg_base.mkdir(parents = True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_base / app_name),
|
||||
)
|
||||
|
||||
# Get config path
|
||||
config_path = xdg_paths.get_config_file_path("xdg")
|
||||
|
||||
# Verify path contains the Unicode directory
|
||||
assert "Müller" in str(config_path) or "Mu\u0308ller" in str(config_path)
|
||||
assert config_path.name == "config.yaml"
|
||||
|
||||
def test_downloaded_ads_path_handles_unicode(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test that downloaded ads directory creation works with Unicode paths."""
|
||||
# Create XDG config directory with umlaut
|
||||
xdg_config = tmp_path / "config" / "Müller"
|
||||
xdg_config.mkdir(parents = True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
lambda app_name, *args, **kwargs: str(xdg_config / app_name),
|
||||
)
|
||||
|
||||
# Get downloaded ads path - this will create the directory
|
||||
ads_path = xdg_paths.get_downloaded_ads_path("xdg")
|
||||
|
||||
# Verify directory was created successfully
|
||||
assert ads_path.exists()
|
||||
assert ads_path.is_dir()
|
||||
assert ads_path.name == "downloaded-ads"
|
||||
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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user