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:
@@ -46,7 +46,7 @@ def test_extractor(browser_mock:MagicMock, test_bot_config:Config) -> extract_mo
|
||||
- browser_mock: Used to mock browser interactions
|
||||
- test_bot_config: Used to initialize the extractor with a valid configuration
|
||||
"""
|
||||
return extract_module.AdExtractor(browser_mock, test_bot_config)
|
||||
return extract_module.AdExtractor(browser_mock, test_bot_config, Path("downloaded-ads"))
|
||||
|
||||
|
||||
class TestAdExtractorBasics:
|
||||
@@ -54,9 +54,10 @@ class TestAdExtractorBasics:
|
||||
|
||||
def test_constructor(self, browser_mock:MagicMock, test_bot_config:Config) -> None:
|
||||
"""Test the constructor of extract_module.AdExtractor"""
|
||||
extractor = extract_module.AdExtractor(browser_mock, test_bot_config)
|
||||
extractor = extract_module.AdExtractor(browser_mock, test_bot_config, Path("downloaded-ads"))
|
||||
assert extractor.browser == browser_mock
|
||||
assert extractor.config == test_bot_config
|
||||
assert extractor.download_dir == Path("downloaded-ads")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected_id"),
|
||||
@@ -950,7 +951,7 @@ class TestAdExtractorCategory:
|
||||
def extractor(self, test_bot_config:Config) -> extract_module.AdExtractor:
|
||||
browser_mock = MagicMock(spec = Browser)
|
||||
config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}})
|
||||
return extract_module.AdExtractor(browser_mock, config)
|
||||
return extract_module.AdExtractor(browser_mock, config, Path("downloaded-ads"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
@@ -1092,7 +1093,7 @@ class TestAdExtractorContact:
|
||||
def extractor(self, test_bot_config:Config) -> extract_module.AdExtractor:
|
||||
browser_mock = MagicMock(spec = Browser)
|
||||
config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}})
|
||||
return extract_module.AdExtractor(browser_mock, config)
|
||||
return extract_module.AdExtractor(browser_mock, config, Path("downloaded-ads"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
@@ -1163,7 +1164,7 @@ class TestAdExtractorDownload:
|
||||
def extractor(self, test_bot_config:Config) -> extract_module.AdExtractor:
|
||||
browser_mock = MagicMock(spec = Browser)
|
||||
config = test_bot_config.with_values({"ad_defaults": {"description": {"prefix": "Test Prefix", "suffix": "Test Suffix"}}})
|
||||
return extract_module.AdExtractor(browser_mock, config)
|
||||
return extract_module.AdExtractor(browser_mock, config, Path("downloaded-ads"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_ad(self, extractor:extract_module.AdExtractor, tmp_path:Path) -> None:
|
||||
@@ -1172,9 +1173,9 @@ class TestAdExtractorDownload:
|
||||
download_base = tmp_path / "downloaded-ads"
|
||||
final_dir = download_base / "ad_12345_Test Advertisement Title"
|
||||
yaml_path = final_dir / "ad_12345.yaml"
|
||||
extractor.download_dir = download_base
|
||||
|
||||
with (
|
||||
patch("kleinanzeigen_bot.extract.xdg_paths.get_downloaded_ads_path", return_value = download_base),
|
||||
patch("kleinanzeigen_bot.extract.dicts.save_dict", autospec = True) as mock_save_dict,
|
||||
patch.object(extractor, "_extract_ad_page_info_with_directory_handling", new_callable = AsyncMock) as mock_extract_with_dir,
|
||||
):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
import copy, fnmatch, io, json, logging, os, tempfile # isort: skip
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import redirect_stdout
|
||||
from datetime import timedelta
|
||||
from pathlib import Path, PureWindowsPath
|
||||
@@ -16,7 +16,7 @@ from kleinanzeigen_bot import LOG, PUBLISH_MAX_RETRIES, AdUpdateStrategy, Kleina
|
||||
from kleinanzeigen_bot._version import __version__
|
||||
from kleinanzeigen_bot.model.ad_model import Ad
|
||||
from kleinanzeigen_bot.model.config_model import AdDefaults, Config, DiagnosticsConfig, PublishingConfig
|
||||
from kleinanzeigen_bot.utils import dicts, loggers
|
||||
from kleinanzeigen_bot.utils import dicts, loggers, xdg_paths
|
||||
from kleinanzeigen_bot.utils.web_scraping_mixin import By, Element
|
||||
|
||||
|
||||
@@ -108,6 +108,26 @@ def mock_config_setup(test_bot:KleinanzeigenBot) -> Generator[None]:
|
||||
yield
|
||||
|
||||
|
||||
def _make_fake_resolve_workspace(
|
||||
captured_mode:dict[str, xdg_paths.InstallationMode | None],
|
||||
workspace:xdg_paths.Workspace,
|
||||
) -> Callable[..., xdg_paths.Workspace]:
|
||||
"""Create a fake resolve_workspace that captures the workspace_mode argument."""
|
||||
|
||||
def fake_resolve_workspace(
|
||||
config_arg:str | None,
|
||||
logfile_arg:str | None,
|
||||
*,
|
||||
workspace_mode:xdg_paths.InstallationMode | None,
|
||||
logfile_explicitly_provided:bool,
|
||||
log_basename:str,
|
||||
) -> xdg_paths.Workspace:
|
||||
captured_mode["value"] = workspace_mode
|
||||
return workspace
|
||||
|
||||
return fake_resolve_workspace
|
||||
|
||||
|
||||
class TestKleinanzeigenBotInitialization:
|
||||
"""Tests for KleinanzeigenBot initialization and basic functionality."""
|
||||
|
||||
@@ -126,28 +146,124 @@ class TestKleinanzeigenBotInitialization:
|
||||
with patch("kleinanzeigen_bot.__version__", "1.2.3"):
|
||||
assert test_bot.get_version() == "1.2.3"
|
||||
|
||||
def test_finalize_installation_mode_skips_help(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Ensure finalize_installation_mode returns early for help."""
|
||||
def test_resolve_workspace_skips_help(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Ensure workspace resolution returns early for help."""
|
||||
test_bot.command = "help"
|
||||
test_bot.installation_mode = None
|
||||
test_bot.finalize_installation_mode()
|
||||
assert test_bot.installation_mode is None
|
||||
test_bot.workspace = None
|
||||
test_bot._resolve_workspace()
|
||||
assert test_bot.workspace is None
|
||||
|
||||
def test_resolve_workspace_skips_create_config(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Ensure workspace resolution returns early for create-config."""
|
||||
test_bot.command = "create-config"
|
||||
test_bot.workspace = None
|
||||
test_bot._resolve_workspace()
|
||||
assert test_bot.workspace is None
|
||||
|
||||
def test_resolve_workspace_exits_on_workspace_resolution_error(self, test_bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None:
|
||||
"""Workspace resolution errors should terminate with code 2."""
|
||||
caplog.set_level(logging.ERROR)
|
||||
test_bot.command = "verify"
|
||||
|
||||
with (
|
||||
patch("kleinanzeigen_bot.xdg_paths.resolve_workspace", side_effect = ValueError("workspace error")),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
test_bot._resolve_workspace()
|
||||
|
||||
assert exc_info.value.code == 2
|
||||
assert "workspace error" in caplog.text
|
||||
|
||||
def test_resolve_workspace_fails_fast_when_config_parent_cannot_be_created(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
||||
"""Workspace resolution should fail immediately when config directory creation fails."""
|
||||
test_bot.command = "verify"
|
||||
workspace = xdg_paths.Workspace.for_config(tmp_path / "blocked" / "config.yaml", "kleinanzeigen-bot")
|
||||
|
||||
with (
|
||||
patch("kleinanzeigen_bot.xdg_paths.resolve_workspace", return_value = workspace),
|
||||
patch("kleinanzeigen_bot.xdg_paths.ensure_directory", side_effect = OSError("mkdir denied")),
|
||||
pytest.raises(OSError, match = "mkdir denied"),
|
||||
):
|
||||
test_bot._resolve_workspace()
|
||||
|
||||
def test_resolve_workspace_programmatic_config_in_xdg_defaults_to_xdg(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
||||
"""Programmatic config_file_path in XDG config tree should default workspace mode to xdg."""
|
||||
test_bot.command = "verify"
|
||||
xdg_dirs = {
|
||||
"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,
|
||||
}
|
||||
for path in xdg_dirs.values():
|
||||
path.mkdir(parents = True, exist_ok = True)
|
||||
config_path = xdg_dirs["config"] / "config.yaml"
|
||||
config_path.touch()
|
||||
test_bot.config_file_path = str(config_path)
|
||||
|
||||
workspace = xdg_paths.Workspace.for_config(tmp_path / "resolved" / "config.yaml", "kleinanzeigen-bot")
|
||||
captured_mode:dict[str, xdg_paths.InstallationMode | None] = {"value": None}
|
||||
|
||||
with (
|
||||
patch("kleinanzeigen_bot.xdg_paths.get_xdg_base_dir", side_effect = lambda category: xdg_dirs[category]),
|
||||
patch("kleinanzeigen_bot.xdg_paths.resolve_workspace", side_effect = _make_fake_resolve_workspace(captured_mode, workspace)),
|
||||
patch("kleinanzeigen_bot.xdg_paths.ensure_directory"),
|
||||
):
|
||||
test_bot._resolve_workspace()
|
||||
|
||||
assert captured_mode["value"] == "xdg"
|
||||
|
||||
def test_resolve_workspace_programmatic_config_outside_xdg_defaults_to_portable(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
||||
"""Programmatic config_file_path outside XDG config tree should default workspace mode to portable."""
|
||||
test_bot.command = "verify"
|
||||
xdg_dirs = {
|
||||
"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,
|
||||
}
|
||||
for path in xdg_dirs.values():
|
||||
path.mkdir(parents = True, exist_ok = True)
|
||||
config_path = tmp_path / "external" / "config.yaml"
|
||||
config_path.parent.mkdir(parents = True, exist_ok = True)
|
||||
config_path.touch()
|
||||
test_bot.config_file_path = str(config_path)
|
||||
|
||||
workspace = xdg_paths.Workspace.for_config(tmp_path / "resolved" / "config.yaml", "kleinanzeigen-bot")
|
||||
captured_mode:dict[str, xdg_paths.InstallationMode | None] = {"value": None}
|
||||
|
||||
with (
|
||||
patch("kleinanzeigen_bot.xdg_paths.get_xdg_base_dir", side_effect = lambda category: xdg_dirs[category]),
|
||||
patch("kleinanzeigen_bot.xdg_paths.resolve_workspace", side_effect = _make_fake_resolve_workspace(captured_mode, workspace)),
|
||||
patch("kleinanzeigen_bot.xdg_paths.ensure_directory"),
|
||||
):
|
||||
test_bot._resolve_workspace()
|
||||
|
||||
assert captured_mode["value"] == "portable"
|
||||
|
||||
def test_create_default_config_creates_parent_without_workspace(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
||||
"""create_default_config should create parent directories when no workspace is set."""
|
||||
config_path = tmp_path / "nested" / "config.yaml"
|
||||
test_bot.workspace = None
|
||||
test_bot.config_file_path = str(config_path)
|
||||
|
||||
test_bot.create_default_config()
|
||||
|
||||
assert config_path.exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("command", ["verify", "update-check", "update-content-hash", "publish", "delete", "download"])
|
||||
async def test_run_uses_installation_mode_for_update_checker(self, test_bot:KleinanzeigenBot, command:str) -> None:
|
||||
"""Ensure UpdateChecker is initialized with the detected installation mode."""
|
||||
update_checker_calls:list[tuple[Config, str | None]] = []
|
||||
async def test_run_uses_workspace_state_file_for_update_checker(self, test_bot:KleinanzeigenBot, command:str, tmp_path:Path) -> None:
|
||||
"""Ensure UpdateChecker is initialized with the workspace state file."""
|
||||
update_checker_calls:list[tuple[Config, Path]] = []
|
||||
|
||||
class DummyUpdateChecker:
|
||||
def __init__(self, config:Config, installation_mode:str | None) -> None:
|
||||
update_checker_calls.append((config, installation_mode))
|
||||
def __init__(self, config:Config, state_file:Path) -> None:
|
||||
update_checker_calls.append((config, state_file))
|
||||
|
||||
def check_for_updates(self, *_args:Any, **_kwargs:Any) -> None:
|
||||
return None
|
||||
|
||||
def set_installation_mode() -> None:
|
||||
test_bot.installation_mode = "xdg"
|
||||
def set_workspace() -> None:
|
||||
test_bot.workspace = xdg_paths.Workspace.for_config(tmp_path / "config.yaml", "kleinanzeigen-bot")
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "configure_file_logging"),
|
||||
@@ -157,17 +273,18 @@ class TestKleinanzeigenBotInitialization:
|
||||
patch.object(test_bot, "login", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "download_ads", new_callable = AsyncMock),
|
||||
patch.object(test_bot, "close_browser_session"),
|
||||
patch.object(test_bot, "finalize_installation_mode", side_effect = set_installation_mode),
|
||||
patch.object(test_bot, "_resolve_workspace", side_effect = set_workspace),
|
||||
patch("kleinanzeigen_bot.UpdateChecker", DummyUpdateChecker),
|
||||
):
|
||||
await test_bot.run(["app", command])
|
||||
|
||||
assert update_checker_calls == [(test_bot.config, "xdg")]
|
||||
expected_state_path = (tmp_path / "config.yaml").resolve().parent / ".temp" / "update_check_state.json"
|
||||
assert update_checker_calls == [(test_bot.config, expected_state_path)]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_ads_passes_installation_mode_and_published_ads(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Ensure download_ads wires installation mode and published_ads_by_id into AdExtractor."""
|
||||
test_bot.installation_mode = "xdg"
|
||||
async def test_download_ads_passes_download_dir_and_published_ads(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
||||
"""Ensure download_ads wires download_dir and published_ads_by_id into AdExtractor."""
|
||||
test_bot.workspace = xdg_paths.Workspace.for_config(tmp_path / "config.yaml", "kleinanzeigen-bot")
|
||||
test_bot.ads_selector = "all"
|
||||
test_bot.browser = MagicMock()
|
||||
|
||||
@@ -184,7 +301,10 @@ class TestKleinanzeigenBotInitialization:
|
||||
|
||||
# Verify published_ads_by_id is built correctly and passed to extractor
|
||||
mock_extractor.assert_called_once_with(
|
||||
test_bot.browser, test_bot.config, "xdg", published_ads_by_id = {123: mock_published_ads[0], 456: mock_published_ads[1]}
|
||||
test_bot.browser,
|
||||
test_bot.config,
|
||||
test_bot.workspace.download_dir,
|
||||
published_ads_by_id = {123: mock_published_ads[0], 456: mock_published_ads[1]},
|
||||
)
|
||||
|
||||
|
||||
@@ -894,6 +1014,17 @@ class TestKleinanzeigenBotArgParsing:
|
||||
assert test_bot.log_file_path is not None
|
||||
assert "test.log" in test_bot.log_file_path
|
||||
|
||||
def test_parse_args_workspace_mode(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Test parsing workspace mode option."""
|
||||
test_bot.parse_args(["script.py", "--workspace-mode=xdg", "help"])
|
||||
assert test_bot._workspace_mode_arg == "xdg"
|
||||
|
||||
def test_parse_args_workspace_mode_invalid(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Test invalid workspace mode exits with error."""
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
test_bot.parse_args(["script.py", "--workspace-mode=invalid", "help"])
|
||||
assert exc_info.value.code == 2
|
||||
|
||||
def test_parse_args_ads_selector(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""Test parsing ads selector."""
|
||||
test_bot.parse_args(["script.py", "--ads=all", "publish"])
|
||||
@@ -931,29 +1062,29 @@ class TestKleinanzeigenBotArgParsing:
|
||||
assert exc_info.value.code == 2
|
||||
|
||||
def test_parse_args_explicit_flags(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
|
||||
"""Test that explicit flags are set when --config and --logfile options are provided."""
|
||||
"""Test that explicit flags are set when config/logfile/workspace options are provided."""
|
||||
config_path = tmp_path / "custom_config.yaml"
|
||||
log_path = tmp_path / "custom.log"
|
||||
|
||||
# Test --config flag sets config_explicitly_provided
|
||||
# Test --config flag stores raw config arg
|
||||
test_bot.parse_args(["script.py", "--config", str(config_path), "help"])
|
||||
assert test_bot.config_explicitly_provided is True
|
||||
assert test_bot._config_arg == str(config_path)
|
||||
assert str(config_path.absolute()) == test_bot.config_file_path
|
||||
|
||||
# Reset for next test
|
||||
test_bot.config_explicitly_provided = False
|
||||
|
||||
# Test --logfile flag sets log_file_explicitly_provided
|
||||
# Test --logfile flag sets explicit logfile values
|
||||
test_bot.parse_args(["script.py", "--logfile", str(log_path), "help"])
|
||||
assert test_bot.log_file_explicitly_provided is True
|
||||
assert test_bot._logfile_explicitly_provided is True
|
||||
assert test_bot._logfile_arg == str(log_path)
|
||||
assert str(log_path.absolute()) == test_bot.log_file_path
|
||||
|
||||
# Test both flags together
|
||||
test_bot.config_explicitly_provided = False
|
||||
test_bot.log_file_explicitly_provided = False
|
||||
test_bot.parse_args(["script.py", "--config", str(config_path), "--logfile", str(log_path), "help"])
|
||||
assert test_bot.config_explicitly_provided is True
|
||||
assert test_bot.log_file_explicitly_provided is True
|
||||
test_bot._config_arg = None
|
||||
test_bot._logfile_explicitly_provided = False
|
||||
test_bot._workspace_mode_arg = None
|
||||
test_bot.parse_args(["script.py", "--config", str(config_path), "--logfile", str(log_path), "--workspace-mode", "portable", "help"])
|
||||
assert test_bot._config_arg == str(config_path)
|
||||
assert test_bot._logfile_explicitly_provided is True
|
||||
assert test_bot._workspace_mode_arg == "portable"
|
||||
|
||||
|
||||
class TestKleinanzeigenBotCommands:
|
||||
|
||||
@@ -10,13 +10,12 @@ from datetime import datetime, timedelta, timezone, tzinfo
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kleinanzeigen_bot.model import update_check_state as update_check_state_module
|
||||
@@ -79,20 +78,20 @@ def state_file(tmp_path:Path) -> Path:
|
||||
class TestUpdateChecker:
|
||||
"""Tests for the update checker functionality."""
|
||||
|
||||
def test_get_local_version(self, config:Config) -> None:
|
||||
def test_get_local_version(self, config:Config, state_file:Path) -> None:
|
||||
"""Test that the local version is correctly retrieved."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
assert checker.get_local_version() is not None
|
||||
|
||||
def test_get_commit_hash(self, config:Config) -> None:
|
||||
def test_get_commit_hash(self, config:Config, state_file:Path) -> None:
|
||||
"""Test that the commit hash is correctly extracted from the version string."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
assert checker._get_commit_hash("2025+fb00f11") == "fb00f11"
|
||||
assert checker._get_commit_hash("2025") is None
|
||||
|
||||
def test_resolve_commitish(self, config:Config) -> None:
|
||||
def test_resolve_commitish(self, config:Config, state_file:Path) -> None:
|
||||
"""Test that a commit-ish is resolved to a full hash and date."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
with patch(
|
||||
"requests.get",
|
||||
return_value = MagicMock(json = lambda: {"sha": "e7a3d46", "commit": {"author": {"date": "2025-05-18T00:00:00Z"}}})
|
||||
@@ -101,10 +100,10 @@ class TestUpdateChecker:
|
||||
assert commit_hash == "e7a3d46"
|
||||
assert commit_date == datetime(2025, 5, 18, tzinfo = timezone.utc)
|
||||
|
||||
def test_request_timeout_uses_config(self, config:Config, mocker:"MockerFixture") -> None:
|
||||
def test_request_timeout_uses_config(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None:
|
||||
"""Ensure HTTP calls honor the timeout configuration."""
|
||||
config.timeouts.multiplier = 1.5
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
mock_response = MagicMock(json = lambda: {"sha": "abc", "commit": {"author": {"date": "2025-05-18T00:00:00Z"}}})
|
||||
mock_get = mocker.patch("requests.get", return_value = mock_response)
|
||||
|
||||
@@ -113,9 +112,9 @@ class TestUpdateChecker:
|
||||
expected_timeout = config.timeouts.effective("update_check")
|
||||
assert mock_get.call_args.kwargs["timeout"] == expected_timeout
|
||||
|
||||
def test_resolve_commitish_no_commit(self, config:Config, mocker:"MockerFixture") -> None:
|
||||
def test_resolve_commitish_no_commit(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None:
|
||||
"""Test resolving a commit-ish when the API returns no commit data."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"sha": "abc"}))
|
||||
commit_hash, commit_date = checker._resolve_commitish("sha")
|
||||
assert commit_hash == "abc"
|
||||
@@ -124,11 +123,12 @@ class TestUpdateChecker:
|
||||
def test_resolve_commitish_logs_warning_on_exception(
|
||||
self,
|
||||
config:Config,
|
||||
state_file:Path,
|
||||
caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test resolving a commit-ish logs a warning when the request fails."""
|
||||
caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker")
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
with patch("requests.get", side_effect = Exception("boom")):
|
||||
commit_hash, commit_date = checker._resolve_commitish("sha")
|
||||
|
||||
@@ -136,22 +136,22 @@ class TestUpdateChecker:
|
||||
assert commit_date is None
|
||||
assert any("Could not resolve commit 'sha': boom" in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_commits_match_short_hash(self, config:Config) -> None:
|
||||
def test_commits_match_short_hash(self, config:Config, state_file:Path) -> None:
|
||||
"""Test that short commit hashes are treated as matching prefixes."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
assert checker._commits_match("abc1234", "abc1234def5678") is True
|
||||
|
||||
def test_check_for_updates_disabled(self, config:Config) -> None:
|
||||
def test_check_for_updates_disabled(self, config:Config, state_file:Path) -> None:
|
||||
"""Test that the update checker does not check for updates if disabled."""
|
||||
config.update_check.enabled = False
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
with patch("requests.get") as mock_get:
|
||||
checker.check_for_updates()
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_check_for_updates_no_local_version(self, config:Config) -> None:
|
||||
def test_check_for_updates_no_local_version(self, config:Config, state_file:Path) -> None:
|
||||
"""Test that the update checker handles the case where the local version cannot be determined."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
with patch.object(UpdateCheckState, "should_check", return_value = True), \
|
||||
patch.object(UpdateChecker, "get_local_version", return_value = None):
|
||||
checker.check_for_updates() # Should not raise exception
|
||||
@@ -159,38 +159,40 @@ class TestUpdateChecker:
|
||||
def test_check_for_updates_logs_missing_local_version(
|
||||
self,
|
||||
config:Config,
|
||||
state_file:Path,
|
||||
caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test that the update checker logs a warning when the local version is missing."""
|
||||
caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker")
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
with patch.object(UpdateCheckState, "should_check", return_value = True), \
|
||||
patch.object(UpdateChecker, "get_local_version", return_value = None):
|
||||
checker.check_for_updates()
|
||||
|
||||
assert any("Could not determine local version." in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_check_for_updates_no_commit_hash(self, config:Config) -> None:
|
||||
def test_check_for_updates_no_commit_hash(self, config:Config, state_file:Path) -> None:
|
||||
"""Test that the update checker handles the case where the commit hash cannot be extracted."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
with patch.object(UpdateChecker, "get_local_version", return_value = "2025"):
|
||||
checker.check_for_updates() # Should not raise exception
|
||||
|
||||
def test_check_for_updates_no_releases(self, config:Config) -> None:
|
||||
def test_check_for_updates_no_releases(self, config:Config, state_file:Path) -> None:
|
||||
"""Test that the update checker handles the case where no releases are found."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
with patch("requests.get", return_value = MagicMock(json = list)):
|
||||
checker.check_for_updates() # Should not raise exception
|
||||
|
||||
def test_check_for_updates_api_error(self, config:Config) -> None:
|
||||
def test_check_for_updates_api_error(self, config:Config, state_file:Path) -> None:
|
||||
"""Test that the update checker handles API errors gracefully."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
with patch("requests.get", side_effect = Exception("API Error")):
|
||||
checker.check_for_updates() # Should not raise exception
|
||||
|
||||
def test_check_for_updates_latest_prerelease_warning(
|
||||
self,
|
||||
config:Config,
|
||||
state_file:Path,
|
||||
mocker:"MockerFixture",
|
||||
caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
@@ -205,13 +207,13 @@ class TestUpdateChecker:
|
||||
return_value = mocker.Mock(json = lambda: {"tag_name": "latest", "prerelease": True})
|
||||
)
|
||||
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
checker.check_for_updates()
|
||||
|
||||
expected = "Latest release from GitHub is a prerelease, but 'latest' channel expects a stable release."
|
||||
assert any(expected in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_check_for_updates_ahead(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
|
||||
def test_check_for_updates_ahead(self, config:Config, state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
|
||||
"""Test that the update checker correctly identifies when the local version is ahead of the latest release."""
|
||||
caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker")
|
||||
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
|
||||
@@ -233,7 +235,7 @@ class TestUpdateChecker:
|
||||
)
|
||||
mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
|
||||
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
checker.check_for_updates()
|
||||
|
||||
print("LOG RECORDS:")
|
||||
@@ -246,7 +248,7 @@ class TestUpdateChecker:
|
||||
)
|
||||
assert any(expected in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_check_for_updates_preview(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
|
||||
def test_check_for_updates_preview(self, config:Config, state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
|
||||
"""Test that the update checker correctly handles preview releases."""
|
||||
caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker")
|
||||
config.update_check.channel = "preview"
|
||||
@@ -269,7 +271,7 @@ class TestUpdateChecker:
|
||||
)
|
||||
mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
|
||||
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
checker.check_for_updates()
|
||||
|
||||
print("LOG RECORDS:")
|
||||
@@ -286,6 +288,7 @@ class TestUpdateChecker:
|
||||
def test_check_for_updates_preview_missing_prerelease(
|
||||
self,
|
||||
config:Config,
|
||||
state_file:Path,
|
||||
mocker:"MockerFixture",
|
||||
caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
@@ -301,12 +304,12 @@ class TestUpdateChecker:
|
||||
return_value = mocker.Mock(json = lambda: [{"tag_name": "v1", "prerelease": False, "draft": False}])
|
||||
)
|
||||
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
checker.check_for_updates()
|
||||
|
||||
assert any("No prerelease found for 'preview' channel." in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_check_for_updates_behind(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
|
||||
def test_check_for_updates_behind(self, config:Config, state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
|
||||
"""Test that the update checker correctly identifies when the local version is behind the latest release."""
|
||||
caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker")
|
||||
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
|
||||
@@ -328,7 +331,7 @@ class TestUpdateChecker:
|
||||
)
|
||||
mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
|
||||
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
checker.check_for_updates()
|
||||
|
||||
print("LOG RECORDS:")
|
||||
@@ -341,6 +344,7 @@ class TestUpdateChecker:
|
||||
def test_check_for_updates_logs_release_notes(
|
||||
self,
|
||||
config:Config,
|
||||
state_file:Path,
|
||||
mocker:"MockerFixture",
|
||||
caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
@@ -365,12 +369,12 @@ class TestUpdateChecker:
|
||||
)
|
||||
)
|
||||
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
checker.check_for_updates()
|
||||
|
||||
assert any("Release notes:\nRelease notes here" in r.getMessage() for r in caplog.records)
|
||||
|
||||
def test_check_for_updates_same(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
|
||||
def test_check_for_updates_same(self, config:Config, state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
|
||||
"""Test that the update checker correctly identifies when the local version is the same as the latest release."""
|
||||
caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker")
|
||||
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
|
||||
@@ -392,7 +396,7 @@ class TestUpdateChecker:
|
||||
)
|
||||
mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
|
||||
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
checker.check_for_updates()
|
||||
|
||||
print("LOG RECORDS:")
|
||||
@@ -405,6 +409,7 @@ class TestUpdateChecker:
|
||||
def test_check_for_updates_unknown_channel(
|
||||
self,
|
||||
config:Config,
|
||||
state_file:Path,
|
||||
mocker:"MockerFixture",
|
||||
caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
@@ -416,7 +421,7 @@ class TestUpdateChecker:
|
||||
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
|
||||
mock_get = mocker.patch("requests.get")
|
||||
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
checker.check_for_updates()
|
||||
|
||||
mock_get.assert_not_called()
|
||||
@@ -425,6 +430,7 @@ class TestUpdateChecker:
|
||||
def test_check_for_updates_respects_interval_gate(
|
||||
self,
|
||||
config:Config,
|
||||
state_file:Path,
|
||||
caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Ensure the interval guard short-circuits update checks without touching the network."""
|
||||
@@ -433,7 +439,7 @@ class TestUpdateChecker:
|
||||
with patch.object(UpdateCheckState, "should_check", return_value = False) as should_check_mock, \
|
||||
patch.object(UpdateCheckState, "update_last_check") as update_last_check_mock, \
|
||||
patch("requests.get") as mock_get:
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
checker.check_for_updates()
|
||||
|
||||
should_check_mock.assert_called_once()
|
||||
@@ -604,33 +610,33 @@ class TestUpdateChecker:
|
||||
# Should not raise
|
||||
state.save(state_file)
|
||||
|
||||
def test_resolve_commitish_no_author(self, config:Config, mocker:"MockerFixture") -> None:
|
||||
def test_resolve_commitish_no_author(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None:
|
||||
"""Test resolving a commit-ish when the API returns no author key."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"sha": "abc", "commit": {}}))
|
||||
commit_hash, commit_date = checker._resolve_commitish("sha")
|
||||
assert commit_hash == "abc"
|
||||
assert commit_date is None
|
||||
|
||||
def test_resolve_commitish_no_date(self, config:Config, mocker:"MockerFixture") -> None:
|
||||
def test_resolve_commitish_no_date(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None:
|
||||
"""Test resolving a commit-ish when the API returns no date key."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"sha": "abc", "commit": {"author": {}}}))
|
||||
commit_hash, commit_date = checker._resolve_commitish("sha")
|
||||
assert commit_hash == "abc"
|
||||
assert commit_date is None
|
||||
|
||||
def test_resolve_commitish_list_instead_of_dict(self, config:Config, mocker:"MockerFixture") -> None:
|
||||
def test_resolve_commitish_list_instead_of_dict(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None:
|
||||
"""Test resolving a commit-ish when the API returns a list instead of dict."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
mocker.patch("requests.get", return_value = mocker.Mock(json = list))
|
||||
commit_hash, commit_date = checker._resolve_commitish("sha")
|
||||
assert commit_hash is None
|
||||
assert commit_date is None
|
||||
|
||||
def test_check_for_updates_missing_release_commitish(self, config:Config, mocker:"MockerFixture") -> None:
|
||||
def test_check_for_updates_missing_release_commitish(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None:
|
||||
"""Test check_for_updates handles missing release commit-ish."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
|
||||
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
|
||||
mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
|
||||
@@ -640,21 +646,21 @@ class TestUpdateChecker:
|
||||
)
|
||||
checker.check_for_updates() # Should not raise
|
||||
|
||||
def test_check_for_updates_no_releases_empty(self, config:Config, mocker:"MockerFixture") -> None:
|
||||
def test_check_for_updates_no_releases_empty(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None:
|
||||
"""Test check_for_updates handles no releases found (API returns empty list)."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
mocker.patch("requests.get", return_value = mocker.Mock(json = list))
|
||||
mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
|
||||
checker.check_for_updates() # Should not raise
|
||||
|
||||
def test_check_for_updates_no_commit_hash_extracted(self, config:Config, mocker:"MockerFixture") -> None:
|
||||
def test_check_for_updates_no_commit_hash_extracted(self, config:Config, state_file:Path, mocker:"MockerFixture") -> None:
|
||||
"""Test check_for_updates handles no commit hash extracted."""
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025")
|
||||
mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
|
||||
checker.check_for_updates() # Should not raise
|
||||
|
||||
def test_check_for_updates_no_commit_dates(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
|
||||
def test_check_for_updates_no_commit_dates(self, config:Config, state_file:Path, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
|
||||
"""Test check_for_updates logs warning if commit dates cannot be determined."""
|
||||
caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker")
|
||||
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
|
||||
@@ -668,7 +674,7 @@ class TestUpdateChecker:
|
||||
json = lambda: {"tag_name": "latest", "prerelease": False}
|
||||
)
|
||||
)
|
||||
checker = UpdateChecker(config)
|
||||
checker = UpdateChecker(config, state_file)
|
||||
checker.check_for_updates()
|
||||
assert any("Could not determine commit dates for comparison." in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
@@ -987,6 +987,93 @@ class TestWebScrapingBrowserConfiguration:
|
||||
assert "-inprivate" in config.browser_args
|
||||
assert os.environ.get("MSEDGEDRIVER_TELEMETRY_OPTOUT") == "1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_browser_session_logs_missing_user_data_dir_for_non_test_runs(
|
||||
self, monkeypatch:pytest.MonkeyPatch, caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test non-test runtime without user_data_dir logs fallback diagnostics and default profile usage."""
|
||||
class DummyConfig:
|
||||
def __init__(self, **kwargs:object) -> None:
|
||||
self.browser_args = cast(list[str], kwargs.get("browser_args", []))
|
||||
self.user_data_dir = cast(str | None, kwargs.get("user_data_dir"))
|
||||
self.browser_executable_path = cast(str | None, kwargs.get("browser_executable_path"))
|
||||
self.headless = cast(bool, kwargs.get("headless", False))
|
||||
|
||||
def add_extension(self, _ext:str) -> None:
|
||||
return
|
||||
|
||||
mock_browser = AsyncMock()
|
||||
mock_browser.websocket_url = "ws://localhost:9222"
|
||||
monkeypatch.setattr(nodriver, "start", AsyncMock(return_value = mock_browser))
|
||||
monkeypatch.setattr("kleinanzeigen_bot.utils.web_scraping_mixin.NodriverConfig", DummyConfig)
|
||||
monkeypatch.setattr(loggers, "is_debug", lambda _logger: False)
|
||||
monkeypatch.setattr(
|
||||
WebScrapingMixin,
|
||||
"_validate_chrome_version_configuration",
|
||||
AsyncMock(return_value = None),
|
||||
)
|
||||
|
||||
async def mock_exists(path:str | Path) -> bool:
|
||||
return str(path) == "/usr/bin/chrome"
|
||||
|
||||
monkeypatch.setattr(files, "exists", mock_exists)
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
with patch.dict(os.environ, {}, clear = True):
|
||||
scraper = WebScrapingMixin()
|
||||
scraper.browser_config.binary_location = "/usr/bin/chrome"
|
||||
await scraper.create_browser_session()
|
||||
|
||||
cfg = _nodriver_start_mock().call_args[0][0]
|
||||
assert cfg.user_data_dir is None
|
||||
assert "--log-level=3" in cfg.browser_args
|
||||
assert "No browser user_data_dir configured" in caplog.text
|
||||
assert "No effective browser user_data_dir found" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_browser_session_ensures_profile_directory_for_user_data_dir(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test configured user_data_dir creates profile structure and skips non-debug log-level override."""
|
||||
class DummyConfig:
|
||||
def __init__(self, **kwargs:object) -> None:
|
||||
self.browser_args = cast(list[str], kwargs.get("browser_args", []))
|
||||
self.user_data_dir = cast(str | None, kwargs.get("user_data_dir"))
|
||||
self.browser_executable_path = cast(str | None, kwargs.get("browser_executable_path"))
|
||||
self.headless = cast(bool, kwargs.get("headless", False))
|
||||
|
||||
def add_extension(self, _ext:str) -> None:
|
||||
return
|
||||
|
||||
mock_browser = AsyncMock()
|
||||
mock_browser.websocket_url = "ws://localhost:9222"
|
||||
monkeypatch.setattr(nodriver, "start", AsyncMock(return_value = mock_browser))
|
||||
monkeypatch.setattr("kleinanzeigen_bot.utils.web_scraping_mixin.NodriverConfig", DummyConfig)
|
||||
monkeypatch.setattr(loggers, "is_debug", lambda _logger: True)
|
||||
monkeypatch.setattr(
|
||||
WebScrapingMixin,
|
||||
"_validate_chrome_version_configuration",
|
||||
AsyncMock(return_value = None),
|
||||
)
|
||||
|
||||
async def mock_exists(path:str | Path) -> bool:
|
||||
path_str = str(path)
|
||||
if path_str == "/usr/bin/chrome":
|
||||
return True
|
||||
return bool(path_str.endswith("Preferences"))
|
||||
|
||||
monkeypatch.setattr(files, "exists", mock_exists)
|
||||
|
||||
with patch.dict(os.environ, {}, clear = True), \
|
||||
patch("kleinanzeigen_bot.utils.web_scraping_mixin.xdg_paths.ensure_directory") as mock_ensure_dir:
|
||||
scraper = WebScrapingMixin()
|
||||
scraper.browser_config.binary_location = "/usr/bin/chrome"
|
||||
scraper.browser_config.user_data_dir = str(tmp_path / "profile-root")
|
||||
await scraper.create_browser_session()
|
||||
|
||||
cfg = _nodriver_start_mock().call_args[0][0]
|
||||
assert cfg.user_data_dir == str(tmp_path / "profile-root")
|
||||
assert "--log-level=3" not in cfg.browser_args
|
||||
mock_ensure_dir.assert_called_once_with(Path(str(tmp_path / "profile-root")), "browser profile directory")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_extension_loading(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test browser extension loading."""
|
||||
|
||||
@@ -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