mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 02:31:45 +01:00
feat: add browser profile XDG support and documentation (#777)
This commit is contained in:
@@ -33,7 +33,7 @@ async def atest_init() -> None:
|
||||
web_scraping_mixin.close_browser_session()
|
||||
|
||||
|
||||
@pytest.mark.flaky(reruns = 4, reruns_delay = 5)
|
||||
@pytest.mark.flaky(reruns = 5, reruns_delay = 10)
|
||||
@pytest.mark.itest
|
||||
def test_init() -> None:
|
||||
nodriver.loop().run_until_complete(atest_init()) # type: ignore[attr-defined]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -641,6 +641,31 @@ class TestKleinanzeigenBotArgParsing:
|
||||
test_bot.parse_args(["script.py", "help", "version"])
|
||||
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."""
|
||||
config_path = tmp_path / "custom_config.yaml"
|
||||
log_path = tmp_path / "custom.log"
|
||||
|
||||
# Test --config flag sets config_explicitly_provided
|
||||
test_bot.parse_args(["script.py", "--config", str(config_path), "help"])
|
||||
assert test_bot.config_explicitly_provided is True
|
||||
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_bot.parse_args(["script.py", "--logfile", str(log_path), "help"])
|
||||
assert test_bot.log_file_explicitly_provided is True
|
||||
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
|
||||
|
||||
|
||||
class TestKleinanzeigenBotCommands:
|
||||
"""Tests for command execution."""
|
||||
@@ -863,7 +888,7 @@ class TestKleinanzeigenBotAdDeletion:
|
||||
async def test_delete_ad_by_title(self, test_bot:KleinanzeigenBot, minimal_ad_config:dict[str, Any]) -> None:
|
||||
"""Test deleting an ad by title."""
|
||||
test_bot.page = MagicMock()
|
||||
test_bot.page.evaluate = AsyncMock(return_value = {"statusCode": 200, "content": "{}"})
|
||||
test_bot.page.evaluate = AsyncMock(return_value = {"statusCode": 200, "statusMessage": "OK", "content": "{}"})
|
||||
test_bot.page.sleep = AsyncMock()
|
||||
|
||||
# Use minimal config since we only need title for deletion by title
|
||||
@@ -891,7 +916,7 @@ class TestKleinanzeigenBotAdDeletion:
|
||||
async def test_delete_ad_by_id(self, test_bot:KleinanzeigenBot, minimal_ad_config:dict[str, Any]) -> None:
|
||||
"""Test deleting an ad by ID."""
|
||||
test_bot.page = MagicMock()
|
||||
test_bot.page.evaluate = AsyncMock(return_value = {"statusCode": 200, "content": "{}"})
|
||||
test_bot.page.evaluate = AsyncMock(return_value = {"statusCode": 200, "statusMessage": "OK", "content": "{}"})
|
||||
test_bot.page.sleep = AsyncMock()
|
||||
|
||||
# Create config with ID for deletion by ID
|
||||
@@ -918,7 +943,7 @@ class TestKleinanzeigenBotAdDeletion:
|
||||
async def test_delete_ad_by_id_with_non_string_csrf_token(self, test_bot:KleinanzeigenBot, minimal_ad_config:dict[str, Any]) -> None:
|
||||
"""Test deleting an ad by ID with non-string CSRF token to cover str() conversion."""
|
||||
test_bot.page = MagicMock()
|
||||
test_bot.page.evaluate = AsyncMock(return_value = {"statusCode": 200, "content": "{}"})
|
||||
test_bot.page.evaluate = AsyncMock(return_value = {"statusCode": 200, "statusMessage": "OK", "content": "{}"})
|
||||
test_bot.page.sleep = AsyncMock()
|
||||
|
||||
# Create config with ID for deletion by ID
|
||||
|
||||
@@ -20,9 +20,7 @@ class TestWebScrapingMixinChromeVersionValidation:
|
||||
return WebScrapingMixin()
|
||||
|
||||
@patch("kleinanzeigen_bot.utils.web_scraping_mixin.detect_chrome_version_from_binary")
|
||||
async def test_validate_chrome_version_configuration_chrome_136_plus_valid(
|
||||
self, mock_detect:Mock, scraper:WebScrapingMixin
|
||||
) -> None:
|
||||
async def test_validate_chrome_version_configuration_chrome_136_plus_valid(self, mock_detect:Mock, scraper:WebScrapingMixin) -> None:
|
||||
"""Test Chrome 136+ validation with valid configuration."""
|
||||
# Setup mocks
|
||||
mock_detect.return_value = ChromeVersionInfo("136.0.6778.0", 136, "Chrome")
|
||||
@@ -88,9 +86,7 @@ class TestWebScrapingMixinChromeVersionValidation:
|
||||
os.environ["PYTEST_CURRENT_TEST"] = original_env
|
||||
|
||||
@patch("kleinanzeigen_bot.utils.web_scraping_mixin.detect_chrome_version_from_binary")
|
||||
async def test_validate_chrome_version_configuration_chrome_pre_136(
|
||||
self, mock_detect:Mock, scraper:WebScrapingMixin
|
||||
) -> None:
|
||||
async def test_validate_chrome_version_configuration_chrome_pre_136(self, mock_detect:Mock, scraper:WebScrapingMixin) -> None:
|
||||
"""Test Chrome pre-136 validation (no special requirements)."""
|
||||
# Setup mocks
|
||||
mock_detect.return_value = ChromeVersionInfo("120.0.6099.109", 120, "Chrome")
|
||||
@@ -121,11 +117,7 @@ class TestWebScrapingMixinChromeVersionValidation:
|
||||
@patch("kleinanzeigen_bot.utils.chrome_version_detector.detect_chrome_version_from_binary")
|
||||
@patch("kleinanzeigen_bot.utils.web_scraping_mixin.detect_chrome_version_from_remote_debugging")
|
||||
async def test_validate_chrome_version_logs_remote_detection(
|
||||
self,
|
||||
mock_remote:Mock,
|
||||
mock_binary:Mock,
|
||||
scraper:WebScrapingMixin,
|
||||
caplog:pytest.LogCaptureFixture
|
||||
self, mock_remote:Mock, mock_binary:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""When a remote browser responds, the detected version should be logged."""
|
||||
mock_remote.return_value = ChromeVersionInfo("136.0.6778.0", 136, "Chrome")
|
||||
@@ -134,17 +126,14 @@ class TestWebScrapingMixinChromeVersionValidation:
|
||||
scraper.browser_config.binary_location = "/path/to/chrome"
|
||||
caplog.set_level("DEBUG")
|
||||
|
||||
with patch.dict(os.environ, {}, clear = True), \
|
||||
patch.object(scraper, "_check_port_with_retry", return_value = True):
|
||||
with patch.dict(os.environ, {}, clear = True), patch.object(scraper, "_check_port_with_retry", return_value = True):
|
||||
await scraper._validate_chrome_version_configuration()
|
||||
|
||||
assert "Detected version from existing browser" in caplog.text
|
||||
mock_remote.assert_called_once()
|
||||
|
||||
@patch("kleinanzeigen_bot.utils.chrome_version_detector.detect_chrome_version_from_binary")
|
||||
async def test_validate_chrome_version_configuration_no_binary_location(
|
||||
self, mock_detect:Mock, scraper:WebScrapingMixin
|
||||
) -> None:
|
||||
async def test_validate_chrome_version_configuration_no_binary_location(self, mock_detect:Mock, scraper:WebScrapingMixin) -> None:
|
||||
"""Test Chrome version validation when no binary location is set."""
|
||||
# Configure scraper without binary location
|
||||
scraper.browser_config.binary_location = None
|
||||
@@ -204,15 +193,10 @@ class TestWebScrapingMixinChromeVersionDiagnostics:
|
||||
"""Test Chrome version diagnostics with binary detection."""
|
||||
# Setup mocks
|
||||
mock_get_diagnostic.return_value = {
|
||||
"binary_detection": {
|
||||
"version_string": "136.0.6778.0",
|
||||
"major_version": 136,
|
||||
"browser_name": "Chrome",
|
||||
"is_chrome_136_plus": True
|
||||
},
|
||||
"binary_detection": {"version_string": "136.0.6778.0", "major_version": 136, "browser_name": "Chrome", "is_chrome_136_plus": True},
|
||||
"remote_detection": None,
|
||||
"chrome_136_plus_detected": True,
|
||||
"recommendations": []
|
||||
"recommendations": [],
|
||||
}
|
||||
mock_validate.return_value = (True, "")
|
||||
|
||||
@@ -230,7 +214,7 @@ class TestWebScrapingMixinChromeVersionDiagnostics:
|
||||
scraper._diagnose_chrome_version_issues(9222)
|
||||
|
||||
# Verify logs
|
||||
assert "Chrome version from binary: Chrome 136.0.6778.0 (major: 136)" in caplog.text
|
||||
assert "Chrome version from binary: 136.0.6778.0 (major: 136)" in caplog.text
|
||||
assert "Chrome 136+ detected - security validation required" in caplog.text
|
||||
|
||||
# Verify mocks were called
|
||||
@@ -255,14 +239,9 @@ class TestWebScrapingMixinChromeVersionDiagnostics:
|
||||
# Setup mocks
|
||||
mock_get_diagnostic.return_value = {
|
||||
"binary_detection": None,
|
||||
"remote_detection": {
|
||||
"version_string": "136.0.6778.0",
|
||||
"major_version": 136,
|
||||
"browser_name": "Chrome",
|
||||
"is_chrome_136_plus": True
|
||||
},
|
||||
"remote_detection": {"version_string": "136.0.6778.0", "major_version": 136, "browser_name": "Chrome", "is_chrome_136_plus": True},
|
||||
"chrome_136_plus_detected": True,
|
||||
"recommendations": []
|
||||
"recommendations": [],
|
||||
}
|
||||
mock_validate.return_value = (False, "Chrome 136+ requires --user-data-dir")
|
||||
|
||||
@@ -280,32 +259,22 @@ class TestWebScrapingMixinChromeVersionDiagnostics:
|
||||
scraper._diagnose_chrome_version_issues(9222)
|
||||
|
||||
# Verify logs
|
||||
assert "Chrome version from remote debugging: Chrome 136.0.6778.0 (major: 136)" in caplog.text
|
||||
assert "(info) Chrome version from remote debugging: 136.0.6778.0 (major: 136)" in caplog.text
|
||||
assert "Remote Chrome 136+ detected - validating configuration" in caplog.text
|
||||
assert "Chrome 136+ configuration validation failed" in caplog.text
|
||||
|
||||
# Verify validation was called
|
||||
mock_validate.assert_called_once_with(
|
||||
["--remote-debugging-port=9222"],
|
||||
None
|
||||
)
|
||||
mock_validate.assert_called_once_with(["--remote-debugging-port=9222"], None)
|
||||
finally:
|
||||
# Restore environment
|
||||
if original_env:
|
||||
os.environ["PYTEST_CURRENT_TEST"] = original_env
|
||||
|
||||
@patch("kleinanzeigen_bot.utils.web_scraping_mixin.get_chrome_version_diagnostic_info")
|
||||
def test_diagnose_chrome_version_issues_no_detection(
|
||||
self, mock_get_diagnostic:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
def test_diagnose_chrome_version_issues_no_detection(self, mock_get_diagnostic:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture) -> None:
|
||||
"""Test Chrome version diagnostics with no detection."""
|
||||
# Setup mocks
|
||||
mock_get_diagnostic.return_value = {
|
||||
"binary_detection": None,
|
||||
"remote_detection": None,
|
||||
"chrome_136_plus_detected": False,
|
||||
"recommendations": []
|
||||
}
|
||||
mock_get_diagnostic.return_value = {"binary_detection": None, "remote_detection": None, "chrome_136_plus_detected": False, "recommendations": []}
|
||||
|
||||
# Configure scraper
|
||||
scraper.browser_config.binary_location = "/path/to/chrome"
|
||||
@@ -334,15 +303,10 @@ class TestWebScrapingMixinChromeVersionDiagnostics:
|
||||
"""Test Chrome version diagnostics with Chrome 136+ recommendations."""
|
||||
# Setup mocks
|
||||
mock_get_diagnostic.return_value = {
|
||||
"binary_detection": {
|
||||
"version_string": "136.0.6778.0",
|
||||
"major_version": 136,
|
||||
"browser_name": "Chrome",
|
||||
"is_chrome_136_plus": True
|
||||
},
|
||||
"binary_detection": {"version_string": "136.0.6778.0", "major_version": 136, "browser_name": "Chrome", "is_chrome_136_plus": True},
|
||||
"remote_detection": None,
|
||||
"chrome_136_plus_detected": True,
|
||||
"recommendations": []
|
||||
"recommendations": [],
|
||||
}
|
||||
|
||||
# Configure scraper
|
||||
@@ -377,11 +341,11 @@ class TestWebScrapingMixinChromeVersionDiagnostics:
|
||||
"version_string": "120.0.6099.109",
|
||||
"major_version": 120,
|
||||
"browser_name": "Chrome",
|
||||
"is_chrome_136_plus": False # This triggers the else branch (lines 832-849)
|
||||
"is_chrome_136_plus": False, # This triggers the else branch (lines 832-849)
|
||||
},
|
||||
"remote_detection": None, # Ensure this is None to avoid other branches
|
||||
"chrome_136_plus_detected": False, # Ensure this is False to avoid recommendations
|
||||
"recommendations": []
|
||||
"recommendations": [],
|
||||
}
|
||||
|
||||
# Configure scraper
|
||||
@@ -420,14 +384,9 @@ class TestWebScrapingMixinChromeVersionDiagnostics:
|
||||
# Setup mocks
|
||||
mock_get_diagnostic.return_value = {
|
||||
"binary_detection": None,
|
||||
"remote_detection": {
|
||||
"version_string": "136.0.6778.0",
|
||||
"major_version": 136,
|
||||
"browser_name": "Chrome",
|
||||
"is_chrome_136_plus": True
|
||||
},
|
||||
"remote_detection": {"version_string": "136.0.6778.0", "major_version": 136, "browser_name": "Chrome", "is_chrome_136_plus": True},
|
||||
"chrome_136_plus_detected": True,
|
||||
"recommendations": []
|
||||
"recommendations": [],
|
||||
}
|
||||
mock_validate.return_value = (True, "") # This triggers the else branch (line 846)
|
||||
|
||||
@@ -451,7 +410,7 @@ class TestWebScrapingMixinChromeVersionDiagnostics:
|
||||
# Verify validation was called with correct arguments
|
||||
mock_validate.assert_called_once_with(
|
||||
["--remote-debugging-port=9222", "--user-data-dir=/tmp/chrome-debug"], # noqa: S108
|
||||
"/tmp/chrome-debug" # noqa: S108
|
||||
"/tmp/chrome-debug", # noqa: S108
|
||||
)
|
||||
finally:
|
||||
# Restore environment
|
||||
@@ -469,9 +428,7 @@ class TestWebScrapingMixinIntegration:
|
||||
|
||||
@patch.object(WebScrapingMixin, "_validate_chrome_version_configuration")
|
||||
@patch.object(WebScrapingMixin, "get_compatible_browser")
|
||||
async def test_create_browser_session_calls_chrome_validation(
|
||||
self, mock_get_browser:Mock, mock_validate:Mock, scraper:WebScrapingMixin
|
||||
) -> None:
|
||||
async def test_create_browser_session_calls_chrome_validation(self, mock_get_browser:Mock, mock_validate:Mock, scraper:WebScrapingMixin) -> None:
|
||||
"""Test that create_browser_session calls Chrome version validation."""
|
||||
# Setup mocks
|
||||
mock_get_browser.return_value = "/path/to/chrome"
|
||||
@@ -493,9 +450,7 @@ class TestWebScrapingMixinIntegration:
|
||||
|
||||
@patch.object(WebScrapingMixin, "_diagnose_chrome_version_issues")
|
||||
@patch.object(WebScrapingMixin, "get_compatible_browser")
|
||||
def test_diagnose_browser_issues_calls_chrome_diagnostics(
|
||||
self, mock_get_browser:Mock, mock_diagnose:Mock, scraper:WebScrapingMixin
|
||||
) -> None:
|
||||
def test_diagnose_browser_issues_calls_chrome_diagnostics(self, mock_get_browser:Mock, mock_diagnose:Mock, scraper:WebScrapingMixin) -> None:
|
||||
"""Test that diagnose_browser_issues calls Chrome version diagnostics."""
|
||||
# Setup mocks
|
||||
mock_get_browser.return_value = "/path/to/chrome"
|
||||
@@ -521,9 +476,7 @@ class TestWebScrapingMixinIntegration:
|
||||
|
||||
# Mock Chrome version detection to return pre-136 version
|
||||
with patch("kleinanzeigen_bot.utils.web_scraping_mixin.detect_chrome_version_from_binary") as mock_detect:
|
||||
mock_detect.return_value = ChromeVersionInfo(
|
||||
"120.0.6099.109", 120, "Chrome"
|
||||
)
|
||||
mock_detect.return_value = ChromeVersionInfo("120.0.6099.109", 120, "Chrome")
|
||||
|
||||
# Temporarily unset PYTEST_CURRENT_TEST to allow validation to run
|
||||
original_env = os.environ.get("PYTEST_CURRENT_TEST")
|
||||
@@ -541,3 +494,68 @@ class TestWebScrapingMixinIntegration:
|
||||
# Restore environment
|
||||
if original_env:
|
||||
os.environ["PYTEST_CURRENT_TEST"] = original_env
|
||||
|
||||
@patch("kleinanzeigen_bot.utils.web_scraping_mixin.detect_chrome_version_from_binary")
|
||||
async def test_validate_chrome_136_configuration_with_whitespace_user_data_dir(
|
||||
self, mock_detect:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test Chrome 136+ validation correctly handles whitespace-only user_data_dir."""
|
||||
# Setup mocks
|
||||
mock_detect.return_value = ChromeVersionInfo("136.0.6778.0", 136, "Chrome")
|
||||
|
||||
# Configure scraper with whitespace-only user_data_dir
|
||||
scraper.browser_config.binary_location = "/path/to/chrome"
|
||||
scraper.browser_config.arguments = ["--remote-debugging-port=9222"]
|
||||
scraper.browser_config.user_data_dir = " " # Only whitespace
|
||||
|
||||
# Temporarily unset PYTEST_CURRENT_TEST to allow validation to run
|
||||
original_env = os.environ.get("PYTEST_CURRENT_TEST")
|
||||
if "PYTEST_CURRENT_TEST" in os.environ:
|
||||
del os.environ["PYTEST_CURRENT_TEST"]
|
||||
|
||||
try:
|
||||
# Test validation should fail because whitespace-only is treated as empty
|
||||
await scraper._validate_chrome_version_configuration()
|
||||
|
||||
# Verify detection was called
|
||||
assert mock_detect.call_count == 1
|
||||
|
||||
# Verify error was logged
|
||||
assert "Chrome 136+ configuration validation failed" in caplog.text
|
||||
assert "Chrome 136+ requires --user-data-dir" in caplog.text
|
||||
finally:
|
||||
# Restore environment
|
||||
if original_env:
|
||||
os.environ["PYTEST_CURRENT_TEST"] = original_env
|
||||
|
||||
@patch("kleinanzeigen_bot.utils.web_scraping_mixin.detect_chrome_version_from_binary")
|
||||
async def test_validate_chrome_136_configuration_with_valid_user_data_dir(
|
||||
self, mock_detect:Mock, scraper:WebScrapingMixin, caplog:pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test Chrome 136+ validation passes with valid user_data_dir."""
|
||||
# Setup mocks
|
||||
mock_detect.return_value = ChromeVersionInfo("136.0.6778.0", 136, "Chrome")
|
||||
|
||||
# Configure scraper with valid user_data_dir
|
||||
scraper.browser_config.binary_location = "/path/to/chrome"
|
||||
scraper.browser_config.arguments = ["--remote-debugging-port=9222"]
|
||||
scraper.browser_config.user_data_dir = "/tmp/valid-profile" # noqa: S108
|
||||
|
||||
# Temporarily unset PYTEST_CURRENT_TEST to allow validation to run
|
||||
original_env = os.environ.get("PYTEST_CURRENT_TEST")
|
||||
if "PYTEST_CURRENT_TEST" in os.environ:
|
||||
del os.environ["PYTEST_CURRENT_TEST"]
|
||||
|
||||
try:
|
||||
# Test validation should pass
|
||||
await scraper._validate_chrome_version_configuration()
|
||||
|
||||
# Verify detection was called
|
||||
assert mock_detect.call_count == 1
|
||||
|
||||
# Verify success was logged
|
||||
assert "Chrome 136+ configuration validation passed" in caplog.text
|
||||
finally:
|
||||
# Restore environment
|
||||
if original_env:
|
||||
os.environ["PYTEST_CURRENT_TEST"] = original_env
|
||||
|
||||
@@ -17,7 +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:
|
||||
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))
|
||||
@@ -28,21 +28,21 @@ class TestGetXdgBaseDir:
|
||||
|
||||
def test_raises_for_unknown_category(self) -> None:
|
||||
"""Test invalid category handling."""
|
||||
with pytest.raises(ValueError, match="Unsupported XDG category"):
|
||||
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:
|
||||
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)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Failed to resolve XDG base directory"):
|
||||
with pytest.raises(RuntimeError, match = "Failed to resolve XDG base directory"):
|
||||
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:
|
||||
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)
|
||||
@@ -54,11 +54,11 @@ class TestDetectInstallationMode:
|
||||
# Verify
|
||||
assert mode == "portable"
|
||||
|
||||
def test_detects_xdg_mode_when_config_exists_in_xdg_location(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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.mkdir(parents = True)
|
||||
(xdg_config / "config.yaml").touch()
|
||||
|
||||
# Mock platformdirs to return our test directory
|
||||
@@ -75,7 +75,7 @@ class TestDetectInstallationMode:
|
||||
# Verify
|
||||
assert mode == "xdg"
|
||||
|
||||
def test_returns_none_when_no_config_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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)
|
||||
@@ -95,7 +95,7 @@ class TestDetectInstallationMode:
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -103,7 +103,7 @@ class TestGetConfigFilePath:
|
||||
|
||||
assert path == tmp_path / "config.yaml"
|
||||
|
||||
def test_returns_xdg_path_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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(
|
||||
@@ -120,7 +120,7 @@ class TestGetConfigFilePath:
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -128,7 +128,7 @@ class TestGetAdFilesSearchDir:
|
||||
|
||||
assert search_dir == tmp_path
|
||||
|
||||
def test_returns_xdg_config_dir_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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(
|
||||
@@ -146,7 +146,7 @@ class TestGetAdFilesSearchDir:
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -154,7 +154,7 @@ class TestGetDownloadedAdsPath:
|
||||
|
||||
assert ads_path == tmp_path / "downloaded-ads"
|
||||
|
||||
def test_creates_directory_if_not_exists(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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)
|
||||
|
||||
@@ -163,7 +163,7 @@ class TestGetDownloadedAdsPath:
|
||||
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:
|
||||
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(
|
||||
@@ -180,7 +180,7 @@ class TestGetDownloadedAdsPath:
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -188,7 +188,7 @@ class TestGetBrowserProfilePath:
|
||||
|
||||
assert profile_path == tmp_path / ".temp" / "browser-profile"
|
||||
|
||||
def test_returns_xdg_cache_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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(
|
||||
@@ -201,7 +201,7 @@ class TestGetBrowserProfilePath:
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -214,7 +214,7 @@ class TestGetBrowserProfilePath:
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -222,7 +222,7 @@ class TestGetLogFilePath:
|
||||
|
||||
assert log_path == tmp_path / "test.log"
|
||||
|
||||
def test_returns_xdg_state_log_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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(
|
||||
@@ -239,7 +239,7 @@ class TestGetLogFilePath:
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -247,7 +247,7 @@ class TestGetUpdateCheckStatePath:
|
||||
|
||||
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:
|
||||
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(
|
||||
@@ -264,12 +264,12 @@ class TestGetUpdateCheckStatePath:
|
||||
class TestPromptInstallationMode:
|
||||
"""Tests for prompt_installation_mode function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _force_identity_translation(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@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:
|
||||
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)
|
||||
monkeypatch.setattr("sys.stdin", None)
|
||||
@@ -278,7 +278,7 @@ class TestPromptInstallationMode:
|
||||
|
||||
assert mode == "portable"
|
||||
|
||||
def test_returns_portable_for_non_interactive_mode_not_tty(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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()
|
||||
@@ -289,7 +289,7 @@ class TestPromptInstallationMode:
|
||||
|
||||
assert mode == "portable"
|
||||
|
||||
def test_returns_portable_when_user_enters_1(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
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
|
||||
mock_stdin = io.StringIO()
|
||||
@@ -307,7 +307,7 @@ class TestPromptInstallationMode:
|
||||
assert "Choose installation type:" in captured.out
|
||||
assert "[1] Portable" in captured.out
|
||||
|
||||
def test_returns_xdg_when_user_enters_2(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
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()
|
||||
@@ -325,7 +325,7 @@ class TestPromptInstallationMode:
|
||||
assert "Choose installation type:" in captured.out
|
||||
assert "[2] System-wide" in captured.out
|
||||
|
||||
def test_reprompts_on_invalid_input_then_accepts_valid(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
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
|
||||
mock_stdin = io.StringIO()
|
||||
@@ -343,7 +343,7 @@ class TestPromptInstallationMode:
|
||||
captured = capsys.readouterr()
|
||||
assert "Invalid choice" in captured.out
|
||||
|
||||
def test_returns_portable_on_eof_error(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
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
|
||||
mock_stdin = io.StringIO()
|
||||
@@ -351,7 +351,7 @@ class TestPromptInstallationMode:
|
||||
monkeypatch.setattr("sys.stdin", mock_stdin)
|
||||
|
||||
# Mock input raising EOFError
|
||||
def mock_input(_: str) -> str:
|
||||
def mock_input(_:str) -> str:
|
||||
raise EOFError
|
||||
|
||||
monkeypatch.setattr("builtins.input", mock_input)
|
||||
@@ -363,7 +363,7 @@ class TestPromptInstallationMode:
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out.endswith("\n")
|
||||
|
||||
def test_returns_portable_on_keyboard_interrupt(self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
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
|
||||
mock_stdin = io.StringIO()
|
||||
@@ -371,7 +371,7 @@ class TestPromptInstallationMode:
|
||||
monkeypatch.setattr("sys.stdin", mock_stdin)
|
||||
|
||||
# Mock input raising KeyboardInterrupt
|
||||
def mock_input(_: str) -> str:
|
||||
def mock_input(_:str) -> str:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr("builtins.input", mock_input)
|
||||
@@ -387,18 +387,18 @@ class TestPromptInstallationMode:
|
||||
class TestGetBrowserProfilePathWithOverride:
|
||||
"""Tests for get_browser_profile_path config_override parameter."""
|
||||
|
||||
def test_respects_config_override_in_portable_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
custom_path = str(tmp_path / "custom" / "browser")
|
||||
profile_path = xdg_paths.get_browser_profile_path("portable", config_override=custom_path)
|
||||
profile_path = xdg_paths.get_browser_profile_path("portable", config_override = custom_path)
|
||||
|
||||
assert profile_path == Path(custom_path)
|
||||
assert profile_path.exists() # Verify directory was created
|
||||
assert profile_path.is_dir()
|
||||
|
||||
def test_respects_config_override_in_xdg_mode(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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(
|
||||
@@ -407,7 +407,7 @@ class TestGetBrowserProfilePathWithOverride:
|
||||
)
|
||||
|
||||
custom_path = str(tmp_path / "custom" / "browser")
|
||||
profile_path = xdg_paths.get_browser_profile_path("xdg", config_override=custom_path)
|
||||
profile_path = xdg_paths.get_browser_profile_path("xdg", config_override = custom_path)
|
||||
|
||||
assert profile_path == Path(custom_path)
|
||||
# Verify it didn't use XDG cache directory
|
||||
@@ -419,7 +419,7 @@ class TestGetBrowserProfilePathWithOverride:
|
||||
class TestUnicodeHandling:
|
||||
"""Tests for Unicode path handling (NFD vs NFC normalization)."""
|
||||
|
||||
def test_portable_mode_handles_unicode_in_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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.
|
||||
|
||||
This tests the edge case where the current directory contains Unicode
|
||||
@@ -442,7 +442,7 @@ class TestUnicodeHandling:
|
||||
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:
|
||||
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
|
||||
@@ -451,7 +451,7 @@ class TestUnicodeHandling:
|
||||
"""
|
||||
# Create XDG directory with umlaut
|
||||
xdg_base = tmp_path / "Users" / "Müller" / ".config"
|
||||
xdg_base.mkdir(parents=True)
|
||||
xdg_base.mkdir(parents = True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
@@ -465,11 +465,11 @@ class TestUnicodeHandling:
|
||||
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:
|
||||
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)
|
||||
xdg_config.mkdir(parents = True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"platformdirs.user_config_dir",
|
||||
|
||||
Reference in New Issue
Block a user