fix: add explicit workspace mode resolution for --config (#818)

This commit is contained in:
Jens
2026-02-11 05:35:41 +01:00
committed by GitHub
parent c212113638
commit 4282b05ff3
15 changed files with 1014 additions and 744 deletions

View File

@@ -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,
):

View File

@@ -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:

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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",
)