mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-07-09 20:51:05 +02:00
## ℹ️ Description Extract three module-level helpers from `set_special_attributes()` in `publishing_form.py` to reduce local variable count and improve readability. `set_special_attributes` had 27 locals — after extraction it has 10. - Behavior-preserving: no change to exception propagation, logging, or control flow. - `condition_s` probe stays in main to avoid translation-key churn. ## 📋 Changes Summary - Extract `_build_special_attribute_xpath()` — Pure XPath construction (fix comment: five → six patterns) - Extract `_resolve_special_attribute_element()` — Wraps `web_find_all` + candidate pick, propagates exceptions uncaught - Extract `_set_special_attribute_input()` — 6-branch input-type dispatch; hidden-input returns without logging, caller logs once - Rewire `set_special_attributes` to call the three helpers - Ratchet `pyproject.toml`: PLR0914 max-locals 27 → 20 - Update `translations.de.yaml`: move 4 debug messages + add `Failed to set attribute` under `_set_special_attribute_input` ### ⚙️ Type of Change - [ ] 🐞 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (adds new functionality without breaking existing usage) - [ ] 💥 Breaking change (changes that might break existing user setups, scripts, or configurations) - [x] ♻️ Refactor (non-breaking restructuring of existing code) ## ✅ Checklist Before requesting a review, confirm the following: - [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`). → 1268 passed, 4 skipped - [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. → Translation file updated <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **Chores** * Tightened linting rules by lowering the allowed maximum number of local variables. * **Refactor** * Reworked special-attribute DOM handling to use clearer selection and value-setting paths for different field types, including improved fallback behavior. * **Tests** * Added async unit test coverage for `set_special_attributes`, including input fallback behavior and error cases for unsupported checkbox values and missing hidden input metadata. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2905 lines
133 KiB
Python
2905 lines
133 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/
|
|
"""Tests for publishing form operations (contact/location fields, category selection, city selection, pricing)."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from collections.abc import Callable
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from typing import Any, Awaitable, Iterator
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from kleinanzeigen_bot import LOG
|
|
from kleinanzeigen_bot.ad_form_helpers import VERSAND_COMBOBOX_SELECTOR
|
|
from kleinanzeigen_bot.app import KleinanzeigenBot
|
|
from kleinanzeigen_bot.model.ad_model import Ad, AdUpdateStrategy
|
|
from kleinanzeigen_bot.model.config_model import PublishingConfig
|
|
from kleinanzeigen_bot.publishing_form import (
|
|
_select_button_combobox, # noqa: PLC2701 - needed for coverage of React fiber selection
|
|
_set_condition, # noqa: PLC2701
|
|
_special_attribute_candidate_priority, # noqa: PLC2701
|
|
city_option_text,
|
|
fill_image_section,
|
|
read_city_selection_text,
|
|
resolve_category_suggestions,
|
|
select_city_combobox_option,
|
|
set_category,
|
|
set_contact_fields,
|
|
set_contact_location,
|
|
set_pricing_fields,
|
|
set_shipping,
|
|
set_shipping_form,
|
|
set_shipping_options,
|
|
set_special_attributes,
|
|
upload_images,
|
|
)
|
|
from kleinanzeigen_bot.utils.exceptions import CategoryResolutionError
|
|
from kleinanzeigen_bot.utils.web_scraping_mixin import By, Element
|
|
|
|
|
|
class TestKleinanzeigenBotContactLocationHardening:
|
|
@pytest.mark.asyncio
|
|
async def test_city_option_text_falls_back_to_visible_text(self, test_bot:KleinanzeigenBot) -> None:
|
|
option = MagicMock(spec = Element)
|
|
option.text = ""
|
|
with patch.object(test_bot, "extract_visible_text", new_callable = AsyncMock, return_value = " Metroville "):
|
|
assert await city_option_text(test_bot, option) == "Metroville"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_city_option_text_returns_empty_when_visible_text_times_out(self, test_bot:KleinanzeigenBot) -> None:
|
|
option = MagicMock(spec = Element)
|
|
option.text = ""
|
|
|
|
with patch.object(test_bot, "extract_visible_text", new_callable = AsyncMock, side_effect = TimeoutError("hidden")):
|
|
assert not await city_option_text(test_bot, option)
|
|
|
|
@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 read_city_selection_text(test_bot)
|
|
|
|
assert selected == "Live City"
|
|
web_text_mock.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_city_selection_text_uses_selected_option_text(self, test_bot:KleinanzeigenBot) -> None:
|
|
city_button = MagicMock(spec = Element)
|
|
city_button.local_name = "button"
|
|
|
|
with (
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_button),
|
|
patch.object(test_bot, "web_text", new_callable = AsyncMock, return_value = "Selected City") as web_text_mock,
|
|
):
|
|
selected = await read_city_selection_text(test_bot)
|
|
|
|
assert selected == "Selected City"
|
|
web_text_mock.assert_awaited_once()
|
|
city_button.apply.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_city_selection_text_falls_back_to_element_text(self, test_bot:KleinanzeigenBot) -> None:
|
|
city_button = MagicMock(spec = Element)
|
|
city_button.local_name = "button"
|
|
city_button.apply = AsyncMock(return_value = "Button City")
|
|
|
|
with (
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_button),
|
|
patch.object(test_bot, "web_text", new_callable = AsyncMock, side_effect = TimeoutError("missing selected option")),
|
|
):
|
|
selected = await read_city_selection_text(test_bot)
|
|
|
|
assert selected == "Button City"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_city_selection_text_returns_none_when_city_field_missing(self, test_bot:KleinanzeigenBot) -> None:
|
|
with patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = TimeoutError("missing city")):
|
|
assert await read_city_selection_text(test_bot) is None
|
|
|
|
@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("kleinanzeigen_bot.publishing_form.set_contact_location", new_callable = AsyncMock) as set_location_mock,
|
|
pytest.raises(TimeoutError, match = "Failed to set contact zipcode"),
|
|
):
|
|
await set_contact_fields(test_bot, 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("kleinanzeigen_bot.publishing_form.set_contact_location", new_callable = AsyncMock) as set_location_mock,
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True),
|
|
):
|
|
await set_contact_fields(test_bot, ad_cfg.contact)
|
|
|
|
web_input_mock.assert_not_awaited()
|
|
set_location_mock.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_contact_fields_sets_optional_contact_inputs(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
config = base_ad_config | {"contact": base_ad_config["contact"] | {"street": "Test Street 1", "phone": "+491234567"}}
|
|
ad_cfg = Ad.model_validate(config)
|
|
checks = {
|
|
"ad-street": True,
|
|
"ad-name": False,
|
|
"ad-phone": True,
|
|
}
|
|
|
|
async def _web_check(_by:By, element_id:str, *_args:Any, **_kwargs:Any) -> bool:
|
|
return checks[element_id]
|
|
|
|
with (
|
|
patch.object(test_bot, "web_input", new_callable = AsyncMock),
|
|
patch("kleinanzeigen_bot.publishing_form.set_contact_location", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, side_effect = _web_check),
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock) as web_click_mock,
|
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock) as set_value_mock,
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = MagicMock(spec = Element)),
|
|
):
|
|
await set_contact_fields(test_bot, ad_cfg.contact)
|
|
|
|
assert any(call.args[:2] == (By.ID, "ad-address-visibility") for call in web_click_mock.await_args_list)
|
|
assert any(call.args[:2] == (By.ID, "ad-phone-visibility") for call in web_click_mock.await_args_list)
|
|
set_value_mock.assert_any_await("ad-street", ad_cfg.contact.street)
|
|
set_value_mock.assert_any_await("ad-name", ad_cfg.contact.name)
|
|
set_value_mock.assert_any_await("ad-phone", ad_cfg.contact.phone)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_contact_fields_skips_absent_phone_field(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
config = base_ad_config | {"contact": base_ad_config["contact"] | {"phone": "+491234567"}}
|
|
ad_cfg = Ad.model_validate(config)
|
|
|
|
with (
|
|
patch.object(test_bot, "web_input", new_callable = AsyncMock),
|
|
patch("kleinanzeigen_bot.publishing_form.set_contact_location", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock) as set_value_mock,
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None) as web_probe_mock,
|
|
):
|
|
await set_contact_fields(test_bot, ad_cfg.contact)
|
|
|
|
web_probe_mock.assert_awaited_once()
|
|
probe_args = web_probe_mock.await_args
|
|
assert probe_args is not None
|
|
assert probe_args.args == (By.ID, "ad-phone")
|
|
assert all(call.args[0] != "ad-phone" for call in set_value_mock.await_args_list)
|
|
|
|
@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(_web:KleinanzeigenBot, 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("kleinanzeigen_bot.publishing_form.read_city_selection_text", new_callable = AsyncMock, return_value = None),
|
|
patch("kleinanzeigen_bot.publishing_form.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 set_contact_location(test_bot, "Metroville")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_contact_location_returns_for_blank_location(self, test_bot:KleinanzeigenBot) -> None:
|
|
with patch("kleinanzeigen_bot.publishing_form.read_city_selection_text", new_callable = AsyncMock) as read_city_mock:
|
|
await set_contact_location(test_bot, " ")
|
|
|
|
read_city_mock.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_contact_location_raises_for_missing_city_element(self, test_bot:KleinanzeigenBot) -> None:
|
|
with (
|
|
patch("kleinanzeigen_bot.publishing_form.read_city_selection_text", new_callable = AsyncMock, return_value = None),
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = None),
|
|
pytest.raises(TimeoutError, match = "Unsupported city element type while setting contact location: <missing>"),
|
|
):
|
|
await set_contact_location(test_bot, "Metroville")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_contact_location_raises_for_unsupported_city_element(self, test_bot:KleinanzeigenBot) -> None:
|
|
city_input = MagicMock(spec = Element)
|
|
city_input.local_name = "input"
|
|
city_input.attrs = {}
|
|
|
|
with (
|
|
patch("kleinanzeigen_bot.publishing_form.read_city_selection_text", new_callable = AsyncMock, return_value = None),
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_input),
|
|
pytest.raises(TimeoutError, match = "Unsupported city element type while setting contact location: <input>"),
|
|
):
|
|
await set_contact_location(test_bot, "Metroville")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_city_combobox_option_raises_when_options_do_not_load(self, test_bot:KleinanzeigenBot) -> None:
|
|
city_button = MagicMock(spec = Element)
|
|
city_button.attrs = {}
|
|
|
|
with (
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_button),
|
|
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, side_effect = TimeoutError("not ready")),
|
|
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = TimeoutError("condition timeout")),
|
|
pytest.raises(TimeoutError, match = "City combobox options did not load for location: Metroville"),
|
|
):
|
|
await select_city_combobox_option(test_bot, "Metroville")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_city_combobox_option_raises_when_no_option_matches(self, test_bot:KleinanzeigenBot) -> None:
|
|
city_button = MagicMock(spec = Element)
|
|
city_button.attrs = {"aria-controls": "custom-city-list extra-token"}
|
|
option = MagicMock(spec = Element)
|
|
option.text = "10115 - Metroville"
|
|
|
|
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_click", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_button),
|
|
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = [option]),
|
|
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = _web_await_side_effect),
|
|
pytest.raises(TimeoutError, match = "No city combobox option matched location: Rivertown"),
|
|
):
|
|
await select_city_combobox_option(test_bot, "Rivertown")
|
|
|
|
@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("kleinanzeigen_bot.publishing_form.read_city_selection_text", new_callable = AsyncMock, return_value = "20095 - Rivertown"),
|
|
pytest.raises(TimeoutError, match = "City selection did not converge"),
|
|
):
|
|
await set_contact_location(test_bot, "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("kleinanzeigen_bot.publishing_form.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("kleinanzeigen_bot.publishing_form.select_city_combobox_option", new_callable = AsyncMock) as combobox_mock,
|
|
):
|
|
await set_contact_location(test_bot, "Metroville")
|
|
combobox_mock.assert_not_called()
|
|
|
|
# ------------------------------------------------------------------
|
|
# read_city_selection_text: edge-case branches
|
|
# ------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_city_selection_text_returns_none_when_find_returns_none(self, test_bot:KleinanzeigenBot) -> None:
|
|
"""web_find succeeds but returns None (not a TimeoutError) — edge case, but handled."""
|
|
with patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = None):
|
|
assert await read_city_selection_text(test_bot) is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_city_selection_text_final_fallback_to_by_id_ad_city(self, test_bot:KleinanzeigenBot) -> None:
|
|
"""When selected-option and textContent both fail, fall back to web_text(By.ID, 'ad-city')."""
|
|
city_button = MagicMock(spec = Element)
|
|
city_button.local_name = "button"
|
|
city_button.apply = AsyncMock(return_value = "") # empty textContent
|
|
|
|
with (
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_button),
|
|
patch.object(test_bot, "web_text", new_callable = AsyncMock, side_effect = [
|
|
TimeoutError("no selected option"),
|
|
"Fallback City",
|
|
]),
|
|
):
|
|
selected = await read_city_selection_text(test_bot)
|
|
|
|
assert selected == "Fallback City"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_city_selection_text_final_fallback_times_out(self, test_bot:KleinanzeigenBot) -> None:
|
|
"""When every lookup strategy fails, return None."""
|
|
city_button = MagicMock(spec = Element)
|
|
city_button.local_name = "button"
|
|
city_button.apply = AsyncMock(return_value = "") # empty textContent
|
|
|
|
with (
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_button),
|
|
patch.object(test_bot, "web_text", new_callable = AsyncMock, side_effect = [
|
|
TimeoutError("no selected option"),
|
|
TimeoutError("ad-city also missing"),
|
|
]),
|
|
):
|
|
selected = await read_city_selection_text(test_bot)
|
|
|
|
assert selected is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_city_selection_text_returns_none_when_input_apply_empty_and_web_text_times_out(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
) -> None:
|
|
"""Input element with empty apply value, then both web_text calls time out — return None."""
|
|
city_input = MagicMock(spec = Element)
|
|
city_input.local_name = "input"
|
|
city_input.apply = AsyncMock(return_value = " ") # blank after trim
|
|
|
|
with (
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_input),
|
|
patch.object(test_bot, "web_text", new_callable = AsyncMock, side_effect = [
|
|
TimeoutError("no selected option"),
|
|
TimeoutError("ad-city missing"),
|
|
]),
|
|
):
|
|
selected = await read_city_selection_text(test_bot)
|
|
|
|
assert selected is None
|
|
|
|
# ------------------------------------------------------------------
|
|
# select_city_combobox_option: prefix-match ambiguity branch
|
|
# ------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_city_combobox_option_raises_on_ambiguous_prefix_match(self, test_bot:KleinanzeigenBot) -> None:
|
|
"""When multiple options share the same city prefix (no ' - ' in target), raise TimeoutError."""
|
|
city_button = MagicMock(spec = Element)
|
|
city_button.attrs = {"aria-controls": "ad-city-menu"}
|
|
|
|
option_a = MagicMock(spec = Element)
|
|
option_a.text = "Berlin - Mitte"
|
|
option_b = MagicMock(spec = Element)
|
|
option_b.text = "Berlin - Spandau"
|
|
|
|
async def _web_await_side_effect(condition:Callable[..., Awaitable[bool] | bool], **_:Any) -> Any:
|
|
result = condition()
|
|
condition_value = await result if asyncio.iscoroutine(result) else result
|
|
if condition_value:
|
|
return condition_value
|
|
raise TimeoutError("Condition not met")
|
|
|
|
def _city_option_text_side(_web:KleinanzeigenBot, elem:Element) -> str:
|
|
return str(getattr(elem, "text", "") or "").strip()
|
|
|
|
with (
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_button),
|
|
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("kleinanzeigen_bot.publishing_form.city_option_text", new_callable = AsyncMock, side_effect = _city_option_text_side),
|
|
pytest.raises(TimeoutError, match = "ambiguous for location: Berlin"),
|
|
):
|
|
await select_city_combobox_option(test_bot, "Berlin")
|
|
|
|
# ------------------------------------------------------------------
|
|
# select_city_combobox_option: web_find_all TimeoutError caught inside _options_available
|
|
# ------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_city_combobox_option_handles_find_all_timeout_in_options_available(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
) -> None:
|
|
"""_options_available catches TimeoutError from web_find_all and returns False, then web_await times out."""
|
|
city_button = MagicMock(spec = Element)
|
|
city_button.attrs = {"aria-controls": "ad-city-menu"}
|
|
|
|
async def _web_await_driver(condition:Callable[..., Awaitable[bool] | bool], **_:Any) -> Any:
|
|
result = condition()
|
|
condition_value = await result if asyncio.iscoroutine(result) else result
|
|
if condition_value:
|
|
return condition_value
|
|
raise TimeoutError("Condition not met")
|
|
|
|
with (
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = city_button),
|
|
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, side_effect = TimeoutError("not ready")),
|
|
patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = _web_await_driver),
|
|
pytest.raises(TimeoutError, match = "City combobox options did not load for location: Townsville"),
|
|
):
|
|
await select_city_combobox_option(test_bot, "Townsville")
|
|
|
|
# ------------------------------------------------------------------
|
|
# set_contact_fields: non-abort TimeoutError warning paths
|
|
# ------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_contact_fields_warns_on_street_timeout(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
|
|
"""A TimeoutError during street input is logged, not re-raised."""
|
|
config = base_ad_config | {"contact": base_ad_config["contact"] | {"street": "Test Street 1"}}
|
|
ad_cfg = Ad.model_validate(config)
|
|
|
|
with (
|
|
patch.object(test_bot, "web_input", new_callable = AsyncMock),
|
|
patch("kleinanzeigen_bot.publishing_form.set_contact_location", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, side_effect = TimeoutError("street check timeout")),
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock),
|
|
):
|
|
# Should not raise — timeout is caught and logged
|
|
await set_contact_fields(test_bot, ad_cfg.contact)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_contact_fields_warns_on_name_timeout(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
|
|
"""A TimeoutError during name input is logged, not re-raised."""
|
|
config = base_ad_config | {"contact": base_ad_config["contact"] | {"name": "Test Name"}}
|
|
ad_cfg = Ad.model_validate(config)
|
|
|
|
with (
|
|
patch.object(test_bot, "web_input", new_callable = AsyncMock),
|
|
patch("kleinanzeigen_bot.publishing_form.set_contact_location", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, side_effect = TimeoutError("name check timeout")),
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock),
|
|
):
|
|
await set_contact_fields(test_bot, ad_cfg.contact)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_contact_fields_warns_on_phone_set_value_timeout(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""A TimeoutError during phone input is logged, not re-raised."""
|
|
config = base_ad_config | {"contact": base_ad_config["contact"] | {"phone": "+491234567"}}
|
|
ad_cfg = Ad.model_validate(config)
|
|
|
|
with (
|
|
patch.object(test_bot, "web_input", new_callable = AsyncMock),
|
|
patch("kleinanzeigen_bot.publishing_form.set_contact_location", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True),
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock, side_effect = TimeoutError("phone input timeout")),
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = MagicMock(spec = Element)),
|
|
):
|
|
await set_contact_fields(test_bot, ad_cfg.contact)
|
|
|
|
|
|
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 set_category(test_bot, root_url = test_bot.root_url, category = "185/249", ad_file = "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 set_category(test_bot, root_url = test_bot.root_url, category = None, ad_file = "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 resolve_category_suggestions(test_bot, "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 resolve_category_suggestions(test_bot, "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 resolve_category_suggestions(test_bot, "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 resolve_category_suggestions(test_bot, "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 resolve_category_suggestions(test_bot, "76/77")
|
|
|
|
mock_click.assert_awaited_once()
|
|
assert "label[@for='id-for-77']" in mock_click.call_args.args[1]
|
|
|
|
|
|
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 []
|
|
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 upload_images(test_bot, 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 []
|
|
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 upload_images(test_bot, 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 upload_images(test_bot, 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 upload_images(test_bot, 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_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 [self._build_marker("https://img.example/a.jpg"), self._build_marker("https://img.example/b.jpg")]
|
|
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 upload_images(test_bot, 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_fill_image_section_removes_existing_images_before_upload(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
tmp_path:Path,
|
|
) -> None:
|
|
"""Cleanup should probe and click remove buttons before upload."""
|
|
image_path = tmp_path / "img.jpg"
|
|
image_path.write_bytes(b"\xff\xd8\xff")
|
|
ad_cfg = Ad.model_validate(base_ad_config | {"images": [str(image_path)]})
|
|
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']":
|
|
return [self._build_marker(f"https://img.example/{index}.jpg") for index in range(3)]
|
|
return []
|
|
|
|
async def upload_side_effect(*_:Any, **__:Any) -> None:
|
|
event_log.append("upload")
|
|
|
|
with (
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = probe_side_effect),
|
|
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("kleinanzeigen_bot.publishing_form.upload_images", new_callable = AsyncMock, side_effect = upload_side_effect) as mock_upload,
|
|
):
|
|
await fill_image_section(test_bot, ad_cfg)
|
|
|
|
assert sum(button.click.await_count for button in remove_buttons) == 3
|
|
mock_upload.assert_awaited_once_with(test_bot, ad_cfg)
|
|
assert event_log == ["remove-1", "remove-2", "remove-3", "upload"]
|
|
|
|
|
|
class TestPricingFields:
|
|
"""Tests for pricing, direct-buy, and description field filling."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("price_type", "price", "expected_idx"),
|
|
[
|
|
("FIXED", 100, 0),
|
|
("NEGOTIABLE", 100, 1),
|
|
("GIVE_AWAY", None, 2),
|
|
],
|
|
)
|
|
async def test_price_type_dropdown_click(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
price_type:str,
|
|
price:int | None,
|
|
expected_idx:int,
|
|
) -> None:
|
|
"""Price type dropdown should click the correct option index."""
|
|
ad_cfg = Ad.model_validate(base_ad_config | {"price_type": price_type, "price": price})
|
|
|
|
with (
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
|
|
patch("kleinanzeigen_bot.publishing_form.get_ad_description", return_value = "desc"),
|
|
):
|
|
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
|
|
|
|
mock_click.assert_any_await(By.ID, "ad-price-type")
|
|
mock_click.assert_any_await(By.ID, f"ad-price-type-menu-option-{expected_idx}")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_price_type_not_applicable_skips_dropdown(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""NOT_APPLICABLE price type should skip all price interactions."""
|
|
ad_cfg = Ad.model_validate(base_ad_config | {"price_type": "NOT_APPLICABLE"})
|
|
|
|
with (
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock) as mock_set,
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
|
|
patch("kleinanzeigen_bot.publishing_form.get_ad_description", return_value = "desc"),
|
|
):
|
|
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
|
|
|
|
ad_price_type_clicks = [c for c in mock_click.call_args_list if len(c.args) >= 2 and c.args[1] == "ad-price-type"]
|
|
assert not ad_price_type_clicks
|
|
ad_price_amount_sets = [c for c in mock_set.call_args_list if c.args[0] == "ad-price-amount"]
|
|
assert not ad_price_amount_sets
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_price_amount_set_when_provided(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""Price amount should be set when price is not None."""
|
|
ad_cfg = Ad.model_validate(base_ad_config | {"price": 42})
|
|
|
|
with (
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock) as mock_set,
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
|
|
patch("kleinanzeigen_bot.publishing_form.get_ad_description", return_value = "desc"),
|
|
):
|
|
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
|
|
|
|
mock_set.assert_any_await("ad-price-amount", "42")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_price_amount_not_set_when_none(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""Price amount should not be set when price is None."""
|
|
ad_cfg = Ad.model_validate(base_ad_config | {"price_type": "NEGOTIABLE", "price": None})
|
|
|
|
with (
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock) as mock_set,
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
|
|
patch("kleinanzeigen_bot.publishing_form.get_ad_description", return_value = "desc"),
|
|
):
|
|
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
|
|
|
|
ad_price_amount_sets = [c for c in mock_set.call_args_list if c.args[0] == "ad-price-amount"]
|
|
assert not ad_price_amount_sets
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_price_type_timeout_raises(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""Timeout on price type click should be re-raised with meaningful message."""
|
|
ad_cfg = Ad.model_validate(base_ad_config | {"price_type": "FIXED"})
|
|
|
|
with (
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock, side_effect = TimeoutError("boom")),
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
|
|
patch("kleinanzeigen_bot.publishing_form.get_ad_description", return_value = "desc"),
|
|
pytest.raises(TimeoutError, match = "Failed to set price type 'FIXED'"),
|
|
):
|
|
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
|
|
|
|
@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],
|
|
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})
|
|
|
|
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 (
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = probe_side_effect),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, side_effect = check_side_effect),
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock),
|
|
patch("kleinanzeigen_bot.publishing_form.get_ad_description", return_value = "desc"),
|
|
):
|
|
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
|
|
|
|
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],
|
|
caplog:pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Shipping ads with sell_directly enabled should continue 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,
|
|
"shipping_options": ["DHL_2"]})
|
|
|
|
with (
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock) as mock_set,
|
|
patch("kleinanzeigen_bot.publishing_form.get_ad_description", return_value = "desc"),
|
|
):
|
|
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
|
|
|
|
# Assert warning about category-unavailable buy-now
|
|
warning_messages = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
|
assert any("sell_directly" in m and "not available" in m for m in warning_messages), \
|
|
"Should log warning when buy-now-true is unavailable"
|
|
|
|
# Assert continuation (description field was set after the warning)
|
|
mock_set.assert_any_await("ad-description", "desc")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sell_directly_without_shipping_options_raises(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""Direct-buy without predefined shipping_options should raise ValueError (publishing guard).
|
|
|
|
Uses model_construct to bypass Phase 1 model validator and test the publishing
|
|
guard independently.
|
|
"""
|
|
ad_cfg = Ad.model_construct(
|
|
**base_ad_config | {
|
|
"shipping_type": "SHIPPING",
|
|
"sell_directly": True,
|
|
"price_type": "FIXED",
|
|
"price": 100,
|
|
"shipping_options": [],
|
|
}
|
|
)
|
|
|
|
with (
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock) as mock_probe,
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock),
|
|
patch("kleinanzeigen_bot.publishing_form.get_ad_description", return_value = "desc"),
|
|
pytest.raises(ValueError, match = "Direct-buy.*shipping_options"),
|
|
):
|
|
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
|
|
|
|
# Price type dropdown may be clicked before the guard fires; the guard
|
|
# must prevent buy-now-true from being probed/clicked.
|
|
buy_now_probes = [c for c in mock_probe.await_args_list if len(c.args) >= 2 and "ad-buy-now" in str(c.args[1])]
|
|
assert not buy_now_probes, "guard must prevent buy-now DOM probing"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_buy_now_true_interaction_timeout_propagates(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> 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,
|
|
"shipping_options": ["DHL_2"]})
|
|
|
|
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 (
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = probe_side_effect),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, side_effect = check_side_effect),
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock),
|
|
patch("kleinanzeigen_bot.publishing_form.get_ad_description", return_value = "desc"),
|
|
pytest.raises(TimeoutError, match = "check timeout"),
|
|
):
|
|
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sell_directly_false_clicks_buy_now_false(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""When sell_directly is False and shipping is SHIPPING, ad-buy-now-false should be clicked."""
|
|
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "sell_directly": False, "price_type": "FIXED", "price": 100})
|
|
|
|
buy_now_false_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":
|
|
return buy_now_false_elem
|
|
return None
|
|
|
|
async def check_side_effect(selector_type:By, selector_value:str, *_:Any, **__:Any) -> bool:
|
|
return False # not already selected
|
|
|
|
with (
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = probe_side_effect),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, side_effect = check_side_effect),
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock),
|
|
patch("kleinanzeigen_bot.publishing_form.get_ad_description", return_value = "desc"),
|
|
):
|
|
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
|
|
|
|
buy_now_false_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"]
|
|
assert buy_now_false_clicks
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_description_filled_via_get_ad_description(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""Description field should be set using get_ad_description result."""
|
|
ad_cfg = Ad.model_validate(base_ad_config)
|
|
|
|
with (
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock) as mock_set,
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
|
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False),
|
|
patch("kleinanzeigen_bot.publishing_form.get_ad_description", return_value = "Expected description text") as mock_desc,
|
|
):
|
|
await set_pricing_fields(test_bot, ad_cfg, test_bot.config.ad_defaults)
|
|
|
|
mock_desc.assert_called_once_with(ad_cfg, test_bot.config.ad_defaults, with_affixes = True)
|
|
mock_set.assert_any_await("ad-description", "Expected description text")
|
|
|
|
|
|
class TestShippingDialogFlow:
|
|
"""Regression tests for shipping dialog flow using new radio selectors only."""
|
|
|
|
shipping_combobox_selector = VERSAND_COMBOBOX_SELECTOR
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("shipping_type", "expected_label"),
|
|
[("SHIPPING", "Versand möglich"), ("PICKUP", "Nur Abholung")],
|
|
)
|
|
async def test_shipping_uses_versand_combobox_when_rendered(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
shipping_type:str,
|
|
expected_label:str,
|
|
) -> None:
|
|
"""Commercial accounts may render Versand as a special-attribute combobox instead of radio buttons."""
|
|
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": shipping_type})
|
|
shipping_combobox = MagicMock()
|
|
shipping_combobox.attrs = {"id": "uhren.versand"}
|
|
|
|
with (
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = shipping_combobox) as mock_probe,
|
|
patch.object(test_bot, "web_select_button_combobox", new_callable = AsyncMock) as mock_select_combobox,
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
|
):
|
|
await set_shipping(test_bot, ad_cfg)
|
|
|
|
mock_probe.assert_awaited_once()
|
|
assert mock_probe.await_args is not None
|
|
assert mock_probe.await_args.args[:2] == (
|
|
By.CSS_SELECTOR,
|
|
self.shipping_combobox_selector,
|
|
)
|
|
mock_select_combobox.assert_awaited_once()
|
|
assert mock_select_combobox.await_args is not None
|
|
assert mock_select_combobox.await_args.args[:2] == ("uhren.versand", expected_label)
|
|
mock_click.assert_not_awaited()
|
|
|
|
@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, side_effect = [None, 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 set_shipping(test_bot, ad_cfg)
|
|
|
|
observed = [call.args[:2] for call in mock_probe.await_args_list]
|
|
assert (By.CSS_SELECTOR, self.shipping_combobox_selector) in observed
|
|
assert (By.ID, "ad-shipping-enabled-no") in observed
|
|
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, side_effect = [None, 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 set_shipping(test_bot, 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, 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 set_shipping(test_bot, 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, 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 set_shipping(test_bot, ad_cfg)
|
|
|
|
observed = [call.args[:2] for call in mock_probe.await_args_list]
|
|
assert (By.CSS_SELECTOR, self.shipping_combobox_selector) in observed
|
|
assert (By.ID, "ad-shipping-enabled-no") in observed
|
|
assert (By.ID, "ad-shipping-enabled") in observed
|
|
mock_check.assert_not_awaited()
|
|
mock_click.assert_not_awaited()
|
|
|
|
# ------------------------------------------------------------------
|
|
# No-options branch: fail-fast for configured costs, platform-default for none
|
|
# ------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shipping_no_options_with_costs_raises(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""No shipping_options with shipping_costs: raise ValueError (fail-fast)."""
|
|
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),
|
|
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
|
pytest.raises(ValueError, match = "shipping_costs.*no longer supported"),
|
|
):
|
|
await set_shipping(test_bot, ad_cfg)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shipping_no_options_no_costs_platform_default(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
caplog:pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""No shipping_options and no shipping_costs: click enabled, debug log only, no warning."""
|
|
caplog.set_level(logging.WARNING, logger = LOG.name)
|
|
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": None})
|
|
|
|
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),
|
|
):
|
|
await set_shipping(test_bot, ad_cfg)
|
|
|
|
# Must click shipping-enabled-yes
|
|
enabled_yes_clicks = [
|
|
c for c in mock_click.await_args_list
|
|
if len(c.args) >= 2 and c.args[0] == By.ID and c.args[1] == "ad-shipping-enabled-yes"
|
|
]
|
|
assert enabled_yes_clicks, "Should click shipping-enabled-yes"
|
|
|
|
# Must NOT open shipping-options dialog
|
|
options_clicks = [
|
|
c for c in mock_click.await_args_list
|
|
if len(c.args) >= 2 and c.args[0] == By.ID and c.args[1] == "ad-shipping-options"
|
|
]
|
|
assert not options_clicks, "Must NOT open shipping-options dialog"
|
|
|
|
# Must NOT touch any individual-shipping selectors
|
|
individual_clicks = [
|
|
c for c in mock_click.await_args_list
|
|
if len(c.args) >= 2 and "ad-individual-shipping" in str(c.args[1])
|
|
]
|
|
assert not individual_clicks, "Must NOT touch individual-shipping selectors"
|
|
|
|
# No warning should be logged (only debug)
|
|
warning_messages = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
|
assert not any("shipping_costs" in m for m in warning_messages)
|
|
assert not any("Individual shipping" in m for m in warning_messages)
|
|
|
|
# ------------------------------------------------------------------
|
|
# set_shipping commercial/pro combobox: error paths
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
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 set_shipping_options(test_bot, 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 set_shipping_options(test_bot, 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 set_shipping_options(test_bot, 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 set_shipping_options(test_bot, 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 set_shipping_options(test_bot, 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 set_shipping_options(test_bot, ad_cfg)
|
|
|
|
# ------------------------------------------------------------------
|
|
# set_shipping_options: pre-DOM validation and close failure
|
|
# ------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shipping_options_missing_raises_value_error(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""Empty shipping_options should raise ValueError before any DOM interaction."""
|
|
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": []})
|
|
|
|
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 = "shipping_options must be provided"),
|
|
):
|
|
await set_shipping_options(test_bot, ad_cfg)
|
|
|
|
mock_find.assert_not_awaited()
|
|
mock_click.assert_not_awaited()
|
|
mock_sleep.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shipping_options_close_timeout_raises(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""Timeout on the final Fertig click should raise TimeoutError."""
|
|
ad_cfg = self._make_ad_with_options(base_ad_config, ["Hermes_Päckchen"])
|
|
|
|
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("Fertig click timeout")
|
|
|
|
radio_mock = MagicMock()
|
|
radio_mock.attrs = {"checked": ""}
|
|
checkbox_mock = MagicMock()
|
|
checkbox_mock.attrs = {"checked": ""}
|
|
|
|
async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock:
|
|
return radio_mock if "radio" in selector_value else checkbox_mock
|
|
|
|
with (
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = find_side_effect),
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock, side_effect = click_side_effect),
|
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
|
pytest.raises(TimeoutError, match = "Unable to close shipping dialog!"),
|
|
):
|
|
await set_shipping_options(test_bot, ad_cfg)
|
|
|
|
@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("kleinanzeigen_bot.publishing_form.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()
|
|
|
|
|
|
class TestWantedShippingSelection:
|
|
"""Tests for WANTED shipping path via set_shipping_form.
|
|
|
|
WANTED ads render shipping as a special-attribute combobox dropdown
|
|
(``<button role="combobox">``) rather than radio buttons. These tests
|
|
verify ``set_shipping_form`` directly with WANTED ad configurations.
|
|
"""
|
|
|
|
@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],
|
|
shipping_type:str,
|
|
expected_label:str,
|
|
) -> None:
|
|
"""WANTED ads should select shipping via button-combobox dropdown using VERSAND_COMBOBOX_SELECTOR."""
|
|
ad_cfg = Ad.model_validate(
|
|
base_ad_config
|
|
| {
|
|
"type": "WANTED",
|
|
"shipping_type": shipping_type,
|
|
"shipping_options": [],
|
|
}
|
|
)
|
|
|
|
combobox_btn = MagicMock()
|
|
combobox_btn.attrs = {"id": "babyausstattung.versand"}
|
|
|
|
with (
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = combobox_btn) as mock_find,
|
|
patch.object(test_bot, "web_select_button_combobox", new_callable = AsyncMock) as mock_select_btn_combo,
|
|
):
|
|
await set_shipping_form(test_bot, ad_cfg)
|
|
|
|
mock_find.assert_awaited_once()
|
|
assert mock_find.await_args is not None
|
|
assert mock_find.await_args.args == (
|
|
By.CSS_SELECTOR,
|
|
VERSAND_COMBOBOX_SELECTOR,
|
|
)
|
|
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],
|
|
) -> None:
|
|
"""WANTED shipping should fail with TimeoutError when the combobox button cannot be found."""
|
|
ad_cfg = Ad.model_validate(
|
|
base_ad_config
|
|
| {
|
|
"type": "WANTED",
|
|
"shipping_type": "SHIPPING",
|
|
"shipping_options": [],
|
|
}
|
|
)
|
|
|
|
with (
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = TimeoutError("combobox not found in DOM")),
|
|
patch.object(test_bot, "web_select_button_combobox", new_callable = AsyncMock),
|
|
pytest.raises(TimeoutError, match = "Failed to set shipping attribute for type 'SHIPPING'!"),
|
|
):
|
|
await set_shipping_form(test_bot, ad_cfg)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wanted_shipping_not_applicable_skips_combobox(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> 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": [],
|
|
}
|
|
)
|
|
|
|
with (
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find,
|
|
patch.object(test_bot, "web_select_button_combobox", new_callable = AsyncMock) as mock_select_btn_combo,
|
|
):
|
|
await set_shipping_form(test_bot, ad_cfg)
|
|
|
|
mock_find.assert_not_awaited()
|
|
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],
|
|
) -> None:
|
|
"""WANTED shipping should fail with TimeoutError when the combobox button has no id attribute."""
|
|
ad_cfg = Ad.model_validate(
|
|
base_ad_config
|
|
| {
|
|
"type": "WANTED",
|
|
"shipping_type": "SHIPPING",
|
|
"shipping_options": [],
|
|
}
|
|
)
|
|
|
|
combobox_btn = MagicMock()
|
|
combobox_btn.attrs = {} # No "id" key
|
|
|
|
with (
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = combobox_btn),
|
|
patch.object(test_bot, "web_select_button_combobox", new_callable = AsyncMock),
|
|
pytest.raises(TimeoutError, match = "Failed to set shipping attribute for type 'SHIPPING'!"),
|
|
):
|
|
await set_shipping_form(test_bot, ad_cfg)
|
|
|
|
|
|
class TestSelectButtonCombobox:
|
|
"""Tests for _select_button_combobox (React fiber combobox selection).
|
|
|
|
``_select_button_combobox`` is the internal helper used for special-attribute
|
|
comboboxes where the option list is embedded in the React fiber tree. It clicks
|
|
the button to open the menu, waits for the listbox, then executes JS to find
|
|
and click the matching option by value.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_button_combobox_success(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
) -> None:
|
|
"""Successful selection: clicks button, waits for listbox, executes JS, returns True."""
|
|
elem_id = "my-combobox-id"
|
|
option_value = "option_value"
|
|
|
|
with (
|
|
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_execute", new_callable = AsyncMock, return_value = True) as mock_execute,
|
|
):
|
|
await _select_button_combobox(test_bot, elem_id, option_value)
|
|
|
|
mock_click.assert_awaited_once_with(By.ID, elem_id)
|
|
mock_find.assert_awaited_once_with(By.ID, f"{elem_id}-menu")
|
|
mock_execute.assert_awaited_once()
|
|
assert mock_execute.await_args is not None
|
|
js = str(mock_execute.await_args.args[0])
|
|
assert "my-combobox-id" in js
|
|
assert "my-combobox-id-menu" in js
|
|
assert "option_value" in js
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_select_button_combobox_js_returns_false(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
) -> None:
|
|
"""When the JS fiber walk returns False, TimeoutError is raised."""
|
|
elem_id = "my-combobox-id"
|
|
option_value = "missing_option"
|
|
|
|
with (
|
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_find", new_callable = AsyncMock),
|
|
patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = False),
|
|
pytest.raises(TimeoutError, match = "not found in button combobox"),
|
|
):
|
|
await _select_button_combobox(test_bot, elem_id, option_value)
|
|
|
|
|
|
class TestSpecialAttributes:
|
|
"""Tests for special attribute handling."""
|
|
|
|
@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 set_special_attributes(test_bot, 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("kleinanzeigen_bot.publishing_form._select_button_combobox", new_callable = AsyncMock) as mock_button_combobox,
|
|
patch.object(test_bot, "web_input", new_callable = AsyncMock) as mock_input,
|
|
):
|
|
await set_special_attributes(test_bot, ad_cfg)
|
|
|
|
mock_button_combobox.assert_awaited_once_with(test_bot, "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 set_special_attributes(test_bot, 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 set_special_attributes(test_bot, 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(
|
|
"kleinanzeigen_bot.publishing_form._select_button_combobox",
|
|
new_callable = AsyncMock,
|
|
) as mock_select_combobox,
|
|
patch.object(test_bot, "web_input", new_callable = AsyncMock) as mock_input,
|
|
):
|
|
await set_special_attributes(test_bot, ad_cfg)
|
|
|
|
mock_find_button.assert_awaited_once_with(hidden_input_name = "attributeMap[groesse]")
|
|
mock_select_combobox.assert_awaited_once_with(test_bot, ":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 set_special_attributes(test_bot, 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(
|
|
"kleinanzeigen_bot.publishing_form._select_button_combobox",
|
|
new_callable = AsyncMock,
|
|
) as mock_select_combobox,
|
|
):
|
|
await set_special_attributes(test_bot, ad_cfg)
|
|
|
|
mock_find_button.assert_not_awaited()
|
|
mock_select_combobox.assert_awaited_once_with(test_bot, "kleidung_herren.type", "accessoires")
|
|
|
|
def test_special_attribute_candidate_priority_textarea(self) -> None:
|
|
"""A textarea element should return priority (4, 0)."""
|
|
elem = MagicMock()
|
|
elem.local_name = "textarea"
|
|
elem.attrs = {}
|
|
assert _special_attribute_candidate_priority(elem) == (4, 0)
|
|
|
|
def test_special_attribute_candidate_priority_fallback(self) -> None:
|
|
"""An unrecognized element (e.g. div with no matching attrs) should return priority (8, 0)."""
|
|
elem = MagicMock()
|
|
elem.local_name = "div"
|
|
elem.attrs = {}
|
|
assert _special_attribute_candidate_priority(elem) == (8, 0)
|
|
|
|
|
|
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 _set_condition(test_bot, "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 _set_condition(test_bot, 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 _set_condition(test_bot, "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 _set_condition(test_bot, "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 _set_condition(test_bot, "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 _set_condition(test_bot, "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(
|
|
"kleinanzeigen_bot.publishing_form._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(
|
|
"kleinanzeigen_bot.publishing_form._select_button_combobox",
|
|
new_callable = AsyncMock,
|
|
) as mock_select_combobox,
|
|
):
|
|
await set_special_attributes(test_bot, ad_cfg)
|
|
|
|
mock_set_condition.assert_awaited_once_with(test_bot, condition_s)
|
|
mock_select_combobox.assert_awaited_once_with(test_bot, "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(
|
|
"kleinanzeigen_bot.publishing_form._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 set_special_attributes(test_bot, 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("kleinanzeigen_bot.publishing_form._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("kleinanzeigen_bot.publishing_form._select_button_combobox", new_callable = AsyncMock) as mock_select_combobox,
|
|
):
|
|
await set_special_attributes(test_bot, ad_cfg)
|
|
|
|
mock_set_condition.assert_awaited_once_with(test_bot, "ok")
|
|
mock_select_combobox.assert_awaited_once_with(test_bot, "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("kleinanzeigen_bot.publishing_form._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 set_special_attributes(test_bot, ad_cfg)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_special_attributes_text_input_fallback(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""A plain input with no special type or role should use web_input."""
|
|
ad_cfg = Ad.model_validate(
|
|
base_ad_config
|
|
| {
|
|
"special_attributes": {"title": "My Title"},
|
|
"updated_on": "2024-01-01T00:00:00",
|
|
"created_on": "2024-01-01T00:00:00",
|
|
}
|
|
)
|
|
|
|
text_elem = MagicMock()
|
|
text_attrs = MagicMock()
|
|
text_attrs.get.side_effect = lambda key, default = None: {
|
|
"id": "title",
|
|
"type": None,
|
|
"role": None,
|
|
}.get(key, default)
|
|
text_elem.attrs = text_attrs
|
|
text_elem.local_name = "input"
|
|
|
|
with (
|
|
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = [text_elem]),
|
|
patch.object(test_bot, "web_input", new_callable = AsyncMock) as mock_input,
|
|
):
|
|
await set_special_attributes(test_bot, ad_cfg)
|
|
|
|
mock_input.assert_awaited_once_with(By.ID, "title", "My Title")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_special_attributes_unsupported_checkbox_value_raises(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""An unsupported checkbox value should raise TimeoutError."""
|
|
ad_cfg = Ad.model_validate(
|
|
base_ad_config
|
|
| {
|
|
"special_attributes": {"feature_b": "maybe"},
|
|
"updated_on": "2024-01-01T00:00:00",
|
|
"created_on": "2024-01-01T00:00:00",
|
|
}
|
|
)
|
|
|
|
checkbox_elem = MagicMock()
|
|
checkbox_attrs = MagicMock()
|
|
checkbox_attrs.get.side_effect = lambda key, default = None: {
|
|
"id": "feature",
|
|
"name": "attributeMap[feature]",
|
|
"type": "checkbox",
|
|
"role": None,
|
|
}.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]),
|
|
pytest.raises(TimeoutError, match = r"Failed to set attribute 'feature_b'"),
|
|
):
|
|
await set_special_attributes(test_bot, ad_cfg)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hidden_input_fallback_no_name_raises(
|
|
self,
|
|
test_bot:KleinanzeigenBot,
|
|
base_ad_config:dict[str, Any],
|
|
) -> None:
|
|
"""A hidden input without a name attribute should raise TimeoutError."""
|
|
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.get.side_effect = lambda key, default = None: {
|
|
"id": None,
|
|
"name": None,
|
|
"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]),
|
|
pytest.raises(TimeoutError, match = r"Failed to set attribute 'groesse_s'"),
|
|
):
|
|
await set_special_attributes(test_bot, ad_cfg)
|