Files
kleinanzeigen-bot/tests/unit/test_init.py
Jens 7803872422 refactor: extract delete, extend, download workflows and published-ad fetch into focused modules (#1101)
## ℹ️ Description

> **Why one PR?** The four extractions are mechanically identical:
extract a method body into a module function, replace `self.*` with
explicit parameters, update call sites in `run()`. Splitting into 4
sequential PRs would produce repetitive diffs against the same
`__init__.py`, compound rebase friction, and add no independent review
signal — each PR would be reviewed as "same pattern, different
function." Combined, the mechanical nature is visible at a glance and
the 17 files are mostly new modules + focused test files.

Reduce KleinanzeigenBot.__init__.py from 2865 to 2391 lines by
extracting four browser workflow modules with explicit dependencies.

## 📋 Changes Summary

- New `published_ads.py` — `fetch_published_ads()` and
`PublishedAdsFetchIncompleteError`, shared by all workflows
- New `delete_flow.py` — `delete_ads()` orchestrator and `delete_ad()`
single-ad deletion
- New `extend_flow.py` — `extend_ads()` orchestrator and `_extend_ad()`
single-ad extension
- New `download_flow.py` — `download_ads()`, `resolve_download_dir()`,
and `_download_ad_with_resolved_state()`
- Promote `_timeout` and `_navigate_paginated_ad_overview` to public in
WebScrapingMixin
- Restore `PublishedAdsFetchIncompleteError` base class to
KleinanzeigenBotError
- New focused test files: `test_published_ads.py` (17),
`test_delete_flow.py` (13), `test_extend_flow.py` (17),
`test_download_flow.py` (16)
- Removed: `test_json_pagination.py`, `test_extend_command.py`
- Flow modules receive explicit dependencies (`web`, `root_url`,
`config`, `workspace`, `load_ads_func`) — no `do_work(bot)` signatures
- Temporary `_fetch_published_ads` delegator kept on bot (still used by
publish/update)

### ⚙️ Type of Change
- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)
- [x]  New feature (adds new functionality without breaking existing
usage)
- [ ] 💥 Breaking change (changes that might break existing user setups,
scripts, or configurations)


##  Checklist
- [x] I have reviewed my changes to ensure they meet the project's
standards.
- [x] I have tested my changes and ensured that all tests pass (`pdm run
test`).
- [x] I have formatted the code (`pdm run format`).
- [x] I have verified that linting passes (`pdm run lint`).
- [x] I have updated documentation where necessary.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Clarified timeout configuration guidance and examples (use public
timeout API and when to use effective timeout).

* **New Features**
* Added workflows to delete, download (all/new/IDs) and extend ads;
added API-backed published-ads retrieval and safe ID matching.

* **Refactor**
* Command routing moved to workflow modules; unified timeout and
pagination helpers for consistent UI/navigation behavior.

* **Tests**
* Expanded/updated unit tests covering deletion, download, extension,
pagination, timeout and related edge cases.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-12 09:58:56 +02:00

4337 lines
206 KiB
Python

# SPDX-FileCopyrightText: © Jens Bergmann and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
import asyncio, copy, fnmatch, json, logging, os # isort: skip
from collections.abc import Callable, Generator
from contextlib import ExitStack, contextmanager
from pathlib import Path, PureWindowsPath
from typing import Any, Awaitable, Iterator, cast
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from nodriver.core.connection import ProtocolException
from kleinanzeigen_bot import (
LOG,
SUBMISSION_MAX_RETRIES,
KleinanzeigenBot,
LoginDetectionReason,
LoginDetectionResult,
runtime_config,
)
from kleinanzeigen_bot._version import __version__
from kleinanzeigen_bot.model.ad_model import Ad, AdUpdateStrategy
from kleinanzeigen_bot.model.config_model import (
AutoPriceReductionConfig,
Config,
DiagnosticsConfig,
PublishingConfig,
)
from kleinanzeigen_bot.utils import xdg_paths
from kleinanzeigen_bot.utils.exceptions import CategoryResolutionError, PublishSubmissionUncertainError
from kleinanzeigen_bot.utils.web_scraping_mixin import By, Element
@pytest.fixture
def mock_page() -> MagicMock:
"""Provide a mock page object for testing."""
mock = MagicMock()
mock.sleep = AsyncMock()
mock.evaluate = AsyncMock()
mock.click = AsyncMock()
mock.type = AsyncMock()
mock.select = AsyncMock()
mock.wait_for_selector = AsyncMock()
mock.wait_for_navigation = AsyncMock()
mock.wait_for_load_state = AsyncMock()
mock.content = AsyncMock(return_value = "<html></html>")
mock.goto = AsyncMock()
mock.close = AsyncMock()
return mock
@pytest.fixture
def base_ad_config() -> dict[str, Any]:
"""Provide a base ad configuration that can be used across tests."""
return {
"id": None,
"title": "Test Title",
"description": "Test Description",
"type": "OFFER",
"price_type": "FIXED",
"price": 100,
"shipping_type": "SHIPPING",
"shipping_options": [],
"category": "160",
"special_attributes": {},
"sell_directly": False,
"images": [],
"active": True,
"republication_interval": 7,
"created_on": None,
"contact": {"name": "Test User", "zipcode": "12345", "location": "Test City", "street": "", "phone": ""},
}
def remove_fields(config:dict[str, Any], *fields:str) -> dict[str, Any]:
"""Create a new ad configuration with specified fields removed.
Args:
config: The configuration to remove fields from
*fields: Field names to remove
Returns:
A new ad configuration dictionary with specified fields removed
"""
result = copy.deepcopy(config)
for field in fields:
if "." in field:
# Handle nested fields (e.g., "contact.phone")
parts = field.split(".", maxsplit = 1)
current = result
for part in parts[:-1]:
if part in current:
current = current[part]
if parts[-1] in current:
del current[parts[-1]]
elif field in result:
del result[field]
return result
@pytest.fixture
def minimal_ad_config(base_ad_config:dict[str, Any]) -> dict[str, Any]:
"""Provide a minimal ad configuration with only required fields."""
return remove_fields(base_ad_config, "id", "created_on", "shipping_options", "special_attributes", "contact.street", "contact.phone")
@pytest.fixture
def mock_config_setup(test_bot:KleinanzeigenBot, tmp_path:Path) -> Generator[None]:
"""Provide a centralized mock configuration setup for tests.
This fixture mocks load_config and other essential configuration-related methods."""
test_bot.config_file_path = str(tmp_path / "config.yaml")
workspace = xdg_paths.Workspace.for_config(tmp_path / "config.yaml", "kleinanzeigen-bot")
with (
patch("kleinanzeigen_bot.runtime_config.resolve_workspace", return_value = workspace),
patch(
"kleinanzeigen_bot.runtime_config.load_config",
return_value = runtime_config.RuntimeState(config = test_bot.config, categories = {}, timing_collector = None),
),
patch("kleinanzeigen_bot.runtime_config.apply_browser_config"),
patch("kleinanzeigen_bot.runtime_config.configure_file_logging", return_value = None),
patch.object(test_bot, "create_browser_session", new_callable = AsyncMock),
patch.object(test_bot, "login", new_callable = AsyncMock),
patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request,
):
# Mock the web request for published ads
mock_request.return_value = {"content": '{"ads": []}'}
yield
def _login_detection_result(is_logged_in:bool, reason:LoginDetectionReason) -> LoginDetectionResult:
return LoginDetectionResult(is_logged_in = is_logged_in, reason = reason)
class TestKleinanzeigenBotInitialization:
"""Tests for run-path initialization and download plumbing."""
@pytest.mark.asyncio
@pytest.mark.parametrize("command", ["verify", "update-check", "update-content-hash", "publish", "delete", "download"])
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]] = []
workspace = xdg_paths.Workspace.for_config(tmp_path / "config.yaml", "kleinanzeigen-bot")
class DummyUpdateChecker:
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
with (
patch("kleinanzeigen_bot.runtime_config.resolve_workspace", return_value = workspace),
patch(
"kleinanzeigen_bot.runtime_config.load_config",
return_value = runtime_config.RuntimeState(config = test_bot.config, categories = {}, timing_collector = None),
),
patch("kleinanzeigen_bot.runtime_config.configure_file_logging", return_value = None),
patch("kleinanzeigen_bot.runtime_config.apply_browser_config"),
patch.object(test_bot, "load_ads", return_value = []),
patch.object(test_bot, "create_browser_session", new_callable = AsyncMock),
patch.object(test_bot, "login", new_callable = AsyncMock),
patch("kleinanzeigen_bot.download_flow.download_ads", new_callable = AsyncMock),
patch.object(test_bot, "close_browser_session"),
patch("kleinanzeigen_bot.UpdateChecker", DummyUpdateChecker),
):
await test_bot.run(["app", command])
expected_state_path = (tmp_path / "config.yaml").resolve().parent / ".temp" / "update_check_state.json"
assert update_checker_calls == [(test_bot.config, expected_state_path)]
class TestKleinanzeigenBotAuthentication:
"""Tests for login and authentication functionality."""
@pytest.mark.asyncio
async def test_is_logged_in_returns_true_when_logged_in(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that login check returns true when logged in."""
with patch.object(
test_bot,
"web_text_first_available",
new_callable = AsyncMock,
return_value = ("Welcome dummy_user", 0),
):
assert await test_bot.is_logged_in() is True
@pytest.mark.asyncio
async def test_is_logged_in_returns_false_when_not_logged_in(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that login check returns false when not logged in."""
with (
patch.object(
test_bot,
"web_text_first_available",
new_callable = AsyncMock,
side_effect = [("nicht-eingeloggt", 0), ("kein user signal", 0)],
),
patch.object(test_bot, "_has_logged_out_cta", new_callable = AsyncMock, return_value = False),
):
assert await test_bot.is_logged_in() is False
@pytest.mark.asyncio
async def test_has_logged_out_cta_requires_visible_candidate(self, test_bot:KleinanzeigenBot) -> None:
matched_element = MagicMock(spec = Element)
with (
patch.object(test_bot, "web_find_first_available", new_callable = AsyncMock, return_value = (matched_element, 0)),
patch.object(test_bot, "_extract_visible_text", new_callable = AsyncMock, return_value = ""),
):
assert await test_bot._has_logged_out_cta() is False
@pytest.mark.asyncio
async def test_has_logged_out_cta_accepts_visible_candidate(self, test_bot:KleinanzeigenBot) -> None:
matched_element = MagicMock(spec = Element)
with (
patch.object(test_bot, "web_find_first_available", new_callable = AsyncMock, return_value = (matched_element, 0)),
patch.object(test_bot, "_extract_visible_text", new_callable = AsyncMock, return_value = "Einloggen"),
):
assert await test_bot._has_logged_out_cta() is True
@pytest.mark.asyncio
async def test_is_logged_in_uses_selector_group_timeout_key(self, test_bot:KleinanzeigenBot) -> None:
"""Verify login detection uses selector-group lookup with login_detection timeout key."""
with patch.object(
test_bot,
"web_text_first_available",
new_callable = AsyncMock,
side_effect = [TimeoutError(), ("Welcome dummy_user", 0)],
) as group_text:
assert await test_bot.is_logged_in() is True
group_text.assert_awaited()
assert any(call.kwargs.get("timeout") == test_bot.timeout("login_detection") for call in group_text.await_args_list)
@pytest.mark.asyncio
async def test_is_logged_in_runs_full_selector_group_before_cta_precheck(self, test_bot:KleinanzeigenBot) -> None:
"""Quick CTA checks must not short-circuit before full logged-in selector checks."""
with patch.object(
test_bot,
"web_text_first_available",
new_callable = AsyncMock,
side_effect = [TimeoutError(), ("Welcome dummy_user", 0)],
) as group_text:
assert await test_bot.is_logged_in() is True
group_text.assert_awaited()
assert group_text.await_count >= 1
@pytest.mark.asyncio
async def test_is_logged_in_short_circuits_before_cta_check_when_quick_user_signal_matches(self, test_bot:KleinanzeigenBot) -> None:
"""Logged-in quick pre-check should win even if incidental login links exist elsewhere."""
with patch.object(
test_bot,
"web_text_first_available",
new_callable = AsyncMock,
return_value = ("angemeldet als: dummy_user", 0),
) as group_text:
assert await test_bot.is_logged_in() is True
group_text.assert_awaited()
assert group_text.await_count >= 1
@pytest.mark.asyncio
async def test_get_login_state_prefers_dom_checks(self, test_bot:KleinanzeigenBot) -> None:
with (
patch.object(
test_bot,
"web_text_first_available",
new_callable = AsyncMock,
return_value = ("Welcome dummy_user", 0),
) as web_text,
):
result = await test_bot.get_login_state()
assert result.is_logged_in is True
assert result.reason == LoginDetectionReason.USER_INFO_MATCH
web_text.assert_awaited_once()
def test_current_page_url_strips_query_and_fragment(self, test_bot:KleinanzeigenBot) -> None:
page = MagicMock()
page.url = "https://login.kleinanzeigen.de/u/login/password?state=secret&code=abc#frag"
test_bot.page = page
assert test_bot._current_page_url() == "https://login.kleinanzeigen.de/u/login/password"
def test_is_valid_post_auth0_destination_filters_invalid_urls(self, test_bot:KleinanzeigenBot) -> None:
assert test_bot._is_valid_post_auth0_destination("https://www.kleinanzeigen.de/") is True
assert test_bot._is_valid_post_auth0_destination("https://www.kleinanzeigen.de/m-meine-anzeigen.html") is True
assert test_bot._is_valid_post_auth0_destination("https://foo.kleinanzeigen.de/") is True
assert test_bot._is_valid_post_auth0_destination("unknown") is False
assert test_bot._is_valid_post_auth0_destination("about:blank") is False
assert test_bot._is_valid_post_auth0_destination("https://evilkleinanzeigen.de/") is False
assert test_bot._is_valid_post_auth0_destination("https://kleinanzeigen.de.evil.com/") is False
assert test_bot._is_valid_post_auth0_destination("https://login.kleinanzeigen.de/u/login/password") is False
assert test_bot._is_valid_post_auth0_destination("https://www.kleinanzeigen.de/login-error-500") is False
@pytest.mark.asyncio
async def test_get_login_state_returns_selector_timeout_when_dom_checks_are_inconclusive(self, test_bot:KleinanzeigenBot) -> None:
with (
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError()]) as web_text,
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()) as cta_find,
):
result = await test_bot.get_login_state()
assert result.is_logged_in is False
assert result.reason == LoginDetectionReason.SELECTOR_TIMEOUT
assert web_text.await_count == 2
assert cta_find.await_count == 1
@pytest.mark.asyncio
async def test_get_login_state_returns_logged_out_when_cta_detected(self, test_bot:KleinanzeigenBot) -> None:
matched_element = MagicMock(spec = Element)
with (
patch.object(
test_bot,
"web_text_first_available",
side_effect = [TimeoutError(), TimeoutError()],
) as web_text,
patch.object(test_bot, "web_find_first_available", new_callable = AsyncMock, return_value = (matched_element, 0)),
patch.object(test_bot, "_extract_visible_text", new_callable = AsyncMock, return_value = "Hier einloggen"),
):
result = await test_bot.get_login_state()
assert result.is_logged_in is False
assert result.reason == LoginDetectionReason.CTA_MATCH
assert web_text.await_count == 2
@pytest.mark.asyncio
async def test_get_login_state_checks_logged_out_cta_only_once(self, test_bot:KleinanzeigenBot) -> None:
with (
patch.object(test_bot, "_has_logged_in_marker", new_callable = AsyncMock, return_value = False),
patch.object(test_bot, "_has_logged_out_cta", new_callable = AsyncMock, return_value = False) as cta_check,
):
result = await test_bot.get_login_state(capture_diagnostics = False)
assert result.is_logged_in is False
assert result.reason == LoginDetectionReason.SELECTOR_TIMEOUT
cta_check.assert_awaited_once()
@pytest.mark.asyncio
async def test_get_login_state_selector_timeout_captures_diagnostics_when_enabled(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"login_detection": True}, "output_dir": str(tmp_path)})
page = MagicMock()
page.save_screenshot = AsyncMock()
page.get_content = AsyncMock(return_value = "<html></html>")
test_bot.page = page
with (
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError(), TimeoutError(), TimeoutError()]),
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()),
):
result = await test_bot.get_login_state()
assert result.is_logged_in is False
assert result.reason == LoginDetectionReason.SELECTOR_TIMEOUT
page.save_screenshot.assert_awaited_once()
page.get_content.assert_awaited_once()
@pytest.mark.asyncio
async def test_get_login_state_selector_timeout_does_not_capture_diagnostics_when_disabled(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"login_detection": False}, "output_dir": str(tmp_path)})
page = MagicMock()
page.save_screenshot = AsyncMock()
page.get_content = AsyncMock(return_value = "<html></html>")
test_bot.page = page
with (
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError(), TimeoutError(), TimeoutError()]),
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()),
):
result = await test_bot.get_login_state()
assert result.is_logged_in is False
assert result.reason == LoginDetectionReason.SELECTOR_TIMEOUT
page.save_screenshot.assert_not_called()
page.get_content.assert_not_called()
@pytest.mark.asyncio
async def test_get_login_state_selector_timeout_pauses_for_inspection_when_enabled_and_interactive(
self, test_bot:KleinanzeigenBot, tmp_path:Path
) -> None:
test_bot.config.diagnostics = DiagnosticsConfig.model_validate(
{"capture_on": {"login_detection": True}, "pause_on_login_detection_failure": True, "output_dir": str(tmp_path)}
)
page = MagicMock()
page.save_screenshot = AsyncMock()
page.get_content = AsyncMock(return_value = "<html></html>")
test_bot.page = page
stdin_mock = MagicMock()
stdin_mock.isatty.return_value = True
with (
patch.object(
test_bot,
"web_text_first_available",
side_effect = [
TimeoutError(),
TimeoutError(),
TimeoutError(),
TimeoutError(),
TimeoutError(),
TimeoutError(),
TimeoutError(),
TimeoutError(),
],
),
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()),
patch("kleinanzeigen_bot.sys.stdin", stdin_mock),
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
):
first_result = await test_bot.get_login_state()
assert first_result.is_logged_in is False
assert first_result.reason == LoginDetectionReason.SELECTOR_TIMEOUT
# Call twice to ensure the capture/pause guard triggers only once per process.
second_result = await test_bot.get_login_state()
assert second_result.is_logged_in is False
assert second_result.reason == LoginDetectionReason.SELECTOR_TIMEOUT
page.save_screenshot.assert_awaited_once()
page.get_content.assert_awaited_once()
mock_ainput.assert_awaited_once()
@pytest.mark.asyncio
async def test_get_login_state_selector_timeout_does_not_pause_when_non_interactive(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
test_bot.config.diagnostics = DiagnosticsConfig.model_validate(
{"capture_on": {"login_detection": True}, "pause_on_login_detection_failure": True, "output_dir": str(tmp_path)}
)
page = MagicMock()
page.save_screenshot = AsyncMock()
page.get_content = AsyncMock(return_value = "<html></html>")
test_bot.page = page
stdin_mock = MagicMock()
stdin_mock.isatty.return_value = False
with (
patch.object(test_bot, "web_text_first_available", side_effect = [TimeoutError(), TimeoutError(), TimeoutError(), TimeoutError()]),
patch.object(test_bot, "web_find_first_available", side_effect = TimeoutError()),
patch("kleinanzeigen_bot.sys.stdin", stdin_mock),
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
):
result = await test_bot.get_login_state()
assert result.is_logged_in is False
assert result.reason == LoginDetectionReason.SELECTOR_TIMEOUT
mock_ainput.assert_not_called()
@pytest.mark.asyncio
async def test_login_flow_completes_successfully(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that normal login flow completes successfully."""
with (
patch.object(test_bot, "web_open") as mock_open,
patch.object(
test_bot,
"get_login_state",
new_callable = AsyncMock,
side_effect = [
_login_detection_result(False, LoginDetectionReason.CTA_MATCH),
_login_detection_result(True, LoginDetectionReason.USER_INFO_MATCH),
],
) as mock_logged_in,
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock),
patch.object(test_bot, "fill_login_data_and_send", new_callable = AsyncMock) as mock_fill,
patch.object(test_bot, "handle_after_login_logic", new_callable = AsyncMock) as mock_after_login,
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
):
await test_bot.login()
opened_urls = [call.args[0] for call in mock_open.call_args_list]
assert any(url.startswith(test_bot.root_url) for url in opened_urls)
assert any(url.endswith("/m-einloggen-sso.html") for url in opened_urls)
mock_logged_in.assert_awaited()
mock_fill.assert_awaited_once()
mock_after_login.assert_awaited_once()
@pytest.mark.asyncio
async def test_login_flow_returns_early_when_already_logged_in(self, test_bot:KleinanzeigenBot) -> None:
"""Login should return early when state is already LOGGED_IN."""
with (
patch.object(test_bot, "web_open") as mock_open,
patch.object(
test_bot,
"get_login_state",
new_callable = AsyncMock,
return_value = _login_detection_result(True, LoginDetectionReason.USER_INFO_MATCH),
) as mock_state,
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock),
patch.object(test_bot, "fill_login_data_and_send", new_callable = AsyncMock) as mock_fill,
patch.object(test_bot, "handle_after_login_logic", new_callable = AsyncMock) as mock_after_login,
):
await test_bot.login()
mock_open.assert_awaited_once()
assert mock_open.await_args is not None
assert mock_open.await_args.args[0] == test_bot.root_url
mock_state.assert_awaited_once()
mock_fill.assert_not_called()
mock_after_login.assert_not_called()
@pytest.mark.asyncio
async def test_login_flow_raises_when_state_remains_inconclusive(self, test_bot:KleinanzeigenBot) -> None:
"""Post-login inconclusive state should fail fast with diagnostics."""
with (
patch.object(test_bot, "web_open"),
patch.object(
test_bot,
"get_login_state",
new_callable = AsyncMock,
side_effect = [
_login_detection_result(False, LoginDetectionReason.CTA_MATCH),
_login_detection_result(False, LoginDetectionReason.SELECTOR_TIMEOUT),
],
) as mock_state,
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock),
patch.object(test_bot, "fill_login_data_and_send", new_callable = AsyncMock),
patch.object(test_bot, "handle_after_login_logic", new_callable = AsyncMock),
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
patch.object(test_bot, "_capture_login_detection_diagnostics_if_enabled", new_callable = AsyncMock) as mock_diagnostics,
):
with pytest.raises(AssertionError, match = "reason=SELECTOR_TIMEOUT"):
await test_bot.login()
mock_diagnostics.assert_awaited_once()
assert mock_diagnostics.await_args is not None
assert mock_diagnostics.await_args.kwargs.get("base_prefix") == "login_detection_selector_timeout"
mock_state.assert_awaited()
assert mock_state.await_count == 2
assert mock_state.await_args_list[1].kwargs.get("capture_diagnostics") is False
@pytest.mark.asyncio
async def test_capture_login_detection_diagnostics_honors_capture_log_copy(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
test_bot.config.diagnostics = DiagnosticsConfig.model_validate(
{
"capture_on": {"login_detection": True},
"capture_log_copy": True,
"output_dir": str(tmp_path),
}
)
test_bot.log_file_path = str(tmp_path / "bot.log")
test_bot._login_detection_diagnostics_captured = False
page = MagicMock()
test_bot.page = page
with patch("kleinanzeigen_bot.utils.diagnostics.capture_diagnostics", new_callable = AsyncMock) as mock_capture:
await test_bot._capture_login_detection_diagnostics_if_enabled(base_prefix = "login_detection_test")
mock_capture.assert_awaited_once()
assert mock_capture.await_args is not None
assert mock_capture.await_args.kwargs.get("base_prefix") == "login_detection_test"
assert mock_capture.await_args.kwargs.get("copy_log") is True
assert mock_capture.await_args.kwargs.get("log_file_path") == test_bot.log_file_path
assert test_bot._login_detection_diagnostics_captured is True
@pytest.mark.asyncio
async def test_capture_login_detection_diagnostics_with_no_page_still_invokes_capture(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
test_bot.config.diagnostics = DiagnosticsConfig.model_validate(
{
"capture_on": {"login_detection": True},
"capture_log_copy": True,
"output_dir": str(tmp_path),
}
)
test_bot.log_file_path = str(tmp_path / "bot.log")
test_bot._login_detection_diagnostics_captured = False
test_bot.page = cast(Any, None)
with patch("kleinanzeigen_bot.utils.diagnostics.capture_diagnostics", new_callable = AsyncMock) as mock_capture:
await test_bot._capture_login_detection_diagnostics_if_enabled(base_prefix = "login_detection_test")
mock_capture.assert_awaited_once()
assert mock_capture.await_args is not None
assert mock_capture.await_args.kwargs.get("page") is None
assert mock_capture.await_args.kwargs.get("copy_log") is True
assert mock_capture.await_args.kwargs.get("log_file_path") == test_bot.log_file_path
assert test_bot._login_detection_diagnostics_captured is True
@pytest.mark.asyncio
async def test_capture_login_detection_diagnostics_does_not_mark_captured_on_output_dir_error(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
test_bot.config.diagnostics = DiagnosticsConfig.model_validate(
{
"capture_on": {"login_detection": True},
"capture_log_copy": True,
"output_dir": str(tmp_path),
}
)
test_bot._login_detection_diagnostics_captured = False
test_bot.page = MagicMock()
with (
patch.object(test_bot, "_diagnostics_output_dir", side_effect = RuntimeError("dir error")),
patch("kleinanzeigen_bot.utils.diagnostics.capture_diagnostics", new_callable = AsyncMock) as mock_capture,
):
await test_bot._capture_login_detection_diagnostics_if_enabled(base_prefix = "login_detection_test")
mock_capture.assert_not_awaited()
assert test_bot._login_detection_diagnostics_captured is False
@pytest.mark.asyncio
async def test_capture_login_detection_diagnostics_does_not_mark_captured_on_capture_error(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
test_bot.config.diagnostics = DiagnosticsConfig.model_validate(
{
"capture_on": {"login_detection": True},
"capture_log_copy": True,
"output_dir": str(tmp_path),
}
)
test_bot._login_detection_diagnostics_captured = False
test_bot.page = MagicMock()
with patch(
"kleinanzeigen_bot.utils.diagnostics.capture_diagnostics",
new_callable = AsyncMock,
side_effect = RuntimeError("capture error"),
):
await test_bot._capture_login_detection_diagnostics_if_enabled(base_prefix = "login_detection_test")
assert test_bot._login_detection_diagnostics_captured is False
def test_login_detection_result_accepts_logged_in_user_info_match(self) -> None:
result = LoginDetectionResult(is_logged_in = True, reason = LoginDetectionReason.USER_INFO_MATCH)
assert result.is_logged_in is True
assert result.reason == LoginDetectionReason.USER_INFO_MATCH
@pytest.mark.parametrize("reason", [LoginDetectionReason.CTA_MATCH, LoginDetectionReason.SELECTOR_TIMEOUT])
def test_login_detection_result_rejects_invalid_logged_in_reason(self, reason:LoginDetectionReason) -> None:
with pytest.raises(ValueError, match = "USER_INFO_MATCH"):
LoginDetectionResult(is_logged_in = True, reason = reason)
def test_login_detection_result_rejects_invalid_logged_out_user_info_match(self) -> None:
with pytest.raises(ValueError, match = "CTA_MATCH or SELECTOR_TIMEOUT"):
LoginDetectionResult(is_logged_in = False, reason = LoginDetectionReason.USER_INFO_MATCH)
def test_login_detection_result_rejects_non_bool_is_logged_in(self) -> None:
with pytest.raises(TypeError, match = "is_logged_in must be a bool"):
LoginDetectionResult(is_logged_in = "yes", reason = LoginDetectionReason.CTA_MATCH) # type: ignore[arg-type]
def test_login_detection_result_rejects_non_enum_reason(self) -> None:
with pytest.raises(TypeError, match = "reason must be a LoginDetectionReason"):
LoginDetectionResult(is_logged_in = False, reason = "bogus") # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_login_flow_raises_when_sso_navigation_times_out(self, test_bot:KleinanzeigenBot) -> None:
"""SSO navigation timeout should trigger diagnostics and re-raise."""
with (
patch.object(test_bot, "web_open", new_callable = AsyncMock, side_effect = [None, TimeoutError("sso timeout")]),
patch.object(
test_bot,
"get_login_state",
new_callable = AsyncMock,
return_value = _login_detection_result(False, LoginDetectionReason.CTA_MATCH),
) as mock_state,
patch.object(test_bot, "_click_gdpr_banner", new_callable = AsyncMock),
patch.object(test_bot, "_capture_login_detection_diagnostics_if_enabled", new_callable = AsyncMock) as mock_diagnostics,
):
with pytest.raises(TimeoutError, match = "sso timeout"):
await test_bot.login()
mock_diagnostics.assert_awaited_once()
assert mock_diagnostics.await_args is not None
assert mock_diagnostics.await_args.kwargs.get("base_prefix") == "login_detection_sso_navigation_timeout"
mock_state.assert_awaited_once()
@pytest.mark.asyncio
async def test_check_and_wait_for_captcha(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that captcha detection works correctly."""
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
):
# Test case 1: Captcha found
mock_probe.return_value = MagicMock()
mock_ainput.return_value = ""
await test_bot.check_and_wait_for_captcha(is_login_page = True)
mock_ainput.assert_awaited_once()
# Test case 2: No captcha
mock_probe.return_value = None
mock_ainput.reset_mock()
await test_bot.check_and_wait_for_captcha(is_login_page = True)
mock_ainput.assert_not_awaited()
@pytest.mark.asyncio
async def test_fill_login_data_and_send(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that login form filling works correctly."""
with (
patch.object(test_bot, "_wait_for_auth0_login_context", new_callable = AsyncMock) as wait_context,
patch.object(test_bot, "_wait_for_auth0_password_step", new_callable = AsyncMock) as wait_password,
patch.object(test_bot, "_wait_for_post_auth0_submit_transition", new_callable = AsyncMock) as wait_transition,
patch.object(test_bot, "web_input") as mock_input,
patch.object(test_bot, "web_click") as mock_click,
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock) as mock_captcha,
):
await test_bot.fill_login_data_and_send()
wait_context.assert_awaited_once()
wait_password.assert_awaited_once()
wait_transition.assert_awaited_once()
mock_captcha.assert_awaited_once_with(is_login_page = True)
assert mock_input.call_args_list == [
call(By.ID, "username", test_bot.config.login.username),
call(By.CSS_SELECTOR, "input[type='password']", test_bot.config.login.password),
]
assert mock_click.call_args_list == [
call(By.CSS_SELECTOR, "button[type='submit']"),
call(By.CSS_SELECTOR, "button[type='submit']"),
]
@pytest.mark.asyncio
async def test_fill_login_data_and_send_fails_when_password_step_missing(self, test_bot:KleinanzeigenBot) -> None:
"""Missing Auth0 password step should fail fast."""
with (
patch.object(test_bot, "_wait_for_auth0_login_context", new_callable = AsyncMock),
patch.object(test_bot, "_wait_for_auth0_password_step", new_callable = AsyncMock, side_effect = AssertionError("missing password")),
patch.object(test_bot, "web_input") as mock_input,
patch.object(test_bot, "web_click") as mock_click,
):
with pytest.raises(AssertionError, match = "missing password"):
await test_bot.fill_login_data_and_send()
assert mock_input.call_count == 1
assert mock_click.call_count == 1
@pytest.mark.asyncio
async def test_wait_for_post_auth0_submit_transition_url_branch(self, test_bot:KleinanzeigenBot) -> None:
"""URL transition success should return without fallback checks."""
with (
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True) as mock_wait,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
):
await test_bot._wait_for_post_auth0_submit_transition()
mock_wait.assert_awaited_once()
mock_sleep.assert_not_called()
@pytest.mark.asyncio
async def test_wait_for_post_auth0_submit_transition_dom_fallback_branch(self, test_bot:KleinanzeigenBot) -> None:
"""DOM fallback should run when URL transition is inconclusive."""
with (
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]) as mock_wait,
patch.object(test_bot, "is_logged_in", new_callable = AsyncMock, return_value = True) as mock_is_logged_in,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
):
await test_bot._wait_for_post_auth0_submit_transition()
mock_wait.assert_awaited_once()
mock_is_logged_in.assert_awaited_once()
mock_sleep.assert_not_called()
@pytest.mark.asyncio
async def test_wait_for_post_auth0_submit_transition_sleep_fallback_branch(self, test_bot:KleinanzeigenBot) -> None:
"""Sleep fallback should run when bounded login check times out."""
with (
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = [TimeoutError()]) as mock_wait,
patch.object(test_bot, "is_logged_in", new_callable = AsyncMock, side_effect = asyncio.TimeoutError) as mock_is_logged_in,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
):
with pytest.raises(TimeoutError, match = "Auth0 post-submit verification remained inconclusive"):
await test_bot._wait_for_post_auth0_submit_transition()
mock_wait.assert_awaited_once()
assert mock_is_logged_in.await_count == 2
mock_sleep.assert_awaited_once()
assert mock_sleep.await_args is not None
sleep_kwargs = cast(Any, mock_sleep.await_args).kwargs
assert sleep_kwargs["min_ms"] < sleep_kwargs["max_ms"]
@pytest.mark.asyncio
async def test_click_gdpr_banner_uses_quick_dom_timeout_and_clicks_found_element(self, test_bot:KleinanzeigenBot) -> None:
mock_element = AsyncMock()
with (
patch.object(test_bot, "timeout", return_value = 1.25) as mock_timeout,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = mock_element) as mock_probe,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
):
await test_bot._click_gdpr_banner()
mock_timeout.assert_called_once_with("quick_dom")
mock_probe.assert_awaited_once()
assert mock_probe.await_args is not None
assert mock_probe.await_args.args == (By.ID, "gdpr-banner-accept")
mock_element.click.assert_awaited_once()
mock_sleep.assert_awaited_once()
@pytest.mark.asyncio
async def test_click_gdpr_banner_does_nothing_when_banner_absent(self, test_bot:KleinanzeigenBot) -> None:
with (
patch.object(test_bot, "timeout", return_value = 1.25),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None) as mock_probe,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
):
await test_bot._click_gdpr_banner()
mock_probe.assert_awaited_once()
mock_sleep.assert_not_awaited()
@pytest.mark.asyncio
async def test_dismiss_consent_banner_clicks_when_present(self, test_bot:KleinanzeigenBot) -> None:
mock_element = AsyncMock()
with (
patch.object(test_bot, "timeout", return_value = 2.0) as mock_timeout,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = mock_element) as mock_probe,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
):
await test_bot._dismiss_consent_banner()
mock_timeout.assert_called_once_with("quick_dom")
mock_probe.assert_awaited_once()
assert mock_probe.await_args is not None
assert mock_probe.await_args.args == (By.ID, "gdpr-banner-accept")
mock_element.click.assert_awaited_once()
mock_sleep.assert_awaited_once()
@pytest.mark.asyncio
async def test_dismiss_consent_banner_does_nothing_when_absent(self, test_bot:KleinanzeigenBot) -> None:
with (
patch.object(test_bot, "timeout", return_value = 2.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None) as mock_probe,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
):
await test_bot._dismiss_consent_banner()
mock_probe.assert_awaited_once()
mock_sleep.assert_not_awaited()
@pytest.mark.asyncio
async def test_check_sms_verification_prompts_user_when_detected(self, test_bot:KleinanzeigenBot) -> None:
mock_element = MagicMock()
with (
patch.object(test_bot, "timeout", return_value = 3.0) as mock_timeout,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = mock_element) as mock_probe,
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
):
await test_bot._check_sms_verification()
mock_timeout.assert_called_once_with("sms_verification")
mock_probe.assert_awaited_once()
assert mock_probe.await_args is not None
assert mock_probe.await_args.args == (By.TEXT, "Wir haben dir gerade einen 6-stelligen Code für die Telefonnummer")
mock_ainput.assert_awaited_once()
@pytest.mark.asyncio
async def test_check_sms_verification_returns_silently_when_absent(self, test_bot:KleinanzeigenBot) -> None:
with (
patch.object(test_bot, "timeout", return_value = 3.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None) as mock_probe,
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
):
await test_bot._check_sms_verification()
mock_probe.assert_awaited_once()
mock_ainput.assert_not_awaited()
@pytest.mark.asyncio
async def test_check_email_verification_prompts_user_when_detected(self, test_bot:KleinanzeigenBot) -> None:
mock_element = MagicMock()
with (
patch.object(test_bot, "timeout", return_value = 4.0) as mock_timeout,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = mock_element) as mock_probe,
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
):
await test_bot._check_email_verification()
mock_timeout.assert_called_once_with("email_verification")
mock_probe.assert_awaited_once()
assert mock_probe.await_args is not None
assert mock_probe.await_args.args == (By.TEXT, "Um dein Konto zu schützen haben wir dir eine E-Mail geschickt")
mock_ainput.assert_awaited_once()
@pytest.mark.asyncio
async def test_check_email_verification_returns_silently_when_absent(self, test_bot:KleinanzeigenBot) -> None:
with (
patch.object(test_bot, "timeout", return_value = 4.0),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None) as mock_probe,
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput,
):
await test_bot._check_email_verification()
mock_probe.assert_awaited_once()
mock_ainput.assert_not_awaited()
class TestKleinanzeigenBotDiagnostics:
@pytest.fixture
def diagnostics_ad_config(self) -> dict[str, Any]:
return {
"active": True,
"type": "OFFER",
"title": "Test ad title",
"description": "Test description",
"category": "161/176/sonstige",
"price_type": "NEGOTIABLE",
"shipping_type": "PICKUP",
"sell_directly": False,
"contact": {
"name": "Tester",
"zipcode": "12345",
},
"republication_interval": 7,
}
@pytest.mark.unit
@pytest.mark.asyncio
async def test_publish_ads_captures_diagnostics_on_failures(
self,
test_bot:KleinanzeigenBot,
tmp_path:Path,
diagnostics_ad_config:dict[str, Any],
) -> None:
"""Ensure publish failures capture diagnostics artifacts."""
log_file_path = tmp_path / "test.log"
log_file_path.write_text("Test log content\n", encoding = "utf-8")
test_bot.log_file_path = str(log_file_path)
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"publish": True}, "output_dir": str(tmp_path)})
page = MagicMock()
page.save_screenshot = AsyncMock()
page.get_content = AsyncMock(return_value = "<html></html>")
page.sleep = AsyncMock()
page.url = "https://example.com/fail"
test_bot.page = page
ad_cfg = Ad.model_validate(diagnostics_ad_config)
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
ad_file = str(tmp_path / "ad_000001_Test.yml")
ads_response = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}
with (
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response),
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
expected_retries = SUBMISSION_MAX_RETRIES
assert page.save_screenshot.await_count == expected_retries
assert page.get_content.await_count == expected_retries
entries = os.listdir(tmp_path)
html_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.html")]
json_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.json")]
assert len(html_files) == expected_retries
assert len(json_files) == expected_retries
@pytest.mark.unit
@pytest.mark.asyncio
async def test_publish_ads_captures_log_copy_when_enabled(
self,
test_bot:KleinanzeigenBot,
tmp_path:Path,
diagnostics_ad_config:dict[str, Any],
) -> None:
"""Ensure publish failures copy log file when capture_log_copy is enabled."""
log_file_path = tmp_path / "test.log"
log_file_path.write_text("Test log content\n", encoding = "utf-8")
test_bot.log_file_path = str(log_file_path)
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"publish": True}, "capture_log_copy": True, "output_dir": str(tmp_path)})
page = MagicMock()
page.save_screenshot = AsyncMock()
page.get_content = AsyncMock(return_value = "<html></html>")
page.sleep = AsyncMock()
page.url = "https://example.com/fail"
test_bot.page = page
ad_cfg = Ad.model_validate(diagnostics_ad_config)
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
ad_file = str(tmp_path / "ad_000001_Test.yml")
ads_response = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}
with (
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response),
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
entries = os.listdir(tmp_path)
log_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.log")]
assert len(log_files) == SUBMISSION_MAX_RETRIES
@pytest.mark.unit
@pytest.mark.asyncio
async def test_publish_ads_does_not_capture_diagnostics_when_disabled(
self,
test_bot:KleinanzeigenBot,
tmp_path:Path,
diagnostics_ad_config:dict[str, Any],
) -> None:
"""Ensure diagnostics are not captured when disabled."""
test_bot.config.diagnostics = DiagnosticsConfig.model_validate({"capture_on": {"publish": False}, "output_dir": str(tmp_path)})
page = MagicMock()
page.save_screenshot = AsyncMock()
page.get_content = AsyncMock(return_value = "<html></html>")
page.sleep = AsyncMock()
page.url = "https://example.com/fail"
test_bot.page = page
ad_cfg = Ad.model_validate(diagnostics_ad_config)
ad_cfg_orig = copy.deepcopy(diagnostics_ad_config)
ad_file = str(tmp_path / "ad_000001_Test.yml")
with (
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps({"ads": []})}),
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
page.save_screenshot.assert_not_called()
page.get_content.assert_not_called()
entries = os.listdir(tmp_path)
html_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.html")]
json_files = [name for name in entries if fnmatch.fnmatch(name, "publish_error_*_attempt*_ad_000001_Test.json")]
assert not html_files
assert not json_files
class TestKleinanzeigenBotBasics:
"""Basic tests for KleinanzeigenBot."""
def test_get_version(self, test_bot:KleinanzeigenBot) -> None:
"""Test version retrieval."""
assert test_bot.get_version() == __version__
@pytest.mark.asyncio
async def test_publish_ads_triggers_publish_and_cleanup(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""Simulate publish job wiring without hitting the live site."""
test_bot.page = mock_page
test_bot.config.publishing.delete_old_ads = "AFTER_PUBLISH"
test_bot.keep_old_ads = False
payload:dict[str, list[Any]] = {"ads": []}
ad_cfgs:list[tuple[str, Ad, dict[str, Any]]] = [("ad.yaml", Ad.model_validate(base_ad_config), {})]
with (
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = {"content": json.dumps(payload)}) as web_request_mock,
patch.object(test_bot, "publish_ad", new_callable = AsyncMock) as publish_ad_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True) as web_await_mock,
patch("kleinanzeigen_bot.delete_flow.delete_ad", new_callable = AsyncMock) as delete_ad_mock,
):
await test_bot.publish_ads(ad_cfgs)
# web_request is called once for initial published-ads snapshot
expected_url = f"{test_bot.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT&pageNum=1"
web_request_mock.assert_awaited_once_with(expected_url)
publish_ad_mock.assert_awaited_once_with("ad.yaml", ad_cfgs[0][1], {}, [], AdUpdateStrategy.REPLACE)
web_await_mock.assert_awaited_once()
delete_ad_mock.assert_awaited_once_with(
web = test_bot, root_url = test_bot.root_url,
ad_cfg = ad_cfgs[0][1],
published_ads_list = [],
delete_old_ads_by_title = False,
)
@pytest.mark.asyncio
async def test_publish_ads_uses_millisecond_retry_delay_on_retryable_failure(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""Retry branch should sleep with explicit millisecond delay and reset price-reduction mutations."""
test_bot.page = mock_page
test_bot.keep_old_ads = True
ad_cfg = Ad.model_validate(base_ad_config | {"price": 100, "price_reduction_count": 0, "repost_count": 1})
ad_cfg_orig = copy.deepcopy(base_ad_config)
ad_file = "ad.yaml"
ads_response = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}
seen_prices:list[tuple[int | None, int | None]] = []
async def publish_side_effect(
_ad_file:str,
candidate_cfg:Ad,
_candidate_orig:dict[str, Any],
_published_ads:list[dict[str, Any]],
_mode:AdUpdateStrategy,
) -> None:
seen_prices.append((candidate_cfg.price, candidate_cfg.price_reduction_count))
if len(seen_prices) == 1:
# Simulate in-memory mutation done by apply_auto_price_reduction before a failed attempt.
candidate_cfg.price = 90
candidate_cfg.price_reduction_count = 1
raise TimeoutError("transient")
with (
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response),
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = publish_side_effect) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
assert publish_mock.await_count == 2
assert seen_prices == [(100, 0), (100, 0)]
sleep_mock.assert_awaited_once_with(2_000)
@pytest.mark.asyncio
async def test_publish_ads_does_not_retry_when_submission_state_is_uncertain(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""Post-submit uncertainty must fail closed and skip retries."""
test_bot.page = mock_page
test_bot.keep_old_ads = True
ad_cfg = Ad.model_validate(base_ad_config)
ad_cfg_orig = copy.deepcopy(base_ad_config)
ad_file = "ad.yaml"
with (
patch.object(
test_bot,
"web_request",
new_callable = AsyncMock,
return_value = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})},
),
patch.object(
test_bot,
"publish_ad",
new_callable = AsyncMock,
side_effect = PublishSubmissionUncertainError("submission may have succeeded before failure"),
) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
):
await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)])
assert publish_mock.await_count == 1
sleep_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_publish_ads_does_not_retry_on_category_resolution_error(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""CategoryResolutionError is deterministic configuration failure -> no retry, fail fast."""
test_bot.page = mock_page
test_bot.keep_old_ads = True
ad_cfg = Ad.model_validate(base_ad_config)
ad_cfg_orig = copy.deepcopy(base_ad_config)
with (
patch.object(
test_bot,
"web_request",
new_callable = AsyncMock,
return_value = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})},
),
patch.object(
test_bot,
"publish_ad",
new_callable = AsyncMock,
side_effect = CategoryResolutionError("no suggestion matched"),
) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
):
await test_bot.publish_ads([("ad.yaml", ad_cfg, ad_cfg_orig)])
assert publish_mock.await_count == 1
sleep_mock.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("mode", "expected_path"),
[
(AdUpdateStrategy.REPLACE, "/p-anzeige-aufgeben-schritt2.html"),
(AdUpdateStrategy.MODIFY, "/p-anzeige-bearbeiten.html?adId=12345"),
],
ids = ["replace", "modify"],
)
async def test_publish_ad_keeps_pre_submit_timeouts_retryable(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mode:AdUpdateStrategy,
expected_path:str,
) -> None:
"""Timeouts before submit boundary should remain plain retryable failures and force reload."""
ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"})
ad_cfg_orig = copy.deepcopy(base_ad_config)
expected_url = f"{test_bot.root_url}{expected_path}"
test_bot.keep_old_ads = True
with (
patch.object(test_bot, "web_open", new_callable = AsyncMock) as web_open_mock,
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock, side_effect = TimeoutError("image upload timeout")),
pytest.raises(TimeoutError, match = "image upload timeout"),
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], mode)
web_open_mock.assert_awaited_once_with(expected_url, reload_if_already_open = True)
@staticmethod
def _build_publish_ad_cfg(base_ad_config:dict[str, Any]) -> tuple[Ad, dict[str, Any]]:
"""Build ad config and original dict for publish_ad tests."""
ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"})
ad_cfg_orig = copy.deepcopy(base_ad_config)
return ad_cfg, ad_cfg_orig
@contextmanager
def _mock_post_submit_dependencies(
self,
test_bot:KleinanzeigenBot,
mock_page:MagicMock,
*,
web_await_side_effect:BaseException | None = None,
redirect_recovery_return:int | None = None,
redirect_recovery_side_effect:BaseException | None = None,
include_success_mocks:bool = False,
) -> Iterator[None]:
"""Mock all post-submit publish_ad dependencies for confirmation fallback tests.
Parameters
----------
web_await_side_effect: Exception to raise from web_await (simulates confirmation timeout).
redirect_recovery_return: Return value for _try_recover_ad_id_from_redirect.
redirect_recovery_side_effect: Exception to raise from _try_recover_ad_id_from_redirect.
include_success_mocks: If True, also mock dicts.save_dict (for success-path tests).
"""
test_bot.page = mock_page
common_patches:list[Any] = [
patch.object(test_bot, "web_open", new_callable = AsyncMock),
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_special_attributes", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_contact_fields", new_callable = AsyncMock),
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock),
patch.object(test_bot, "web_input", new_callable = AsyncMock),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
patch.object(test_bot, "web_execute", new_callable = AsyncMock),
patch.object(test_bot, "web_find", new_callable = AsyncMock),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []),
patch.object(test_bot, "_web_find_all_once", new_callable = AsyncMock, return_value = []),
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = web_await_side_effect),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "_try_recover_ad_id_from_redirect", new_callable = AsyncMock,
return_value = redirect_recovery_return, side_effect = redirect_recovery_side_effect),
]
if include_success_mocks:
common_patches.append(patch("kleinanzeigen_bot.utils.dicts.save_dict"))
with ExitStack() as stack:
for p in common_patches:
stack.enter_context(p)
yield
@pytest.mark.asyncio
@pytest.mark.parametrize(
"web_await_error",
[TimeoutError("confirmation timeout"), ProtocolException(MagicMock(), "connection lost", 0)],
ids = ["timeout", "protocol-exception"],
)
async def test_publish_ad_marks_post_submit_errors_as_uncertain(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
web_await_error:Exception,
) -> None:
"""Post-submit exceptions (TimeoutError, ProtocolException) should be converted to non-retryable uncertainty."""
ad_cfg, ad_cfg_orig = self._build_publish_ad_cfg(base_ad_config)
with (
self._mock_post_submit_dependencies(test_bot, mock_page,
web_await_side_effect = web_await_error,
redirect_recovery_return = None),
pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"),
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
@pytest.mark.asyncio
async def test_publish_ad_confirmation_fallback_from_referrer(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""When confirmation URL polling times out, ad ID should be recovered from document.referrer."""
ad_cfg, ad_cfg_orig = self._build_publish_ad_cfg(base_ad_config)
with self._mock_post_submit_dependencies(
test_bot, mock_page,
web_await_side_effect = TimeoutError("confirmation timeout"),
redirect_recovery_return = 99887766,
include_success_mocks = True,
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
assert ad_cfg_orig["id"] == 99887766
@pytest.mark.asyncio
async def test_publish_ad_confirmation_fallback_when_redirect_happens_after_url_poll(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""When the confirmation URL was observed by polling but the page redirected before extraction, the fallback should recover the ad ID."""
ad_cfg, ad_cfg_orig = self._build_publish_ad_cfg(base_ad_config)
# web_await succeeds (confirmation URL was seen during polling), but the page
# redirects before line 2479 can extract the URL, causing IndexError in the
# extraction which falls into the except block and triggers the fallback.
with self._mock_post_submit_dependencies(
test_bot, mock_page,
redirect_recovery_return = 55667788,
include_success_mocks = True,
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
assert ad_cfg_orig["id"] == 55667788
@pytest.mark.asyncio
async def test_publish_ad_confirmation_fallback_from_tracking_raises_uncertain_when_not_found(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""When both confirmation URL polling and tracking fallback fail, PublishSubmissionUncertainError should be raised."""
ad_cfg, ad_cfg_orig = self._build_publish_ad_cfg(base_ad_config)
with (
self._mock_post_submit_dependencies(test_bot, mock_page,
web_await_side_effect = TimeoutError("confirmation timeout"),
redirect_recovery_return = None),
pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"),
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
@pytest.mark.asyncio
async def test_publish_ad_confirmation_fallback_helper_failure_still_raises_uncertain(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
) -> None:
"""When the tracking fallback helper itself raises, it should not change retry behavior — PublishSubmissionUncertainError must still be raised."""
ad_cfg, ad_cfg_orig = self._build_publish_ad_cfg(base_ad_config)
with (
self._mock_post_submit_dependencies(test_bot, mock_page,
web_await_side_effect = TimeoutError("confirmation timeout"),
redirect_recovery_side_effect = RuntimeError("browser disconnected")),
pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"),
):
await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
def test_get_config_file_path(self, test_bot:KleinanzeigenBot, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test config file path handling."""
monkeypatch.chdir(tmp_path)
test_bot.config_file_path = os.path.abspath("config.yaml")
default_path = os.path.abspath("config.yaml")
assert test_bot.config_file_path == default_path
test_path = os.path.abspath("custom_config.yaml")
test_bot.config_file_path = test_path
assert test_bot.config_file_path == test_path
def test_get_log_file_path(self, test_bot:KleinanzeigenBot, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test log file path handling."""
monkeypatch.chdir(tmp_path)
test_bot.log_file_path = os.path.abspath("kleinanzeigen_bot.log")
default_path = os.path.abspath("kleinanzeigen_bot.log")
assert test_bot.log_file_path == default_path
test_path = os.path.abspath("custom.log")
test_bot.log_file_path = test_path
assert test_bot.log_file_path == test_path
def test_get_categories(self, test_bot:KleinanzeigenBot) -> None:
"""Test categories handling."""
test_categories = {"test_cat": "test_id"}
test_bot.categories = test_categories
assert test_bot.categories == test_categories
class TestKleinanzeigenBotUpdateAdsResilience:
@staticmethod
def _build_update_ad(base_ad_config:dict[str, Any], ad_id:int, title:str) -> tuple[str, Ad, dict[str, Any]]:
ad_payload = copy.deepcopy(base_ad_config) | {"id": ad_id, "title": title}
return (f"{ad_id}.yaml", Ad.model_validate(ad_payload), ad_payload)
@staticmethod
def _build_published_ads(*ad_ids:int) -> list[dict[str, Any]]:
return [{"id": ad_id, "state": "active"} for ad_id in ad_ids]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("first_failure", "first_title"),
[
(TimeoutError("transient timeout"), "Timeout Ad"),
(ProtocolException(MagicMock(), "connection lost", 0), "Protocol Failing"),
],
ids = ["timeout_error", "protocol_exception"],
)
async def test_update_ads_continues_after_retryable_first_ad_failure(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
first_failure:Exception,
first_title:str,
) -> None:
ad_one = self._build_update_ad(base_ad_config, 101, first_title)
ad_two = self._build_update_ad(base_ad_config, 102, "Success Ad")
async def publish_side_effect(
_ad_file:str,
ad_cfg:Ad,
_ad_cfg_orig:dict[str, Any],
_published_ads:list[dict[str, Any]],
_mode:AdUpdateStrategy,
) -> None:
if ad_cfg.id == 101:
raise first_failure
with (
patch.object(test_bot, "_fetch_published_ads", new_callable = AsyncMock, return_value = self._build_published_ads(101, 102)),
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = publish_side_effect) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.update_ads([ad_one, ad_two])
assert publish_mock.await_count == SUBMISSION_MAX_RETRIES + 1
assert any(call.args[1].id == 102 for call in publish_mock.await_args_list)
assert all(call.args[4] == AdUpdateStrategy.MODIFY for call in publish_mock.await_args_list)
assert sleep_mock.await_count == SUBMISSION_MAX_RETRIES - 1
@pytest.mark.asyncio
async def test_update_ads_publish_submission_uncertain_is_not_retried(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
ad_one = self._build_update_ad(base_ad_config, 301, "Uncertain Update")
ad_two = self._build_update_ad(base_ad_config, 302, "Second Update")
async def publish_side_effect(
_ad_file:str,
ad_cfg:Ad,
_ad_cfg_orig:dict[str, Any],
_published_ads:list[dict[str, Any]],
_mode:AdUpdateStrategy,
) -> None:
if ad_cfg.id == 301:
raise PublishSubmissionUncertainError("submission may have succeeded before failure")
with (
patch.object(test_bot, "_fetch_published_ads", new_callable = AsyncMock, return_value = self._build_published_ads(301, 302)),
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = publish_side_effect) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.update_ads([ad_one, ad_two])
assert publish_mock.await_count == 2
sleep_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_ads_category_resolution_error_is_not_retried(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
ad_one = self._build_update_ad(base_ad_config, 303, "Category Error Update")
ad_two = self._build_update_ad(base_ad_config, 304, "Second Update")
async def publish_side_effect(
_ad_file:str,
ad_cfg:Ad,
_ad_cfg_orig:dict[str, Any],
_published_ads:list[dict[str, Any]],
_mode:AdUpdateStrategy,
) -> None:
if ad_cfg.id == 303:
raise CategoryResolutionError("no suggestion matched")
with (
patch.object(test_bot, "_fetch_published_ads", new_callable = AsyncMock, return_value = self._build_published_ads(303, 304)),
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = publish_side_effect) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.update_ads([ad_one, ad_two])
assert [call.args[1].id for call in publish_mock.await_args_list] == [303, 304]
sleep_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_ads_cancelled_error_propagates_immediately(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
ad_one = self._build_update_ad(base_ad_config, 401, "Cancelled Ad")
ad_two = self._build_update_ad(base_ad_config, 402, "Should Not Run")
with (
patch.object(test_bot, "_fetch_published_ads", new_callable = AsyncMock, return_value = self._build_published_ads(401, 402)),
patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = asyncio.CancelledError()) as publish_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
pytest.raises(asyncio.CancelledError),
):
await test_bot.update_ads([ad_one, ad_two])
assert publish_mock.await_count == 1
@pytest.mark.asyncio
async def test_update_ads_publishing_result_timeout_is_non_fatal(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
ad_one = self._build_update_ad(base_ad_config, 501, "Result Timeout")
with (
patch.object(test_bot, "_fetch_published_ads", new_callable = AsyncMock, return_value = self._build_published_ads(501)),
patch.object(test_bot, "publish_ad", new_callable = AsyncMock) as publish_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = TimeoutError("result timeout")),
):
await test_bot.update_ads([ad_one])
publish_mock.assert_awaited_once()
class TestDisplayCounterProgression:
"""Regression tests for issue #977: progress counter must increment for every ad, including skipped ones."""
@staticmethod
def _build_ad(base_ad_config:dict[str, Any], ad_id:int | None, title:str) -> tuple[str, Ad, dict[str, Any]]:
ad_payload = copy.deepcopy(base_ad_config) | {"id": ad_id, "title": title}
return (f"{ad_id}.yaml", Ad.model_validate(ad_payload), ad_payload)
@staticmethod
def _build_published_ads(*ad_specs:tuple[int, str]) -> list[dict[str, Any]]:
return [{"id": ad_id, "state": state} for ad_id, state in ad_specs]
@pytest.mark.asyncio
async def test_publish_ads_counter_progression_with_paused_ads(
self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], caplog:pytest.LogCaptureFixture
) -> None:
"""Display counter must advance for paused ads, and only non-paused ads are published."""
ad_cfgs = [
self._build_ad(base_ad_config, 101, "Paused Ad 1"),
self._build_ad(base_ad_config, 102, "Active Ad 102"),
self._build_ad(base_ad_config, 103, "Paused Ad 2"),
]
published_ads = self._build_published_ads((101, "paused"), (102, "active"), (103, "paused"))
with (
caplog.at_level(logging.INFO),
patch.object(test_bot, "_fetch_published_ads", new_callable = AsyncMock, return_value = published_ads),
patch.object(test_bot, "publish_ad", new_callable = AsyncMock) as publish_mock,
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.publish_ads(ad_cfgs)
processing = [r for r in caplog.records if r.message.startswith("Processing")]
assert len(processing) == 3
assert "1/3" in processing[0].message
assert "2/3" in processing[1].message
assert "3/3" in processing[2].message
skip_msgs = [r for r in caplog.records if "Skipping because ad is reserved" in r.message]
assert len(skip_msgs) == 2
publish_mock.assert_awaited_once()
assert publish_mock.call_args.args[1].id == 102
summary = [r for r in caplog.records if "DONE:" in r.message]
assert any("1 ad" in r.message for r in summary)
@pytest.mark.asyncio
async def test_update_ads_counter_progression_with_paused_ads(
self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], caplog:pytest.LogCaptureFixture
) -> None:
"""Display counter must advance for paused ads, and only non-paused ads are updated."""
ad_cfgs = [
self._build_ad(base_ad_config, 201, "Paused Ad 1"),
self._build_ad(base_ad_config, 202, "Active Ad 202"),
self._build_ad(base_ad_config, 203, "Paused Ad 2"),
]
published_ads = self._build_published_ads((201, "paused"), (202, "active"), (203, "paused"))
with (
caplog.at_level(logging.INFO),
patch.object(test_bot, "_fetch_published_ads", new_callable = AsyncMock, return_value = published_ads),
patch.object(test_bot, "publish_ad", new_callable = AsyncMock) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.update_ads(ad_cfgs)
processing = [r for r in caplog.records if r.message.startswith("Processing")]
assert len(processing) == 3
assert "1/3" in processing[0].message
assert "2/3" in processing[1].message
assert "3/3" in processing[2].message
skip_msgs = [r for r in caplog.records if "Skipping because ad is reserved" in r.message]
assert len(skip_msgs) == 2
publish_mock.assert_awaited_once()
assert publish_mock.call_args.args[1].id == 202
summary = [r for r in caplog.records if "DONE:" in r.message]
assert any("1 ad" in r.message for r in summary)
@pytest.mark.asyncio
async def test_update_ads_counter_includes_not_found_ads(
self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], caplog:pytest.LogCaptureFixture
) -> None:
"""Display counter must advance even for ads not found in published ads."""
ad_cfgs = [
self._build_ad(base_ad_config, 301, "Not Found Ad"),
self._build_ad(base_ad_config, 302, "Active Ad 302"),
]
published_ads = self._build_published_ads((302, "active"))
with (
caplog.at_level(logging.INFO),
patch.object(test_bot, "_fetch_published_ads", new_callable = AsyncMock, return_value = published_ads),
patch.object(test_bot, "publish_ad", new_callable = AsyncMock) as publish_mock,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.update_ads(ad_cfgs)
processing = [r for r in caplog.records if r.message.startswith("Processing")]
assert len(processing) == 2
assert "1/2" in processing[0].message
assert "2/2" in processing[1].message
assert "Not Found Ad" in processing[0].message
skip_msgs = [r for r in caplog.records if "SKIPPED" in r.message and "not found" in r.message]
assert len(skip_msgs) == 1
publish_mock.assert_awaited_once()
assert publish_mock.call_args.args[1].id == 302
class TestKleinanzeigenBotContactLocationHardening:
@pytest.mark.parametrize(
("target", "candidate", "expected"),
[
("10115 - Metroville", "10115 - Metroville", True),
("10115 - Metroville", "12623 - Metroville", False),
("Metroville", "12623 - Metroville", True),
("Berlin", "Berlin - Mitte", True),
("Metroville", None, False),
("Berlin", "Hamburg", False),
("Berlin", "berlin", True),
("Berlin", " Berlin ", True),
],
)
def test_location_matches_target(self, test_bot:KleinanzeigenBot, target:str, candidate:str | None, expected:bool) -> None:
matcher = getattr(test_bot, "_KleinanzeigenBot__location_matches_target")
assert matcher(target, candidate) is expected
@pytest.mark.asyncio
async def test_read_city_selection_text_prefers_live_input_value(self, test_bot:KleinanzeigenBot) -> None:
city_input = MagicMock(spec = Element)
city_input.local_name = "input"
city_input.apply = AsyncMock(return_value = "Live City")
with (
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_input),
patch.object(test_bot, "web_text", new_callable = AsyncMock) as web_text_mock,
):
selected = await getattr(test_bot, "_KleinanzeigenBot__read_city_selection_text")()
assert selected == "Live City"
web_text_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_set_contact_fields_fails_closed_when_zipcode_cannot_be_set(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
ad_cfg = Ad.model_validate(base_ad_config)
with (
patch.object(test_bot, "web_input", new_callable = AsyncMock, side_effect = TimeoutError("zip timeout")),
patch.object(test_bot, "_KleinanzeigenBot__set_contact_location", new_callable = AsyncMock) as set_location_mock,
pytest.raises(TimeoutError, match = "Failed to set contact zipcode"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_contact_fields")(ad_cfg.contact)
set_location_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_set_contact_fields_skips_zipcode_and_location_when_empty(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""When no zipcode is configured, both ZIP entry and location setting are skipped without error."""
config = base_ad_config | {"contact": base_ad_config["contact"] | {"zipcode": ""}}
ad_cfg = Ad.model_validate(config)
with (
patch.object(test_bot, "web_input", new_callable = AsyncMock) as web_input_mock,
patch.object(test_bot, "_KleinanzeigenBot__set_contact_location", new_callable = AsyncMock) as set_location_mock,
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True),
):
await getattr(test_bot, "_KleinanzeigenBot__set_contact_fields")(ad_cfg.contact)
web_input_mock.assert_not_awaited()
set_location_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_set_contact_location_fails_when_city_suffix_matches_multiple_zip_codes(self, test_bot:KleinanzeigenBot) -> None:
"""When multiple ZIP codes share the same city name and no exact match, selection must fail closed."""
city_button = MagicMock(spec = Element)
city_button.local_name = "button"
city_button.attrs = {"role": "combobox", "aria-controls": "ad-city-menu"}
option_a = MagicMock(spec = Element)
option_a.text = "10115 - Metroville"
option_b = MagicMock(spec = Element)
option_b.text = "12623 - Metroville"
def _mock_city_option_text(elem:Element) -> str:
return str(getattr(elem, "text", "") or "")
async def _web_await_side_effect(condition:Callable[..., Awaitable[bool] | bool], **_:Any) -> Any:
result = condition()
return await result if asyncio.iscoroutine(result) else result
with (
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_button),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = [option_a, option_b]),
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = _web_await_side_effect),
patch.object(test_bot, "_KleinanzeigenBot__read_city_selection_text", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "_KleinanzeigenBot__city_option_text", new_callable = AsyncMock, side_effect = _mock_city_option_text),
pytest.raises(TimeoutError, match = "City combobox options are ambiguous for location: Metroville"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_contact_location")("Metroville")
@pytest.mark.asyncio
async def test_set_contact_location_raises_when_selection_does_not_converge(self, test_bot:KleinanzeigenBot) -> None:
city_button = MagicMock(spec = Element)
city_button.local_name = "button"
city_button.attrs = {"role": "combobox", "aria-controls": "ad-city-menu"}
target_option = MagicMock(spec = Element)
target_option.text = "10115 - Metroville"
target_option.click = AsyncMock()
wait_calls = 0
async def web_await_side_effect(condition:Callable[..., Awaitable[bool] | bool], **_:Any) -> Any:
nonlocal wait_calls
wait_calls += 1
result = condition()
condition_value = await result if asyncio.iscoroutine(result) else result
if wait_calls == 1:
return condition_value
raise TimeoutError("Condition not met")
with (
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_button),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = [target_option]),
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = web_await_side_effect),
patch.object(test_bot, "_KleinanzeigenBot__read_city_selection_text", new_callable = AsyncMock, return_value = "20095 - Rivertown"),
pytest.raises(TimeoutError, match = "City selection did not converge"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_contact_location")("10115 - Metroville")
@pytest.mark.asyncio
async def test_set_contact_location_accepts_readonly_input_with_zip_derived_value(self, test_bot:KleinanzeigenBot) -> None:
"""When ad-city is a readonly <input> with a non-empty prefilled value (zip-derived), accept it."""
city_input = MagicMock(spec = Element)
city_input.local_name = "input"
city_input.attrs = {"readonly": "", "value": "Metroville - Riverside"}
with (
patch.object(test_bot, "_KleinanzeigenBot__read_city_selection_text", new_callable = AsyncMock, return_value = "Metroville - Riverside"),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_input),
patch.object(test_bot, "_KleinanzeigenBot__select_city_combobox_option", new_callable = AsyncMock) as combobox_mock,
):
await getattr(test_bot, "_KleinanzeigenBot__set_contact_location")("Metroville")
combobox_mock.assert_not_called()
class TestKleinanzeigenBotCommands:
"""Tests for command execution."""
@pytest.mark.asyncio
async def test_run_version_command(self, test_bot:KleinanzeigenBot, capsys:Any) -> None:
"""Test running version command."""
await test_bot.run(["script.py", "version"])
captured = capsys.readouterr()
assert __version__ in captured.out
@pytest.mark.asyncio
async def test_run_help_command(self, test_bot:KleinanzeigenBot, capsys:Any) -> None:
"""Test running help command."""
await test_bot.run(["script.py", "help"])
captured = capsys.readouterr()
assert "Usage:" in captured.out
@pytest.mark.asyncio
async def test_run_unknown_command(self, test_bot:KleinanzeigenBot) -> None:
"""Test running unknown command."""
with pytest.raises(SystemExit) as exc_info:
await test_bot.run(["script.py", "unknown"])
assert exc_info.value.code == 2
@pytest.mark.asyncio
async def test_verify_command(self, test_bot:KleinanzeigenBot, tmp_path:Any) -> None:
"""Test verify command with minimal config."""
config_path = Path(tmp_path) / "config.yaml"
config_path.write_text(
"""
login:
username: test
password: test
""",
encoding = "utf-8",
)
test_bot.config_file_path = str(config_path)
await test_bot.run(["script.py", "verify", "--config", str(config_path), "--workspace-mode", "portable"])
assert test_bot.config.login.username == "test"
class TestKleinanzeigenBotAdOperations:
"""Tests for ad-related operations."""
@pytest.mark.asyncio
async def test_run_delete_command_no_ads(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running delete command with no ads."""
with patch.object(test_bot, "load_ads", return_value = []):
await test_bot.run(["script.py", "delete"])
assert test_bot.command == "delete"
@pytest.mark.asyncio
async def test_run_publish_command_no_ads(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running publish command with no ads."""
with patch.object(test_bot, "load_ads", return_value = []):
await test_bot.run(["script.py", "publish"])
assert test_bot.command == "publish"
@pytest.mark.asyncio
async def test_run_download_command_default_selector(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running download command with default selector."""
with patch("kleinanzeigen_bot.download_flow.download_ads", new_callable = AsyncMock) as mock_download:
await test_bot.run(["script.py", "download"])
assert test_bot.ads_selector == "new"
mock_download.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_update_default_selector(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running update command with default selector falls back to changed."""
with patch.object(test_bot, "load_ads", return_value = []):
await test_bot.run(["script.py", "update"])
assert test_bot.ads_selector == "changed"
@pytest.mark.asyncio
async def test_run_extend_default_selector(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running extend command with default selector falls back to all."""
with patch.object(test_bot, "load_ads", return_value = []):
await test_bot.run(["script.py", "extend"])
assert test_bot.ads_selector == "all"
class TestKleinanzeigenBotAdManagement:
"""Tests for ad management functionality."""
@pytest.mark.asyncio
async def test_download_ads_with_specific_ids(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test downloading ads with specific IDs."""
test_bot.ads_selector = "123,456"
with patch("kleinanzeigen_bot.download_flow.download_ads", new_callable = AsyncMock) as mock_download:
await test_bot.run(["script.py", "download", "--ads=123,456"])
assert test_bot.ads_selector == "123,456"
mock_download.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_publish_invalid_selector(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running publish with invalid selector exits with error."""
with pytest.raises(SystemExit) as exc_info:
await test_bot.run(["script.py", "publish", "--ads=invalid"])
assert exc_info.value.code == 2
@pytest.mark.asyncio
async def test_run_download_invalid_selector(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running download with invalid selector exits with error."""
with pytest.raises(SystemExit) as exc_info:
await test_bot.run(["script.py", "download", "--ads=invalid"])
assert exc_info.value.code == 2
@pytest.mark.asyncio
async def test_run_update_invalid_selector(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running update with invalid selector exits with error."""
with pytest.raises(SystemExit) as exc_info:
await test_bot.run(["script.py", "update", "--ads=invalid"])
assert exc_info.value.code == 2
@pytest.mark.asyncio
async def test_run_extend_invalid_selector(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running extend with invalid selector exits with error."""
with pytest.raises(SystemExit) as exc_info:
await test_bot.run(["script.py", "extend", "--ads=invalid"])
assert exc_info.value.code == 2
class TestKleinanzeigenBotShippingOptions:
"""Tests for shipping options functionality."""
@pytest.mark.asyncio
async def test_shipping_options_mapping(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], tmp_path:Any) -> None:
"""Test that shipping options are mapped correctly."""
# Create a mock page to simulate browser context
test_bot.page = MagicMock()
test_bot.page.url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345"
test_bot.page.evaluate = AsyncMock()
# Create ad config with specific shipping options
ad_cfg = Ad.model_validate(
base_ad_config
| {
"shipping_options": ["DHL_2", "Hermes_Päckchen"],
"updated_on": "2024-01-01T00:00:00", # Add created_on to prevent KeyError
"created_on": "2024-01-01T00:00:00", # Add updated_on for consistency
}
)
# Create the original ad config and published ads list
ad_cfg.update_content_hash() # Add content hash to prevent republication
ad_cfg_orig = ad_cfg.model_dump()
published_ads:list[dict[str, Any]] = []
# Set up default config values needed for the test
test_bot.config.publishing = PublishingConfig.model_validate({"delete_old_ads": "BEFORE_PUBLISH", "delete_old_ads_by_title": False})
# Create temporary file path
ad_file = Path(tmp_path) / "test_ad.yaml"
# Mock web_execute to handle all JavaScript calls
async def mock_web_execute(script:str) -> Any:
if script == "document.body.scrollHeight":
return 0 # Return integer to prevent scrolling loop
if "window.location.href" in script:
return test_bot.page.url # Return confirmation URL for ad_id extraction
return None
# Create mock elements
csrf_token_elem = MagicMock()
csrf_token_elem.attrs = {"content": "csrf-token-123"}
shipping_form_elem = MagicMock()
shipping_form_elem.attrs = {}
shipping_size_radio = MagicMock()
shipping_size_radio.attrs = {"checked": ""} # SMALL radio is pre-checked
shipping_checkbox = MagicMock()
shipping_checkbox.attrs = {"checked": ""} # Simulate pre-checked carriers for SMALL
category_path_elem = MagicMock()
category_path_elem.apply = AsyncMock(return_value = "Test Category")
# Mock the necessary web interaction methods
with (
patch.object(test_bot, "web_execute", side_effect = mock_web_execute),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find,
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
patch.object(test_bot, "web_select", new_callable = AsyncMock),
patch.object(test_bot, "web_input", new_callable = AsyncMock),
patch.object(test_bot, "web_open", new_callable = AsyncMock),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True),
patch.object(test_bot, "web_request", new_callable = AsyncMock),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock),
patch.object(test_bot, "web_await", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_contact_fields", new_callable = AsyncMock),
patch("builtins.input", return_value = ""),
patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock),
):
async def probe_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
if selector_type == By.ID and selector_value == "ad-category-path":
return category_path_elem
return None
mock_probe.side_effect = probe_side_effect
# Mock web_find to simulate element detection
async def mock_find_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
if selector_value == "meta[name=_csrf]":
return csrf_token_elem
if selector_value == "myftr-shppngcrt-frm":
return shipping_form_elem
# New shipping dialog: size radio via XPath with value attribute
if selector_type == By.XPATH and '@type="radio"' in selector_value and "@value=" in selector_value:
return shipping_size_radio
if selector_type == By.XPATH and '@type="checkbox"' in selector_value and "@value=" in selector_value:
return shipping_checkbox
return None
mock_find.side_effect = mock_find_side_effect
# Mock web_check to return True for radio button checked state
with patch.object(test_bot, "web_check", new_callable = AsyncMock) as mock_check:
mock_check.return_value = True
# Test through the public interface by publishing an ad
await test_bot.publish_ad(str(ad_file), ad_cfg, ad_cfg_orig, published_ads)
# Verify that the shipping dialog was interacted with:
# - web_find should have been called for the size radio (XPath with @type="radio")
# - web_click should have been called to deselect unwanted carriers and close dialog
radio_find_calls = [c for c in mock_find.await_args_list if len(c.args) >= 2 and '@type="radio"' in str(c.args[1])]
assert len(radio_find_calls) >= 1, "Expected at least one web_find for size radio"
click_xpath_values = [str(c.args[1]) for c in mock_click.await_args_list if len(c.args) >= 2 and c.args[0] == By.XPATH]
# Should click Weiter, deselect HERMES_002 (unwanted), and click Fertig
assert any("Weiter" in v for v in click_xpath_values), "Expected click on Weiter button"
assert any("HERMES_002" in v for v in click_xpath_values), "Expected click to deselect HERMES_002"
assert not any("DHL_001" in v for v in click_xpath_values), "Did not expect click for wanted DHL_001"
assert not any("HERMES_001" in v for v in click_xpath_values), "Did not expect click for wanted HERMES_001"
assert any("Fertig" in v for v in click_xpath_values), "Expected click on Fertig button"
# Verify the file was created in the temporary directory
assert ad_file.exists()
@pytest.mark.asyncio
async def test_cross_drive_path_fallback_windows(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Test that cross-drive path handling falls back to absolute path on Windows."""
# Create ad config
ad_cfg = Ad.model_validate(
base_ad_config
| {
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00",
"auto_price_reduction": {"enabled": True, "strategy": "FIXED", "amount": 10, "min_price": 50, "delay_reposts": 0, "delay_days": 0},
"price": 100,
"repost_count": 1,
"price_reduction_count": 0,
}
)
ad_cfg.update_content_hash()
ad_cfg_orig = ad_cfg.model_dump()
# Simulate Windows cross-drive scenario
# Config on D:, ad file on C:
test_bot.config_file_path = "D:\\project\\config.yaml"
ad_file = "C:\\temp\\test_ad.yaml"
# Create a sentinel exception to abort publish_ad early
class _SentinelException(Exception):
pass
# Track what path argument __apply_auto_price_reduction receives
recorded_path:list[str] = []
def mock_apply_auto_price_reduction(
ad_cfg:Ad,
ad_cfg_orig:dict[str, Any],
ad_file_relative:str,
*,
mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE,
) -> None:
_ = mode
recorded_path.append(ad_file_relative)
raise _SentinelException("Abort early for test")
# Mock Path to use PureWindowsPath for testing cross-drive behavior
with (
patch("kleinanzeigen_bot.Path", PureWindowsPath),
patch("kleinanzeigen_bot.price_reduction.apply_auto_price_reduction", side_effect = mock_apply_auto_price_reduction),
patch.object(test_bot, "web_open", new_callable = AsyncMock),
patch("kleinanzeigen_bot.delete_flow.delete_ad", new_callable = AsyncMock),
):
# Call publish_ad and expect sentinel exception
try:
await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
pytest.fail("Expected _SentinelException to be raised")
except _SentinelException:
# This is expected - the test aborts early
pass
# Verify the path argument is the absolute path (fallback behavior)
assert len(recorded_path) == 1
assert recorded_path[0] == ad_file, f"Expected absolute path fallback, got: {recorded_path[0]}"
@pytest.mark.asyncio
async def test_auto_price_reduction_conditional_on_mode_and_on_update(
self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], tmp_path:Path
) -> None:
"""Test price reduction dispatch across REPLACE and MODIFY modes.
- REPLACE mode always calls apply_auto_price_reduction.
- MODIFY mode with on_update=false still calls it for restore-first (no cycle advance).
- MODIFY mode with on_update=true calls it with full evaluation and cycle advance.
"""
# Shared ad config with auto price reduction enabled
ad_cfg = Ad.model_validate(
base_ad_config
| {
"id": 12345,
"price": 200,
"auto_price_reduction": {"enabled": True, "strategy": "FIXED", "amount": 50, "min_price": 50, "delay_reposts": 0, "delay_days": 0},
"repost_count": 1,
"price_reduction_count": 0,
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00",
}
)
ad_cfg.update_content_hash()
ad_cfg_orig = ad_cfg.model_dump()
mock_response = {"statusCode": 200, "statusMessage": "OK", "content": "{}"}
async def mock_web_execute_price_reduction(script:str) -> Any:
if "window.location.href" in script:
return "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345"
return mock_response
async def mock_web_probe(selector_type:Any, selector_value:str, **_kwargs:Any) -> Any:
if selector_value == "ad-category-path":
marker = MagicMock()
marker.apply = AsyncMock(return_value = "")
return marker
return None
with (
patch("kleinanzeigen_bot.price_reduction.apply_auto_price_reduction") as mock_apply,
patch("kleinanzeigen_bot.delete_flow.delete_ad", new_callable = AsyncMock),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = mock_web_probe),
patch.object(test_bot, "web_find", new_callable = AsyncMock),
patch.object(test_bot, "web_input", new_callable = AsyncMock),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_open", new_callable = AsyncMock),
patch.object(test_bot, "web_select", new_callable = AsyncMock),
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
patch.object(test_bot, "web_await", new_callable = AsyncMock),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "web_execute", side_effect = mock_web_execute_price_reduction),
patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = mock_response),
patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []),
patch.object(test_bot, "_web_find_all_once", new_callable = AsyncMock, return_value = []),
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_contact_fields", new_callable = AsyncMock),
patch("builtins.input", return_value = ""),
patch("kleinanzeigen_bot.utils.misc.ainput", new_callable = AsyncMock, return_value = ""),
):
test_bot.page = MagicMock()
test_bot.page.url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345"
test_bot.config.publishing.delete_old_ads = "BEFORE_PUBLISH"
# --- REPLACE mode: always calls apply_auto_price_reduction ---
await test_bot.publish_ad(str(tmp_path / "ad.yaml"), ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
mock_apply.assert_called_once()
assert mock_apply.call_args.kwargs["mode"] == AdUpdateStrategy.REPLACE
# --- MODIFY mode with default config (on_update=false): still calls for restore-first ---
mock_apply.reset_mock()
await test_bot.publish_ad(str(tmp_path / "ad.yaml"), ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
mock_apply.assert_called_once()
assert mock_apply.call_args.kwargs["mode"] == AdUpdateStrategy.MODIFY
# --- MODIFY mode with on_update=true: SHOULD call (new conditional behavior) ---
mock_apply.reset_mock()
ad_cfg.auto_price_reduction = AutoPriceReductionConfig(
enabled = True,
strategy = "FIXED",
amount = 50,
min_price = 50,
delay_reposts = 0,
delay_days = 0,
on_update = True,
)
await test_bot.publish_ad(str(tmp_path / "ad.yaml"), ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
mock_apply.assert_called_once()
assert mock_apply.call_args.kwargs["mode"] == AdUpdateStrategy.MODIFY
@pytest.mark.asyncio
async def test_special_attributes_compound_name_lookup(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Compound special-attribute names should be matched via original key in @name."""
ad_cfg = Ad.model_validate(
base_ad_config
| {
"special_attributes": {"autos.model_s": "a3"},
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00",
}
)
model_elem = MagicMock()
model_attrs = MagicMock()
model_attrs.id = None
model_attrs.name = "attributeMap[autos.marke_s+autos.model_s]"
model_attrs.get.side_effect = lambda key, default = None: {
"id": None,
"name": "attributeMap[autos.marke_s+autos.model_s]",
"type": None,
"role": None,
}.get(key, default)
model_elem.attrs = model_attrs
model_elem.local_name = "select"
with (
patch.object(test_bot, "web_find_all", new_callable = AsyncMock) as mock_find_all,
patch.object(test_bot, "web_select", new_callable = AsyncMock) as mock_select,
):
async def find_all_side_effect(selector_type:By, selector_value:str, **_:Any) -> list[Element]:
if selector_type == By.XPATH and "autos.model_s" in selector_value:
return [model_elem]
return []
mock_find_all.side_effect = find_all_side_effect
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
assert mock_select.await_count == 1
assert mock_select.await_args is not None
assert mock_select.await_args.args[0] == By.XPATH
assert "contains(@name, 'autos.model_s')" in str(mock_select.await_args.args[1])
assert mock_select.await_args.args[2] == "a3"
@pytest.mark.asyncio
async def test_special_attributes_prefers_button_combobox_over_hidden_input(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""Hidden backing inputs must not win over visible button combobox controls."""
ad_cfg = Ad.model_validate(
base_ad_config
| {
"special_attributes": {"color_s": "beige"},
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00",
}
)
hidden_elem = MagicMock()
hidden_attrs = MagicMock()
hidden_attrs.id = None
hidden_attrs.type = "hidden"
hidden_attrs.get.side_effect = lambda key, default = None: {
"id": None,
"name": "attributeMap[kleidung_herren.color]",
"type": "hidden",
"role": None,
}.get(key, default)
hidden_elem.attrs = hidden_attrs
hidden_elem.local_name = "input"
button_elem = MagicMock()
button_attrs = MagicMock()
button_attrs.id = "kleidung_herren.color"
button_attrs.type = "button"
button_attrs.get.side_effect = lambda key, default = None: {
"id": "kleidung_herren.color",
"name": None,
"type": "button",
"role": "combobox",
}.get(key, default)
button_elem.attrs = button_attrs
button_elem.local_name = "button"
with (
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = [hidden_elem, button_elem]),
patch.object(test_bot, "_KleinanzeigenBot__select_button_combobox", new_callable = AsyncMock) as mock_button_combobox,
patch.object(test_bot, "web_input", new_callable = AsyncMock) as mock_input,
):
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
mock_button_combobox.assert_awaited_once_with("kleidung_herren.color", "beige")
mock_input.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("combobox_type", ["text", None], ids = ["type-text", "type-absent"])
async def test_special_attributes_combobox_routed_over_hidden_input(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
combobox_type:str | None,
) -> None:
"""Combobox <input> must be routed to web_select_combobox regardless of type attribute presence."""
ad_cfg = Ad.model_validate(
base_ad_config
| {
"special_attributes": {"brand_s": "armani"},
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00",
}
)
hidden_elem = MagicMock()
hidden_attrs = MagicMock()
hidden_attrs.id = None
hidden_attrs.type = "hidden"
hidden_attrs.get.side_effect = lambda key, default = None: {
"id": None,
"name": "attributeMap[kleidung_herren.brand]",
"type": "hidden",
"role": None,
}.get(key, default)
hidden_elem.attrs = hidden_attrs
hidden_elem.local_name = "input"
combobox_elem = MagicMock()
combobox_attrs = MagicMock()
combobox_attrs.id = "kleidung_herren.brand"
combobox_attrs.type = combobox_type
combobox_attrs.get.side_effect = lambda key, default = None: {
"id": "kleidung_herren.brand",
"name": None,
"type": combobox_type,
"role": "combobox",
}.get(key, default)
combobox_elem.attrs = combobox_attrs
combobox_elem.local_name = "input"
with (
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = [hidden_elem, combobox_elem]),
patch.object(test_bot, "web_select_combobox", new_callable = AsyncMock) as mock_select_combobox,
patch.object(test_bot, "web_input", new_callable = AsyncMock) as mock_input,
):
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
mock_select_combobox.assert_awaited_once_with(By.ID, "kleidung_herren.brand", "armani")
mock_input.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("checked_attr", "attribute_value", "expect_click"),
[(None, "true", True), ("checked", "true", False), ("checked", "false", True)],
)
async def test_special_attributes_checkbox_clicks_only_on_state_change(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
checked_attr:str | None,
attribute_value:str,
expect_click:bool,
) -> None:
"""Checkbox attributes should only click when current and desired states differ."""
ad_cfg = Ad.model_validate(
base_ad_config
| {
"special_attributes": {"feature_b": attribute_value},
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00",
}
)
checkbox_elem = MagicMock()
checkbox_attrs = MagicMock()
checkbox_attrs.id = "feature"
checkbox_attrs.type = "checkbox"
checkbox_attrs.get.side_effect = lambda key, default = None: {
"id": "feature",
"name": "attributeMap[feature]",
"type": "checkbox",
"role": None,
"checked": checked_attr,
}.get(key, default)
checkbox_elem.attrs = checkbox_attrs
checkbox_elem.local_name = "input"
with (
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = [checkbox_elem]),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
):
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
if expect_click:
mock_click.assert_awaited_once_with(By.ID, "feature")
else:
mock_click.assert_not_awaited()
@pytest.mark.asyncio
async def test_hidden_input_fallback_finds_associated_button_combobox(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""When XPath matches only a hidden backing input (dynamic React IDs),
the fallback should locate the associated <button role="combobox"> and use
__select_button_combobox. Regression test for issue #1096."""
ad_cfg = Ad.model_validate(
base_ad_config
| {
"special_attributes": {"groesse_s": "68"},
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00",
}
)
hidden_elem = MagicMock()
hidden_attrs = MagicMock()
hidden_attrs.id = None
hidden_attrs.type = "hidden"
hidden_attrs.get.side_effect = lambda key, default = None: {
"id": None,
"name": "attributeMap[groesse]",
"type": "hidden",
"role": None,
}.get(key, default)
hidden_elem.attrs = hidden_attrs
hidden_elem.local_name = "input"
with (
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = [hidden_elem]),
patch.object(
test_bot,
"_find_associated_button_combobox",
new_callable = AsyncMock,
return_value = ":r8r7:",
) as mock_find_button,
patch.object(
test_bot,
"_KleinanzeigenBot__select_button_combobox",
new_callable = AsyncMock,
) as mock_select_combobox,
patch.object(test_bot, "web_input", new_callable = AsyncMock) as mock_input,
):
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
mock_find_button.assert_awaited_once_with(hidden_input_name = "attributeMap[groesse]")
mock_select_combobox.assert_awaited_once_with(":r8r7:", "68")
mock_input.assert_not_awaited()
@pytest.mark.asyncio
async def test_hidden_input_fallback_no_associated_button_raises(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""When the fallback cannot find an associated button combobox, a
TimeoutError is raised instead of falling through to the text-input handler."""
ad_cfg = Ad.model_validate(
base_ad_config
| {
"special_attributes": {"color_s": "beige"},
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00",
}
)
hidden_elem = MagicMock()
hidden_attrs = MagicMock()
hidden_attrs.id = None
hidden_attrs.type = "hidden"
hidden_attrs.get.side_effect = lambda key, default = None: {
"id": None,
"name": "attributeMap[color]",
"type": "hidden",
"role": None,
}.get(key, default)
hidden_elem.attrs = hidden_attrs
hidden_elem.local_name = "input"
with (
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = [hidden_elem]),
patch.object(
test_bot,
"_find_associated_button_combobox",
new_callable = AsyncMock,
return_value = None,
) as mock_find_button,pytest.raises(TimeoutError)
):
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
mock_find_button.assert_awaited_once_with(hidden_input_name = "attributeMap[color]")
@pytest.mark.asyncio
async def test_fallback_not_triggered_when_button_combobox_directly_matched(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""When the XPath directly matches a <button role="combobox"> (not just
a hidden input), the fallback search should NOT be triggered — the normal
dispatch path handles it. This guards against unnecessary JS execution."""
ad_cfg = Ad.model_validate(
base_ad_config
| {
"special_attributes": {"type_s": "accessoires"},
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00",
}
)
button_elem = MagicMock()
button_attrs = MagicMock()
button_attrs.id = "kleidung_herren.type"
button_attrs.type = "button"
button_attrs.get.side_effect = lambda key, default = None: {
"id": "kleidung_herren.type",
"name": None,
"type": "button",
"role": "combobox",
}.get(key, default)
button_elem.attrs = button_attrs
button_elem.local_name = "button"
with (
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = [button_elem]),
patch.object(
test_bot,
"_find_associated_button_combobox",
new_callable = AsyncMock,
) as mock_find_button,
patch.object(
test_bot,
"_KleinanzeigenBot__select_button_combobox",
new_callable = AsyncMock,
) as mock_select_combobox,
):
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
mock_find_button.assert_not_awaited()
mock_select_combobox.assert_awaited_once_with("kleidung_herren.type", "accessoires")
class TestConditionSelector:
"""Regression tests for condition dialog selection."""
@pytest.mark.asyncio
async def test_condition_selects_radio_by_value(self, test_bot:KleinanzeigenBot) -> None:
"""Condition selection should resolve radios by value in the new dialog."""
dialog = MagicMock()
trigger = MagicMock()
trigger.attrs = {"id": "condition-trigger", "aria-controls": "condition-dialog"}
trigger.click = AsyncMock()
radio = MagicMock()
radio_attrs = MagicMock()
radio_attrs.id = "radio-condition-ok"
radio_attrs.get.side_effect = lambda key, default = None: "radio-condition-ok" if key == "id" else default
radio.attrs = radio_attrs
radio.click = AsyncMock()
async def probe_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
# trigger lookup returns the dialog trigger button
if selector_type == By.XPATH and "contains(@for, '.condition')" in selector_value:
return trigger
# radio lookup returns the matching radio
if selector_type == By.XPATH and "@type='radio'" in selector_value and "@value='ok'" in selector_value:
return radio
return None
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = probe_side_effect),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = dialog),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
):
handled = await getattr(test_bot, "_KleinanzeigenBot__set_condition")("ok")
assert handled is True
clicked_xpath_selectors = [str(call.args[1]) for call in mock_click.await_args_list if len(call.args) > 1]
trigger.click.assert_awaited_once()
assert any("label[@for=" in selector and "radio-condition-ok" in selector for selector in clicked_xpath_selectors)
assert any("Bestätigen" in selector for selector in clicked_xpath_selectors)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("configured", "expected_api_value", "expect_warning"),
[
("wie_neu", "like_new", True),
("sehr_gut", "like_new", True),
("new", "new", False),
("like_new", "like_new", False),
("ok", "ok", False),
("alright", "alright", False),
("defect", "defect", False),
],
)
async def test_condition_tokens_warn_only_for_legacy_values(
self,
test_bot:KleinanzeigenBot,
configured:str,
expected_api_value:str,
expect_warning:bool,
caplog:pytest.LogCaptureFixture,
) -> None:
"""Condition tokens should resolve to the current API codes and warn only for legacy German values."""
caplog.set_level(logging.WARNING, logger = LOG.name)
dialog = MagicMock()
trigger = MagicMock()
trigger.attrs = {"id": "condition-trigger", "aria-controls": "condition-dialog"}
trigger.click = AsyncMock()
radio = MagicMock()
radio_attrs = MagicMock()
radio_attrs.get.side_effect = lambda key, default = None: f"radio-condition-{expected_api_value}" if key == "id" else default
radio.attrs = radio_attrs
radio.click = AsyncMock()
probed_values:list[str] = []
async def probe_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
if selector_type == By.XPATH and "contains(@for, '.condition')" in selector_value:
return trigger
if selector_type == By.XPATH and "@type='radio'" in selector_value:
if f"@value='{expected_api_value}'" in selector_value:
probed_values.append(expected_api_value)
return radio
if f"@value='{configured}'" in selector_value:
probed_values.append(configured)
return radio
return None
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = probe_side_effect),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = dialog),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
):
handled = await getattr(test_bot, "_KleinanzeigenBot__set_condition")(configured)
assert handled is True
warning_messages = [record.message for record in caplog.records if record.levelno == logging.WARNING]
if expect_warning:
assert len(warning_messages) == 1
assert configured in warning_messages[0]
assert expected_api_value in warning_messages[0]
# Legacy German values should prefer the mapped API code and stop once it is found.
assert probed_values == [expected_api_value]
else:
assert warning_messages == []
assert probed_values == [configured]
clicked_xpath_selectors = [str(call.args[1]) for call in mock_click.await_args_list if len(call.args) > 1]
assert any(f"radio-condition-{expected_api_value}" in selector for selector in clicked_xpath_selectors)
@pytest.mark.asyncio
async def test_condition_legacy_value_falls_back_when_mapped_value_is_missing(
self,
test_bot:KleinanzeigenBot,
caplog:pytest.LogCaptureFixture,
) -> None:
"""Legacy values should still work if the mapped API radio is unavailable."""
caplog.set_level(logging.WARNING, logger = LOG.name)
dialog = MagicMock()
trigger = MagicMock()
trigger.attrs = {"id": "condition-trigger", "aria-controls": "condition-dialog"}
trigger.click = AsyncMock()
radio = MagicMock()
radio_attrs = MagicMock()
radio_attrs.id = "radio-condition-wie_neu"
radio_attrs.get.side_effect = lambda key, default = None: "radio-condition-wie_neu" if key == "id" else default
radio.attrs = radio_attrs
radio.click = AsyncMock()
probed_values:list[str] = []
async def probe_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
if selector_type == By.XPATH and "contains(@for, '.condition')" in selector_value:
return trigger
if selector_type == By.XPATH and "@type='radio'" in selector_value:
if "@value='like_new'" in selector_value:
probed_values.append("like_new")
return None
if "@value='wie_neu'" in selector_value:
probed_values.append("wie_neu")
return radio
return None
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = probe_side_effect),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = dialog),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
):
handled = await getattr(test_bot, "_KleinanzeigenBot__set_condition")("wie_neu")
assert handled is True
warning_messages = [record.message for record in caplog.records if record.levelno == logging.WARNING]
assert len(warning_messages) == 1
assert "wie_neu" in warning_messages[0]
assert "like_new" in warning_messages[0]
assert probed_values == ["like_new", "wie_neu"]
clicked_xpath_selectors = [str(call.args[1]) for call in mock_click.await_args_list if len(call.args) > 1]
assert any("radio-condition-wie_neu" in selector for selector in clicked_xpath_selectors)
@pytest.mark.asyncio
async def test_condition_legacy_value_warns_even_when_not_handled(
self,
test_bot:KleinanzeigenBot,
caplog:pytest.LogCaptureFixture,
) -> None:
"""Legacy German values should warn even when the condition dialog path is unavailable."""
caplog.set_level(logging.WARNING, logger = LOG.name)
with patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None):
handled = await getattr(test_bot, "_KleinanzeigenBot__set_condition")("wie_neu")
assert handled is False
warning_messages = [record.message for record in caplog.records if record.levelno == logging.WARNING]
assert len(warning_messages) == 1
assert "wie_neu" in warning_messages[0]
assert "like_new" in warning_messages[0]
@pytest.mark.asyncio
async def test_condition_unknown_value_raises(self, test_bot:KleinanzeigenBot) -> None:
"""Unknown condition values should raise when no matching radio option is present."""
dialog = MagicMock()
trigger = MagicMock()
trigger.attrs = {"id": "condition-trigger", "aria-controls": "condition-dialog"}
trigger.click = AsyncMock()
async def probe_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
if selector_type == By.XPATH and "contains(@for, '.condition')" in selector_value:
return trigger
# Radio lookups for unknown values return None (no matching radio in the dialog).
return None
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = probe_side_effect),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = dialog),
pytest.raises(TimeoutError, match = "Failed to set attribute 'condition_s'"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_condition")("totally_unknown_value")
@pytest.mark.asyncio
async def test_condition_rejects_shipping_trigger(self, test_bot:KleinanzeigenBot) -> None:
"""Condition dialog path should not click shipping trigger controls."""
trigger = MagicMock()
trigger.attrs = {
"id": "ad-shipping-options",
"aria-controls": None,
"aria-haspopup": "dialog",
}
trigger.click = AsyncMock()
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element:
if selector_type == By.XPATH and "contains(@for, '.condition')" in selector_value:
return trigger
raise TimeoutError("unexpected selector")
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = trigger),
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = find_side_effect),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
):
handled = await getattr(test_bot, "_KleinanzeigenBot__set_condition")("new")
assert handled is False
# Regression guard: wrong shipping trigger must never be clicked by condition handler
trigger.click.assert_not_awaited()
mock_click.assert_not_awaited()
class TestConditionFallbackToGenericHandler:
"""Regression tests for condition_s fallback behavior.
When __set_condition reports "not handled" (e.g. category uses a button-combobox
instead of a dialog), __set_special_attributes should fall through to the generic
XPath-based handler.
"""
@pytest.mark.asyncio
@pytest.mark.parametrize(
("condition_s", "expected_generic_value"),
[
("new", "new"),
("wie_neu", "like_new"),
],
)
async def test_condition_falls_back_to_generic_handler_with_canonical_value(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
condition_s:str,
expected_generic_value:str,
) -> None:
"""Fallback should pass the canonical condition value to the generic combobox handler."""
ad_cfg = Ad.model_validate(base_ad_config | {"category": "185/249", "special_attributes": {"condition_s": condition_s}, "shipping_type": "PICKUP"})
button_elem = MagicMock()
button_attrs = MagicMock()
button_attrs.get.side_effect = lambda key, default = None: {
"id": "modellbau.condition",
"type": "button",
"role": "combobox",
"name": None,
}.get(key, default)
button_elem.attrs = button_attrs
button_elem.local_name = "button"
probe_elem = MagicMock()
probe_elem.attrs = {"id": "modellbau.condition"}
with (
patch.object(
test_bot,
"_KleinanzeigenBot__set_condition",
new_callable = AsyncMock,
return_value = False,
) as mock_set_condition,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = probe_elem),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = [button_elem]),
patch.object(
test_bot,
"_KleinanzeigenBot__select_button_combobox",
new_callable = AsyncMock,
) as mock_select_combobox,
):
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
mock_set_condition.assert_awaited_once_with(condition_s)
mock_select_combobox.assert_awaited_once_with("modellbau.condition", expected_generic_value)
@pytest.mark.asyncio
async def test_condition_timeout_propagates_instead_of_falling_back(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Real condition dialog failures should propagate and not silently use generic fallback."""
ad_cfg = Ad.model_validate(base_ad_config | {"category": "161/176", "special_attributes": {"condition_s": "ok"}})
with (
patch.object(
test_bot,
"_KleinanzeigenBot__set_condition",
new_callable = AsyncMock,
side_effect = TimeoutError("dialog timeout"),
),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock) as mock_find_all,
pytest.raises(TimeoutError, match = "dialog timeout"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
mock_find_all.assert_not_awaited()
@pytest.mark.asyncio
async def test_condition_s_missing_control_logs_warning_and_continues(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
caplog:pytest.LogCaptureFixture,
) -> None:
"""Missing condition_s control should warn and allow later attributes to continue."""
caplog.set_level(logging.WARNING, logger = LOG.name)
ad_cfg = Ad.model_validate(
base_ad_config
| {
"category": "185/249",
"special_attributes": {"condition_s": "ok", "color_s": "beige"},
"shipping_type": "PICKUP",
}
)
color_elem = MagicMock()
color_attrs = MagicMock()
color_attrs.id = "kleidung_herren.color"
color_attrs.get.side_effect = lambda key, default = None: {
"id": "kleidung_herren.color",
"type": "button",
"role": "combobox",
"name": None,
}.get(key, default)
color_elem.attrs = color_attrs
color_elem.local_name = "button"
condition_probe = MagicMock()
condition_probe.attrs = {"id": "condition_s"}
async def find_all_side_effect(selector_type:By, selector_value:str, **_:Any) -> list[Element]:
if selector_type == By.XPATH and "color_s" in selector_value:
return [color_elem]
if selector_type == By.XPATH and "condition_s" in selector_value:
raise AssertionError("condition_s lookup should be skipped when the probe returns None")
return []
async def probe_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
if selector_type == By.XPATH and "condition_s" in selector_value:
return None
if selector_type == By.XPATH and "color_s" in selector_value:
return condition_probe
return None
with (
patch.object(test_bot, "_KleinanzeigenBot__set_condition", new_callable = AsyncMock, return_value = False) as mock_set_condition,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = probe_side_effect),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, side_effect = find_all_side_effect),
patch.object(test_bot, "_KleinanzeigenBot__select_button_combobox", new_callable = AsyncMock) as mock_select_combobox,
):
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
mock_set_condition.assert_awaited_once_with("ok")
mock_select_combobox.assert_awaited_once_with("kleidung_herren.color", "beige")
warning_messages = [record.message for record in caplog.records if record.levelno == logging.WARNING]
assert len([message for message in warning_messages if "Special attribute 'condition_s' is not available" in message]) == 1
@pytest.mark.asyncio
async def test_condition_s_lookup_timeout_propagates(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""Lookup timeouts for condition_s should still fail instead of being skipped."""
ad_cfg = Ad.model_validate(base_ad_config | {"category": "185/249", "special_attributes": {"condition_s": "ok"}, "shipping_type": "PICKUP"})
condition_probe = MagicMock()
condition_probe.attrs = {"id": "condition_s"}
with (
patch.object(test_bot, "_KleinanzeigenBot__set_condition", new_callable = AsyncMock, return_value = False),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = condition_probe),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, side_effect = TimeoutError("lookup timeout")),
pytest.raises(TimeoutError, match = "lookup timeout"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
class TestCategoryProbeBehavior:
"""Tests for category marker probing without retry backoff."""
@pytest.mark.asyncio
async def test_set_category_uses_probe_for_auto_selected_marker(self, test_bot:KleinanzeigenBot) -> None:
"""In __set_category, category marker lookup should go through web_probe."""
category_marker = MagicMock()
category_marker.apply = AsyncMock(return_value = "Auto Category")
async def probe(selector_type:Any, selector_value:str, **_kwargs:Any) -> Any:
if selector_value == "ad-category-path":
return category_marker
return None # no suggestion picker shown
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = probe) as mock_probe,
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_find", new_callable = AsyncMock),
patch.object(test_bot, "web_open", new_callable = AsyncMock),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
):
await getattr(test_bot, "_KleinanzeigenBot__set_category")("185/249", "data/my_ads/ad.yaml")
mock_probe.assert_any_await(By.ID, "ad-category-path")
@pytest.mark.asyncio
async def test_set_category_without_explicit_category_requires_probe_match(self, test_bot:KleinanzeigenBot) -> None:
"""When no category is configured, missing marker should fail fast."""
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
pytest.raises(AssertionError, match = "No category specified"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_category")(None, "data/my_ads/ad.yaml")
class TestCategorySuggestionPicker:
"""Regression tests for the post-redesign category-suggestion radio picker fallback."""
@staticmethod
def _picker_probe_factory(picker_present:bool) -> Callable[..., Any]:
async def probe(selector_type:Any, selector_value:str, **_kwargs:Any) -> Any:
if selector_value == "ad-category-path":
marker = MagicMock()
marker.apply = AsyncMock(return_value = "")
return marker
if selector_value == "ad-category-picker":
return MagicMock() if picker_present else None
return None
return probe
@staticmethod
def _radio(value:str, radio_id:str | None = None) -> MagicMock:
elem = MagicMock()
elem.attrs = {"value": value}
if radio_id is not None:
elem.attrs["id"] = radio_id
elem.click = AsyncMock()
return elem
@pytest.mark.asyncio
async def test_picker_absent_leaves_flow_unchanged(self, test_bot:KleinanzeigenBot) -> None:
"""No picker -> no-op, no find_all / label click."""
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = self._picker_probe_factory(picker_present = False)),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock) as mock_find_all,
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
):
await getattr(test_bot, "_KleinanzeigenBot__resolve_category_suggestions")("73/76/sachbuecher")
mock_find_all.assert_not_awaited()
mock_click.assert_not_awaited()
@pytest.mark.asyncio
async def test_picker_present_without_rendered_radios_retries_then_times_out(self, test_bot:KleinanzeigenBot) -> None:
"""Picker shell present but radios not rendered yet should fail closed after a bounded retry."""
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = self._picker_probe_factory(picker_present = True)),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []) as mock_find_all,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
pytest.raises(TimeoutError, match = "Category suggestion picker element found but no radio suggestions rendered after waiting."),
):
await getattr(test_bot, "_KleinanzeigenBot__resolve_category_suggestions")("73/76/sachbuecher")
assert mock_find_all.await_count == 2
mock_sleep.assert_awaited_once()
mock_click.assert_not_awaited()
@pytest.mark.asyncio
async def test_picker_present_matches_leaf_segment_and_clicks_label(self, test_bot:KleinanzeigenBot) -> None:
"""Picker present with matching radio value -> label[for=ID] is clicked (value != id to catch regressions)."""
radios = [
self._radio("76", "category-suggestion-parent"),
self._radio("77", "category-suggestion-leaf"),
self._radio("240", "category-suggestion-other"),
]
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = self._picker_probe_factory(picker_present = True)),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = radios),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
):
await getattr(test_bot, "_KleinanzeigenBot__resolve_category_suggestions")("73/77")
mock_click.assert_awaited_once()
selector_type, selector_value = mock_click.call_args.args[:2]
assert selector_type == By.XPATH
assert "label[@for='category-suggestion-leaf']" in selector_value
assert "'ad-category-picker'" in selector_value
@pytest.mark.asyncio
async def test_picker_present_no_match_raises_with_offered_list(self, test_bot:KleinanzeigenBot) -> None:
"""Picker present but path has no matching segment -> CategoryResolutionError listing offered IDs."""
radios = [
self._radio("76", "category-suggestion-parent"),
self._radio("77", "category-suggestion-leaf"),
self._radio("240", "category-suggestion-other"),
]
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = self._picker_probe_factory(picker_present = True)),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = radios),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
pytest.raises(CategoryResolutionError, match = r"Category suggestion picker shown.*offered") as exc_info,
):
await getattr(test_bot, "_KleinanzeigenBot__resolve_category_suggestions")("999/888")
mock_click.assert_not_awaited()
error_message = str(exc_info.value)
# The error must name the configured (unmatched) path and every offered ID,
# otherwise the user cannot know what to correct.
assert "999/888" in error_message
for offered_id in ("76", "77", "240"):
assert offered_id in error_message
@pytest.mark.asyncio
async def test_picker_prefers_deepest_matching_segment(self, test_bot:KleinanzeigenBot) -> None:
"""When both parent and leaf segments match radios, the leaf (deepest) wins."""
radios = [self._radio("76", "id-for-76"), self._radio("77", "id-for-77")]
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = self._picker_probe_factory(picker_present = True)),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = radios),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
):
await getattr(test_bot, "_KleinanzeigenBot__resolve_category_suggestions")("76/77")
mock_click.assert_awaited_once()
assert "label[@for='id-for-77']" in mock_click.call_args.args[1]
class TestShippingDialogFlow:
"""Regression tests for shipping dialog flow using new radio selectors only."""
@pytest.mark.asyncio
@pytest.mark.parametrize("selected", [False, True])
async def test_pickup_shipping_radio_selection(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
selected:bool,
) -> None:
"""PICKUP shipping should click the pickup radio only when it is not already selected."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "PICKUP"})
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = MagicMock()) as mock_probe,
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = selected),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
mock_probe.assert_awaited_once()
assert mock_probe.call_args.args[:2] == (By.ID, "ad-shipping-enabled-no")
if selected:
mock_click.assert_not_awaited()
else:
mock_click.assert_awaited_once()
assert mock_click.call_args.args[:2] == (By.ID, "ad-shipping-enabled-no")
@pytest.mark.asyncio
async def test_pickup_shipping_raises_when_radio_lookup_times_out(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""PICKUP shipping should fail fast when pickup radio selector is unavailable."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "PICKUP"})
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "web_check", new_callable = AsyncMock, side_effect = TimeoutError("pickup lookup timed out")),
pytest.raises(TimeoutError, match = "Failed to set shipping attribute for type 'PICKUP'!"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
@pytest.mark.asyncio
async def test_pickup_shipping_skips_when_toggle_not_rendered(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""Categories without a shipping fieldset (e.g. books 76/77, comics 76/77/15156)
are PICKUP-only by site convention — the absence of both shipping selectors should
short-circuit without calling ``web_check``/``web_click``."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "PICKUP"})
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = [None, None]),
patch.object(test_bot, "web_check", new_callable = AsyncMock) as mock_check,
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
mock_check.assert_not_awaited()
mock_click.assert_not_awaited()
@pytest.mark.asyncio
async def test_pickup_shipping_raises_when_fieldset_rendered_but_pickup_radio_missing(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""A rendered shipping fieldset without the pickup radio should be treated as an error."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "PICKUP"})
with (
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = [None, MagicMock()]) as mock_probe,
patch.object(test_bot, "web_check", new_callable = AsyncMock) as mock_check,
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
pytest.raises(
TimeoutError,
match = "Shipping fieldset is rendered, but the pickup radio is missing; page may not be fully loaded.",
),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
assert mock_probe.await_count == 2
assert [call.args[:2] for call in mock_probe.await_args_list] == [
(By.ID, "ad-shipping-enabled-no"),
(By.ID, "ad-shipping-enabled"),
]
mock_check.assert_not_awaited()
mock_click.assert_not_awaited()
@pytest.mark.asyncio
async def test_shipping_without_options_uses_radio_and_dialog(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Shipping without package options should use radio + dialog flow."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "_KleinanzeigenBot__set_input_value", new_callable = AsyncMock) as mock_set_input,
patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = "4,95"),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
click_args = [c.args for c in mock_click.await_args_list]
assert any(len(a) >= 2 and a[0] == By.ID and a[1] == "ad-individual-shipping-checkbox-control" for a in click_args)
assert any("Fertig" in str(a[1]) for a in click_args if len(a) >= 2)
mock_set_input.assert_awaited_once_with("ad-individual-shipping-price", "4,95")
@pytest.mark.asyncio
async def test_shipping_finish_timeout_raises(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Timeout while confirming shipping dialog should raise a clear error."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": []})
async def click_side_effect(selector_type:By, selector_value:str, **_:Any) -> None:
if selector_type == By.XPATH and "Fertig" in selector_value:
raise TimeoutError("finish timeout")
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock, side_effect = click_side_effect),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
pytest.raises(TimeoutError, match = "Unable to close shipping dialog!"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
@pytest.mark.asyncio
async def test_shipping_without_options_does_not_toggle_checkbox_when_price_input_visible(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""When price input is already visible, individual-shipping checkbox is not toggled."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "_KleinanzeigenBot__set_input_value", new_callable = AsyncMock) as mock_set_input,
patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = "4,95"),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
click_args = [c.args for c in mock_click.await_args_list]
assert not any(len(a) >= 2 and a[0] == By.ID and a[1] == "ad-individual-shipping-checkbox-control" for a in click_args)
mock_set_input.assert_awaited_once_with("ad-individual-shipping-price", "4,95")
@pytest.mark.asyncio
async def test_shipping_price_lost_to_react_rerender_raises(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""When React re-render swallows the shipping price, the dialog must NOT be closed."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
async def set_input_side_effect(element_id:str, value:str) -> None:
# Simulate __set_input_value succeeding (no TimeoutError) but
# the JS returning early because the element is null after re-render.
# The real __set_input_value does a web_find + web_execute whose JS
# silently returns on `if(!el) return;`. By mocking at this level we
# model the observable effect: the call completes without error, but
# the DOM value was never written.
pass
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "_KleinanzeigenBot__set_input_value", new_callable = AsyncMock, side_effect = set_input_side_effect),
patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
pytest.raises(TimeoutError, match = "Unable to set shipping price!"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
# Fertig must never be clicked when the price was not confirmed
fertig_clicks = [c for c in mock_click.await_args_list if c.args and len(c.args) >= 2 and "Fertig" in str(c.args[1])]
assert not fertig_clicks, "Fertig was clicked despite shipping price not being set"
@pytest.mark.asyncio
async def test_shipping_price_recovers_when_readback_matches_on_retry(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""First attempt mismatches (readback empty), second attempt succeeds — must close the dialog normally."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "_KleinanzeigenBot__set_input_value", new_callable = AsyncMock) as mock_set_input,
patch.object(test_bot, "web_execute", new_callable = AsyncMock, side_effect = [None, "4,95"]),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
assert mock_set_input.await_count == 2, "expected exactly one retry after the first mismatch"
inter_attempt_sleeps = [c for c in mock_sleep.await_args_list if c.args == (300, 500)]
assert len(inter_attempt_sleeps) == 1, "expected one inter-attempt backoff sleep"
fertig_clicks = [c for c in mock_click.await_args_list if c.args and len(c.args) >= 2 and "Fertig" in str(c.args[1])]
assert fertig_clicks, "Fertig must be clicked once the readback confirms the price"
@pytest.mark.asyncio
async def test_shipping_price_retries_when_readback_raises_transiently(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""A TimeoutError from the readback web_execute must be retried, not propagated on the first occurrence."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
readback_results:list[str | Exception] = [TimeoutError("readback raced with re-render"), "4,95"]
async def readback_side_effect(*_args:Any, **_kwargs:Any) -> str | None:
result = readback_results.pop(0)
if isinstance(result, Exception):
raise result
return result
with (
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
patch.object(test_bot, "_KleinanzeigenBot__set_input_value", new_callable = AsyncMock) as mock_set_input,
patch.object(test_bot, "web_execute", new_callable = AsyncMock, side_effect = readback_side_effect),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
assert mock_set_input.await_count == 2, "expected one retry after the readback raised"
fertig_clicks = [c for c in mock_click.await_args_list if c.args and len(c.args) >= 2 and "Fertig" in str(c.args[1])]
assert fertig_clicks, "Fertig must be clicked once a later readback confirms the price"
class TestShippingOptionsDialog:
"""Tests for __set_shipping_options using carrier-code-based selectors."""
@staticmethod
def _make_ad_with_options(base_ad_config:dict[str, Any], options:list[str]) -> Ad:
return Ad.model_validate(
base_ad_config
| {
"shipping_type": "SHIPPING",
"shipping_options": options,
}
)
@staticmethod
def _mock_checkbox(checked:bool = False) -> MagicMock:
"""Create a mock checkbox element with optional checked attribute."""
el = MagicMock()
if checked:
el.attrs = {"checked": ""}
else:
el.attrs = {}
return el
@pytest.mark.parametrize(
"case",
[
# SMALL pre-checked, only unwanted carriers are toggled
{
"options": ["Hermes_Päckchen"],
"radio_checked": True,
"expected_radio_click": False,
"expected_clicked_carriers": ["HERMES_002", "DHL_001"],
"expected_not_clicked_carriers": ["HERMES_001"],
},
# LARGE not checked, radio click needed and only unwanted carriers are toggled
{
"options": ["DHL_10"],
"radio_checked": False,
"expected_radio_click": True,
"expected_clicked_carriers": ["HERMES_004", "DHL_004", "DHL_005"],
"expected_not_clicked_carriers": ["DHL_003"],
},
],
)
@pytest.mark.asyncio
async def test_replace_mode_handles_radio_state(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
case:dict[str, Any],
) -> None:
"""REPLACE mode: handles both pre-checked and unchecked radio states."""
ad_cfg = self._make_ad_with_options(base_ad_config, case["options"])
radio_mock = self._mock_checkbox(checked = case["radio_checked"])
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock:
if "radio" in selector_value:
return radio_mock
return self._mock_checkbox(checked = True) # all checkboxes pre-checked
with (
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = find_side_effect),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping_options")(ad_cfg, mode = AdUpdateStrategy.REPLACE)
click_args = [(c.args[0], c.args[1]) for c in mock_click.await_args_list if len(c.args) >= 2]
# Radio click behavior matches expectation
radio_clicked = any("radio" in str(a[1]) for a in click_args)
assert radio_clicked == case["expected_radio_click"]
# Should click Weiter and Fertig
assert any("Weiter" in str(a[1]) for a in click_args)
assert any("Fertig" in str(a[1]) for a in click_args)
# Should toggle exactly the expected carriers for this scenario
for carrier_code in case["expected_clicked_carriers"]:
assert any(carrier_code in str(a[1]) for a in click_args)
for carrier_code in case["expected_not_clicked_carriers"]:
assert not any(carrier_code in str(a[1]) for a in click_args)
@pytest.mark.asyncio
async def test_replace_mode_dom_verified_unchecked_defaults_select_wanted_carrier(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""REPLACE mode must select wanted carriers when defaults are unchecked (DOM-verified for MEDIUM/LARGE)."""
ad_cfg = self._make_ad_with_options(base_ad_config, ["DHL_5"])
radio_mock = self._mock_checkbox(checked = False) # MEDIUM radio not selected yet
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock:
if "radio" in selector_value and "MEDIUM" in selector_value:
return radio_mock
# DOM probe confirms MEDIUM defaults can be unchecked after "Weiter"
if "HERMES_003" in selector_value:
return self._mock_checkbox(checked = False)
if "DHL_002" in selector_value:
return self._mock_checkbox(checked = False)
return self._mock_checkbox(checked = False)
with (
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = find_side_effect),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping_options")(ad_cfg, mode = AdUpdateStrategy.REPLACE)
click_args = [(c.args[0], c.args[1]) for c in mock_click.await_args_list if len(c.args) >= 2]
# Regression guard for issue #956: wanted DHL_002 must be selected
assert any("DHL_002" in str(a[1]) for a in click_args)
# Unwanted Hermes checkbox must remain untouched when already unchecked
assert not any("HERMES_003" in str(a[1]) for a in click_args)
@pytest.mark.asyncio
async def test_modify_mode_toggles_carriers(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""MODIFY mode: explicitly (de-)selects each carrier based on wanted set."""
ad_cfg = self._make_ad_with_options(base_ad_config, ["Hermes_Päckchen", "DHL_2"])
radio_mock = self._mock_checkbox(checked = True) # SMALL already selected
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock:
if "radio" in selector_value and "SMALL" in selector_value:
return radio_mock
# HERMES_001 checked, HERMES_002 checked, DHL_001 unchecked
if "HERMES_001" in selector_value:
return self._mock_checkbox(checked = True)
if "HERMES_002" in selector_value:
return self._mock_checkbox(checked = True)
if "DHL_001" in selector_value:
return self._mock_checkbox(checked = False)
return self._mock_checkbox(checked = False)
with (
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = find_side_effect),
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping_options")(ad_cfg, mode = AdUpdateStrategy.MODIFY)
click_args = [(c.args[0], c.args[1]) for c in mock_click.await_args_list if len(c.args) >= 2]
# HERMES_002 should be deselected (was checked, not wanted)
assert any("HERMES_002" in str(a[1]) for a in click_args)
# DHL_001 should be selected (was unchecked, wanted via DHL_2 → DHL_001)
assert any("DHL_001" in str(a[1]) for a in click_args)
# HERMES_001 should NOT be clicked (was checked, wanted)
assert not any("HERMES_001" in str(a[1]) for a in click_args)
@pytest.mark.asyncio
async def test_unknown_option_raises_key_error(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""Unknown shipping option name raises KeyError with helpful message."""
ad_cfg = self._make_ad_with_options(base_ad_config, ["NonExistent_Option"])
with (
patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find,
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
pytest.raises(KeyError, match = "Unknown shipping option"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping_options")(ad_cfg)
# Validation errors must occur before any DOM interaction
mock_find.assert_not_awaited()
mock_click.assert_not_awaited()
mock_sleep.assert_not_awaited()
@pytest.mark.asyncio
async def test_mixed_size_options_raises_value_error(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
) -> None:
"""Options from different size groups raise ValueError."""
ad_cfg = self._make_ad_with_options(base_ad_config, ["Hermes_Päckchen", "DHL_5"])
with (
patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find,
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
pytest.raises(ValueError, match = "one package size"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping_options")(ad_cfg)
# Validation errors must occur before any DOM interaction
mock_find.assert_not_awaited()
mock_click.assert_not_awaited()
mock_sleep.assert_not_awaited()
@pytest.mark.asyncio
async def test_timeout_in_dialog_raises(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""TimeoutError during dialog interaction is re-raised with descriptive message."""
ad_cfg = self._make_ad_with_options(base_ad_config, ["Hermes_Päckchen"])
with (
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = TimeoutError("radio not found")),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
pytest.raises(TimeoutError, match = "Failed to configure shipping options in dialog!"),
):
await getattr(test_bot, "_KleinanzeigenBot__set_shipping_options")(ad_cfg)
class TestWantedShippingSelection:
"""Regression tests for WANTED shipping path using button-combobox dropdowns.
WANTED ads render shipping as a special-attribute combobox dropdown
(``<button role="combobox">``) rather than radio buttons. These tests
verify that the correct CSS selector lookup and ``web_select_button_combobox``
dispatch happen during ``publish_ad``.
"""
@contextmanager
def _mock_publish_dependencies(
self,
test_bot:KleinanzeigenBot,
mock_page:MagicMock,
) -> Iterator[tuple[AsyncMock, AsyncMock]]:
"""Mock all publish_ad dependencies, yielding (mock_find, mock_select_btn_combo)."""
test_bot.page = mock_page
test_bot.page.url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345"
async def execute_side_effect(script:str) -> Any:
if "window.location.href" in script:
return test_bot.page.url
return None
with (
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock, return_value = ""),
patch.object(test_bot, "web_open", new_callable = AsyncMock),
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_special_attributes", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_shipping", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_contact_fields", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__upload_images", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_input_value", new_callable = AsyncMock),
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find,
patch.object(test_bot, "_web_find_all_once", new_callable = AsyncMock, return_value = []),
patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
patch.object(test_bot, "web_execute", side_effect = execute_side_effect),
patch.object(test_bot, "web_check", new_callable = AsyncMock),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_select_button_combobox", new_callable = AsyncMock) as mock_select_btn_combo,
):
yield mock_find, mock_select_btn_combo
@staticmethod
def _build_wanted_ad(base_ad_config:dict[str, Any], shipping_type:str) -> tuple[Ad, dict[str, Any]]:
ad_cfg = Ad.model_validate(
base_ad_config
| {
"type": "WANTED",
"shipping_type": shipping_type,
"shipping_options": [],
"price_type": "NOT_APPLICABLE",
"price": None,
}
)
return ad_cfg, ad_cfg.model_dump()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("shipping_type", "expected_label"),
[("SHIPPING", "Versand möglich"), ("PICKUP", "Nur Abholung")],
ids = ["shipping", "pickup"],
)
async def test_wanted_shipping_selects_combobox_dropdown(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
tmp_path:Path,
shipping_type:str,
expected_label:str,
) -> None:
"""WANTED ads should select shipping via button-combobox dropdown, not radios."""
ad_cfg, ad_cfg_orig = self._build_wanted_ad(base_ad_config, shipping_type)
ad_file = str(tmp_path / "ad.yaml")
combobox_btn = MagicMock()
combobox_btn.attrs = {"id": "babyausstattung.versand"}
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock:
if selector_type == By.CSS_SELECTOR and selector_value == '[role="combobox"][id$=".versand"]':
return combobox_btn
return MagicMock()
with self._mock_publish_dependencies(test_bot, mock_page) as (mock_find, mock_select_btn_combo):
mock_find.side_effect = find_side_effect
await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
mock_select_btn_combo.assert_awaited_once_with(
"babyausstattung.versand",
expected_label,
)
@pytest.mark.asyncio
async def test_wanted_shipping_raises_when_combobox_not_found(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
tmp_path:Path,
) -> None:
"""WANTED shipping should fail when the combobox button cannot be found."""
ad_cfg, ad_cfg_orig = self._build_wanted_ad(base_ad_config, "SHIPPING")
ad_file = str(tmp_path / "ad.yaml")
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock:
if selector_type == By.CSS_SELECTOR and selector_value == '[role="combobox"][id$=".versand"]':
raise TimeoutError("combobox not found in DOM")
return MagicMock()
with self._mock_publish_dependencies(test_bot, mock_page) as (mock_find, _):
mock_find.side_effect = find_side_effect
with pytest.raises(TimeoutError, match = "Failed to set shipping attribute for type 'SHIPPING'!"):
await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
@pytest.mark.asyncio
async def test_wanted_shipping_not_applicable_skips_combobox(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
tmp_path:Path,
) -> None:
"""WANTED ads with NOT_APPLICABLE shipping should skip the combobox entirely."""
ad_cfg = Ad.model_validate(
base_ad_config
| {
"type": "WANTED",
"shipping_type": "NOT_APPLICABLE",
"shipping_options": [],
"price_type": "NOT_APPLICABLE",
"price": None,
}
)
ad_cfg_orig = ad_cfg.model_dump()
ad_file = str(tmp_path / "ad.yaml")
with self._mock_publish_dependencies(test_bot, mock_page) as (_, mock_select_btn_combo):
await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
mock_select_btn_combo.assert_not_awaited()
@pytest.mark.asyncio
async def test_wanted_shipping_raises_when_combobox_has_no_id(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
tmp_path:Path,
) -> None:
"""WANTED shipping should fail when the combobox button has no id attribute."""
ad_cfg, ad_cfg_orig = self._build_wanted_ad(base_ad_config, "SHIPPING")
ad_file = str(tmp_path / "ad.yaml")
combobox_btn = MagicMock()
combobox_btn.attrs = {} # No "id" key
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock:
if selector_type == By.CSS_SELECTOR and selector_value == '[role="combobox"][id$=".versand"]':
return combobox_btn
return MagicMock()
with self._mock_publish_dependencies(test_bot, mock_page) as (mock_find, _):
mock_find.side_effect = find_side_effect
with pytest.raises(TimeoutError, match = "Failed to set shipping attribute for type 'SHIPPING'!"):
await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
def test_file_logger_writes_message(tmp_path:Path, caplog:pytest.LogCaptureFixture) -> None:
"""
Unit: Logger can be initialized and used, robust to pytest log capture.
"""
log_path = tmp_path / "logger_test.log"
logger_name = "logger_test_logger_unique"
logger = logging.getLogger(logger_name)
logger.setLevel(logging.DEBUG)
logger.propagate = False
for h in list(logger.handlers):
logger.removeHandler(h)
handle = logging.FileHandler(str(log_path), encoding = "utf-8")
handle.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(levelname)s:%(name)s:%(message)s")
handle.setFormatter(formatter)
logger.addHandler(handle)
logger.info("Logger test log message")
handle.flush()
handle.close()
logger.removeHandler(handle)
assert log_path.exists()
with open(log_path, "r", encoding = "utf-8") as f:
contents = f.read()
assert "Logger test log message" in contents
def _apply_price_reduction_persistence(count:int | None) -> dict[str, Any]:
"""Return a dict with price_reduction_count only when count is positive (count -> dict[str, Any])."""
ad_cfg_orig:dict[str, Any] = {}
if count is not None and count > 0:
ad_cfg_orig["price_reduction_count"] = count
return ad_cfg_orig
class TestPriceReductionPersistence:
"""Tests for price_reduction_count persistence logic."""
@pytest.mark.unit
@pytest.mark.parametrize(
("count", "expected_cfg"),
[
(3, {"price_reduction_count": 3}),
(0, {}),
(None, {}),
],
ids = ["positive", "zero", "none"],
)
def test_persistence_logic(self, count:int | None, expected_cfg:dict[str, Any]) -> None:
"""Test the conditional logic that decides whether to persist price_reduction_count."""
assert _apply_price_reduction_persistence(count) == expected_cfg
class TestBuyNowRadioWarning:
"""Regression tests for buy-now handling across shipping modes."""
@contextmanager
def _mock_publish_ad_dependencies(
self,
test_bot:KleinanzeigenBot,
mock_page:MagicMock,
probe_side_effect:Callable[[By, str], Awaitable[Element | None]],
check_side_effect:Callable[[By, str, Any, Any], Awaitable[bool]],
) -> Iterator[tuple[MagicMock, MagicMock, MagicMock]]:
"""Context manager that mocks all publish_ad dependencies for buy-now radio tests.
Args:
test_bot: The bot instance to patch methods on.
mock_page: Mock page object to assign to test_bot.page.
probe_side_effect: Async function defining web_probe behavior.
check_side_effect: Async function defining web_check behavior for ad-buy-now-false.
Yields:
Tuple of (mock_probe, mock_check, mock_click) for assertions in the test.
"""
test_bot.page = mock_page
test_bot.page.url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345"
async def execute_side_effect(script:str) -> Any:
if "window.location.href" in script:
return test_bot.page.url
return None
with (
patch.object(test_bot, "web_open", new_callable = AsyncMock),
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_special_attributes", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_shipping", new_callable = AsyncMock),
patch.object(test_bot, "web_input", new_callable = AsyncMock),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = probe_side_effect) as mock_probe,
patch.object(test_bot, "web_check", new_callable = AsyncMock, side_effect = check_side_effect) as mock_check,
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
patch.object(test_bot, "web_execute", side_effect = execute_side_effect),
patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_contact_fields", new_callable = AsyncMock),
patch.object(test_bot, "web_find", new_callable = AsyncMock),
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []),
patch.object(test_bot, "_web_find_all_once", new_callable = AsyncMock, return_value = []),
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock),
):
yield mock_probe, mock_check, mock_click
@pytest.mark.asyncio
@pytest.mark.parametrize(
("scenario", "expected_click"),
[
("radio_absent_swallowed", False),
("radio_visible_needs_click", True),
("radio_already_selected", False),
],
)
async def test_buy_now_radio_behavior_for_pickup(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
tmp_path:Path,
scenario:str,
expected_click:bool,
) -> None:
"""Buy-now radio handling for PICKUP: skips when absent, clicks when needed."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "PICKUP", "price_type": "FIXED", "price": 100})
ad_cfg_orig = copy.deepcopy(base_ad_config)
ad_file = str(tmp_path / "ad.yaml")
buy_now_elem = MagicMock()
async def probe_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
if selector_type == By.ID and selector_value == "ad-buy-now-false":
if scenario == "radio_absent_swallowed":
return None
return buy_now_elem
return None
async def check_side_effect(selector_type:By, selector_value:str, *_:Any, **__:Any) -> bool:
if selector_type == By.ID and selector_value == "ad-buy-now-false":
return scenario == "radio_already_selected"
return False
with self._mock_publish_ad_dependencies(test_bot, mock_page, probe_side_effect, check_side_effect) as (_, _, mock_click):
await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
buy_now_clicks = [c for c in mock_click.call_args_list if len(c.args) >= 2 and c.args[0] == By.ID and c.args[1] == "ad-buy-now-false"]
if expected_click:
assert buy_now_clicks, "web_click should be called for ad-buy-now-false when visible but not selected"
else:
assert not buy_now_clicks, "web_click should not be called for ad-buy-now-false"
@pytest.mark.asyncio
async def test_buy_now_true_missing_logs_warning_and_continues(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
tmp_path:Path,
caplog:pytest.LogCaptureFixture,
) -> None:
"""Shipping ads with sell_directly enabled should warn if buy-now-true control is unavailable."""
caplog.set_level(logging.WARNING, logger = LOG.name)
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "sell_directly": True, "price_type": "FIXED", "price": 100})
ad_cfg_orig = copy.deepcopy(base_ad_config)
ad_file = str(tmp_path / "ad.yaml")
async def probe_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
if selector_type == By.ID and selector_value == "ad-buy-now-true":
return None
return None
async def check_side_effect(*_:Any, **__:Any) -> bool:
return False
with self._mock_publish_ad_dependencies(test_bot, mock_page, probe_side_effect, check_side_effect):
await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
warning_messages = [record.message for record in caplog.records if record.levelno == logging.WARNING]
assert len([message for message in warning_messages if "Direct-buy (sell_directly) is not available" in message]) == 1
@pytest.mark.asyncio
async def test_buy_now_true_interaction_timeout_propagates(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
tmp_path:Path,
) -> None:
"""Existing direct-buy controls should still fail if interaction times out."""
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "sell_directly": True, "price_type": "FIXED", "price": 100})
ad_cfg_orig = copy.deepcopy(base_ad_config)
ad_file = str(tmp_path / "ad.yaml")
buy_now_elem = MagicMock()
async def probe_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
if selector_type == By.ID and selector_value == "ad-buy-now-true":
return buy_now_elem
return None
async def check_side_effect(selector_type:By, selector_value:str, *_:Any, **__:Any) -> bool:
if selector_type == By.ID and selector_value == "ad-buy-now-true":
raise TimeoutError("check timeout")
return False
with (
pytest.raises(TimeoutError, match = "check timeout"),
self._mock_publish_ad_dependencies(test_bot, mock_page, probe_side_effect, check_side_effect),
):
await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.REPLACE)
class TestImageUploadProcessedMarkerFallback:
"""Regression tests for image upload completion detection via hidden marker inputs."""
@staticmethod
def _build_two_image_ad(base_ad_config:dict[str, Any], tmp_path:Path) -> tuple[Ad, str, str]:
image_a = tmp_path / "img_a.jpg"
image_b = tmp_path / "img_b.jpg"
image_a.write_bytes(b"")
image_b.write_bytes(b"")
ad_cfg = Ad.model_validate(base_ad_config | {"images": [str(image_a), str(image_b)]})
return ad_cfg, str(image_a), str(image_b)
@staticmethod
def _build_marker(url:str) -> MagicMock:
marker = MagicMock()
marker.attrs.value = url
return marker
@staticmethod
@contextmanager
def _mock_upload_dependencies(
test_bot:KleinanzeigenBot,
file_input:MagicMock,
find_all_side_effect:Callable[..., Awaitable[list[MagicMock]]],
await_side_effect:Callable[..., Awaitable[Any]],
) -> Iterator[None]:
async def find_all_once_side_effect(selector_type:By, selector_value:str, *_:Any, **__:Any) -> list[MagicMock]:
return await find_all_side_effect(selector_type, selector_value, **__)
with (
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = file_input),
patch.object(test_bot, "_web_find_all_once", new_callable = AsyncMock, side_effect = find_all_once_side_effect),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = await_side_effect),
):
yield
@pytest.mark.asyncio
async def test_upload_images_succeeds_with_hidden_markers_when_thumbnails_absent(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
tmp_path:Path,
) -> None:
"""Hidden adImages markers should satisfy completion when thumbnail list is missing."""
ad_cfg, image_a, image_b = self._build_two_image_ad(base_ad_config, tmp_path)
file_input = MagicMock()
file_input.send_file = AsyncMock()
marker_a = self._build_marker("https://img.example/a.jpg")
marker_b = self._build_marker("https://img.example/b.jpg")
marker_query_count = 0
async def find_all_side_effect(selector_type:By, selector_value:str, *_:Any, **__:Any) -> list[MagicMock]:
nonlocal marker_query_count
if selector_type == By.CSS_SELECTOR and selector_value == "input[name^='adImages'][name$='.url']":
marker_query_count += 1
if marker_query_count == 1:
return [] # baseline before upload
return [marker_a, marker_b]
return []
async def await_side_effect(condition:Callable[[], Awaitable[bool]], **_:Any) -> bool:
if await condition():
return True
raise TimeoutError("condition did not pass")
with self._mock_upload_dependencies(test_bot, file_input, find_all_side_effect, await_side_effect):
await getattr(test_bot, "_KleinanzeigenBot__upload_images")(ad_cfg)
file_input.send_file.assert_any_await(image_a)
file_input.send_file.assert_any_await(image_b)
@pytest.mark.asyncio
async def test_upload_images_refetches_file_input_per_image_to_avoid_stale_element(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
tmp_path:Path,
) -> None:
"""Each image upload should re-fetch the file input because the DOM replaces it after selection."""
ad_cfg, image_a, image_b = self._build_two_image_ad(base_ad_config, tmp_path)
first_file_input = MagicMock()
first_file_input.send_file = AsyncMock()
second_file_input = MagicMock()
second_file_input.send_file = AsyncMock()
marker_a = self._build_marker("https://img.example/a.jpg")
marker_b = self._build_marker("https://img.example/b.jpg")
marker_query_count = 0
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock:
assert selector_type == By.CSS_SELECTOR
assert selector_value == "input[type=file]"
if first_file_input.send_file.await_count == 0:
return first_file_input
return second_file_input
async def find_all_side_effect(selector_type:By, selector_value:str, *_:Any, **__:Any) -> list[MagicMock]:
nonlocal marker_query_count
if selector_type == By.CSS_SELECTOR and selector_value == "input[name^='adImages'][name$='.url']":
marker_query_count += 1
if marker_query_count == 1:
return [] # baseline before upload
return [marker_a, marker_b]
return []
async def await_side_effect(condition:Callable[[], Awaitable[bool]], **_:Any) -> bool:
if await condition():
return True
raise TimeoutError("condition did not pass")
with (
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = find_side_effect) as mock_find,
patch.object(test_bot, "_web_find_all_once", new_callable = AsyncMock, side_effect = find_all_side_effect),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = await_side_effect),
):
await getattr(test_bot, "_KleinanzeigenBot__upload_images")(ad_cfg)
first_file_input.send_file.assert_awaited_once_with(image_a)
second_file_input.send_file.assert_awaited_once_with(image_b)
assert mock_find.await_count >= 2
@pytest.mark.asyncio
@pytest.mark.parametrize(
("baseline_count", "post_count", "expected_found"),
[
pytest.param(2, 2, 0, id = "stale-only-markers"),
pytest.param(0, 1, 1, id = "one-new-marker"),
],
)
async def test_upload_images_timeout_reports_processed_count(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
tmp_path:Path,
baseline_count:int,
post_count:int,
expected_found:int,
) -> None:
"""Upload timeout should report the correct processed-marker count based on baseline vs post-upload markers."""
ad_cfg, image_a, image_b = self._build_two_image_ad(base_ad_config, tmp_path)
file_input = MagicMock()
file_input.send_file = AsyncMock()
marker_query_count = 0
async def find_all_side_effect(selector_type:By, selector_value:str, **_:Any) -> list[MagicMock]:
nonlocal marker_query_count
if selector_type == By.CSS_SELECTOR and selector_value == "input[name^='adImages'][name$='.url']":
marker_query_count += 1
if marker_query_count == 1:
return [self._build_marker(f"https://img.example/baseline-{i}.jpg") for i in range(baseline_count)]
return [self._build_marker(f"https://img.example/post-{i}.jpg") for i in range(post_count)]
return []
async def await_timeout(*_:Any, **__:Any) -> None:
raise TimeoutError("Image upload timeout exceeded")
with (
pytest.raises(TimeoutError, match = rf"Expected 2, found {expected_found} processed"),
self._mock_upload_dependencies(test_bot, file_input, find_all_side_effect, await_timeout),
):
await getattr(test_bot, "_KleinanzeigenBot__upload_images")(ad_cfg)
file_input.send_file.assert_any_await(image_a)
file_input.send_file.assert_any_await(image_b)
@pytest.mark.asyncio
async def test_upload_images_succeeds_when_new_markers_exceed_baseline(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
tmp_path:Path,
) -> None:
"""Only marker delta beyond baseline should satisfy completion when thumbnails are absent."""
ad_cfg, image_a, image_b = self._build_two_image_ad(base_ad_config, tmp_path)
file_input = MagicMock()
file_input.send_file = AsyncMock()
stale_marker = self._build_marker("https://img.example/stale.jpg")
marker_a = self._build_marker("https://img.example/a.jpg")
marker_b = self._build_marker("https://img.example/b.jpg")
marker_query_count = 0
async def find_all_side_effect(selector_type:By, selector_value:str, **_:Any) -> list[MagicMock]:
nonlocal marker_query_count
if selector_type == By.CSS_SELECTOR and selector_value == "input[name^='adImages'][name$='.url']":
marker_query_count += 1
if marker_query_count == 1:
return [stale_marker] # baseline before upload
return [stale_marker, marker_a, marker_b] # 2 new markers beyond baseline
return []
async def await_side_effect(condition:Callable[[], Awaitable[bool]], **_:Any) -> bool:
if await condition():
return True
raise TimeoutError("condition did not pass")
with self._mock_upload_dependencies(test_bot, file_input, find_all_side_effect, await_side_effect):
await getattr(test_bot, "_KleinanzeigenBot__upload_images")(ad_cfg)
file_input.send_file.assert_any_await(image_a)
file_input.send_file.assert_any_await(image_b)
@pytest.mark.asyncio
async def test_upload_images_baseline_capture_timeout_defaults_to_zero(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
tmp_path:Path,
) -> None:
"""If baseline marker lookup times out, marker fallback should still work with baseline=0."""
ad_cfg, image_a, image_b = self._build_two_image_ad(base_ad_config, tmp_path)
file_input = MagicMock()
file_input.send_file = AsyncMock()
marker_a = self._build_marker("https://img.example/a.jpg")
marker_b = self._build_marker("https://img.example/b.jpg")
marker_query_count = 0
async def find_all_side_effect(selector_type:By, selector_value:str, **_:Any) -> list[MagicMock]:
nonlocal marker_query_count
if selector_type == By.CSS_SELECTOR and selector_value == "input[name^='adImages'][name$='.url']":
marker_query_count += 1
if marker_query_count == 1:
raise TimeoutError("baseline markers unavailable")
return [marker_a, marker_b]
return []
async def await_side_effect(condition:Callable[[], Awaitable[bool]], **_:Any) -> bool:
if await condition():
return True
raise TimeoutError("condition did not pass")
with self._mock_upload_dependencies(test_bot, file_input, find_all_side_effect, await_side_effect):
await getattr(test_bot, "_KleinanzeigenBot__upload_images")(ad_cfg)
file_input.send_file.assert_any_await(image_a)
file_input.send_file.assert_any_await(image_b)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("baseline_count", "post_count"),
[
pytest.param(0, 2, id = "no_baseline"),
pytest.param(1, 3, id = "one_stale_plus_two_new"),
pytest.param(2, 4, id = "two_stale_plus_two_new"),
],
)
async def test_upload_images_marker_delta_determines_completion(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
tmp_path:Path,
baseline_count:int,
post_count:int,
) -> None:
"""Completion should succeed when marker delta reaches expected count."""
ad_cfg, image_a, image_b = self._build_two_image_ad(base_ad_config, tmp_path)
file_input = MagicMock()
file_input.send_file = AsyncMock()
marker_query_count = 0
async def find_all_side_effect(selector_type:By, selector_value:str, **_:Any) -> list[MagicMock]:
nonlocal marker_query_count
if selector_type == By.CSS_SELECTOR and selector_value == "input[name^='adImages'][name$='.url']":
marker_query_count += 1
if marker_query_count == 1:
return [self._build_marker(f"https://img.example/stale-{i}.jpg") for i in range(baseline_count)]
return [self._build_marker(f"https://img.example/post-{i}.jpg") for i in range(post_count)]
return []
async def await_side_effect(condition:Callable[[], Awaitable[bool]], **_:Any) -> bool:
if await condition():
return True
raise TimeoutError("condition did not pass")
with self._mock_upload_dependencies(test_bot, file_input, find_all_side_effect, await_side_effect):
await getattr(test_bot, "_KleinanzeigenBot__upload_images")(ad_cfg)
file_input.send_file.assert_any_await(image_a)
file_input.send_file.assert_any_await(image_b)
class TestImageCleanupInPublishAd:
"""Regression tests for image cleanup loop in publish_ad."""
@pytest.mark.asyncio
async def test_existing_images_removed_before_upload(
self,
test_bot:KleinanzeigenBot,
base_ad_config:dict[str, Any],
mock_page:MagicMock,
tmp_path:Path,
) -> None:
"""Cleanup should probe and click remove buttons before upload."""
test_bot.page = mock_page
test_bot.page.url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345"
image_path = tmp_path / "img.jpg"
ad_cfg = Ad.model_validate(base_ad_config | {"images": [str(image_path)]})
ad_cfg = ad_cfg.model_copy(update = {"id": 12345})
image_path.write_bytes(b"\xff\xd8\xff")
ad_cfg_orig = ad_cfg.model_dump()
ad_file = str(tmp_path / "ad.yaml")
probe_call_count = 0
remove_buttons:list[MagicMock] = []
event_log:list[str] = []
async def probe_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
nonlocal probe_call_count
if selector_type == By.CSS_SELECTOR and selector_value == "button[aria-label='Bild entfernen']":
probe_call_count += 1
if probe_call_count <= 3:
remove_btn = MagicMock()
remove_btn.click = AsyncMock(side_effect = lambda idx = probe_call_count: event_log.append(f"remove-{idx}"))
remove_buttons.append(remove_btn)
return remove_btn
return None
return None
async def find_all_side_effect(selector_type:By, selector_value:str, *_:Any, **__:Any) -> list[MagicMock]:
if selector_type == By.CSS_SELECTOR and selector_value == "input[name^='adImages'][name$='.url']":
markers:list[MagicMock] = []
for index in range(3):
marker = MagicMock()
marker.attrs.value = f"https://img.example/{index}.jpg"
markers.append(marker)
return markers
return []
async def execute_side_effect(script:str) -> Any:
if "window.location.href" in script:
return test_bot.page.url
return None
async def upload_side_effect(*_:Any, **__:Any) -> None:
event_log.append("upload")
with (
patch.object(test_bot, "web_open", new_callable = AsyncMock),
patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_special_attributes", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_shipping", new_callable = AsyncMock),
patch.object(test_bot, "web_input", new_callable = AsyncMock),
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = probe_side_effect),
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
patch.object(test_bot, "web_click", new_callable = AsyncMock),
patch.object(test_bot, "web_execute", side_effect = execute_side_effect),
patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock),
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__set_contact_fields", new_callable = AsyncMock),
patch.object(test_bot, "_KleinanzeigenBot__upload_images", new_callable = AsyncMock, side_effect = upload_side_effect) as mock_upload,
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock),
patch.object(test_bot, "web_find", new_callable = AsyncMock),
patch.object(test_bot, "_web_find_all_once", new_callable = AsyncMock, side_effect = find_all_side_effect),
patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True),
):
await test_bot.publish_ad(ad_file, ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY)
assert sum(button.click.await_count for button in remove_buttons) == 3
mock_upload.assert_awaited_once()
assert event_log == ["remove-1", "remove-2", "remove-3", "upload"]
class TestTrackingFallback:
"""Tests for _try_recover_ad_id_from_redirect helper method."""
@pytest.mark.asyncio
async def test_extract_ad_id_from_referrer(self, test_bot:KleinanzeigenBot) -> None:
"""Ad ID should be extracted from document.referrer containing the confirmation URL."""
referrer_url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=3382410263"
with patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = referrer_url):
result = await test_bot._try_recover_ad_id_from_redirect()
assert result == 3382410263
@pytest.mark.asyncio
async def test_extract_ad_id_from_script_content(self, test_bot:KleinanzeigenBot) -> None:
"""When referrer has no confirmation URL, ad ID should be extracted from inline script content."""
referrer = "https://www.kleinanzeigen.de/m-meine-anzeigen.html"
script_content = (
'Belen.Tracking.initTrackingData({"page":"p-anzeige-aufgeben-bestaetigung.html?adId=44556677"});'
)
execute_returns = [referrer, script_content]
with patch.object(test_bot, "web_execute", new_callable = AsyncMock, side_effect = execute_returns):
result = await test_bot._try_recover_ad_id_from_redirect()
assert result == 44556677
@pytest.mark.asyncio
async def test_extract_ad_id_returns_none_when_not_found(self, test_bot:KleinanzeigenBot) -> None:
"""When neither referrer nor scripts contain a confirmation URL, None should be returned."""
execute_returns = [
"https://www.kleinanzeigen.de/m-meine-anzeigen.html", # referrer
"var x = 42;", # script content — no confirmation URL
]
with patch.object(test_bot, "web_execute", new_callable = AsyncMock, side_effect = execute_returns):
result = await test_bot._try_recover_ad_id_from_redirect()
assert result is None
@pytest.mark.asyncio
@pytest.mark.parametrize("referrer_value", ["", None], ids = ["empty-referrer", "none-referrer"])
async def test_extract_ad_id_falls_back_to_script_when_referrer_lacks_confirmation_url(
self, test_bot:KleinanzeigenBot, referrer_value:str | None,
) -> None:
"""When document.referrer is empty or None, the script scan fallback should extract the ad ID."""
script_content = 'initTrackingData("p-anzeige-aufgeben-bestaetigung.html?adId=11223344")'
execute_returns = [referrer_value, script_content]
with patch.object(test_bot, "web_execute", new_callable = AsyncMock, side_effect = execute_returns):
result = await test_bot._try_recover_ad_id_from_redirect()
assert result == 11223344