mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-07-09 12:41:05 +02:00
fix(publish,download): support condition_s for categories with combobox instead of dialog (#955)
## ℹ️ Description - Link to the related issue(s): Issue #952 - In certain categories (e.g. 185/249 Modellbau), the condition field is rendered as a `<button role="combobox">` with id `modellbau.condition` instead of a dialog with radio buttons. Publishing failed because `__set_condition` raised `TimeoutError` for these categories, and the method was short-circuited with `continue` — skipping the generic XPath-based handler that already supports combobox controls. Downloading returned `special_attributes: {}` because `ad_attributes` metadata can be empty/missing for some ads, with no fallback to scrape the DOM. ## 📋 Changes Summary - Publishing (`__init__.py`): Catch `TimeoutError` from `__set_condition` and fall through to the generic special-attribute handler, which correctly handles `button[role=combobox]` controls (e.g. `modellbau.condition` in category 185/249). - Downloading (`extract.py`): Add DOM-based fallback in `_extract_special_attributes_from_ad_page` that scrapes `#viewad-details .addetailslist--detail` entries when `ad_attributes` is empty/missing, using `removesuffix` for robust label extraction. - i18n (`translations.de.yaml`): German translation for the new INFO-level fallback log message. - Tests: Added regression tests for both publishing fallback and DOM-based download extraction. Cleaned up duplicate and superficial mock-call-assertion tests (4 items per test-scout-cleanup). - Verified end-to-end against live site via `verify_dom_assumptions.py`: `set_result.ok: True`, `all_matched: True`, condition correctly set via `button#modellbau.condition` combobox. ### ⚙️ Type of Change - [x] 🐞 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) ## ✅ Checklist - [x] I have reviewed my changes to ensure they meet the project's standards. - [x] I have tested my changes and ensured that all tests pass (`pdm run test`). - [x] I have formatted the code (`pdm run format`). - [x] I have verified that linting passes (`pdm run lint`). - [x] I have updated documentation where necessary. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Condition handling is more resilient: if the condition dialog times out, the flow logs the event and falls back to the generic handler instead of aborting. * **New Features** * DOM-based fallback extraction of item attributes when primary attribute data is missing, with mapping of common condition labels to internal values. * **Tests** * Added tests for DOM fallback extraction and condition-setting fallback behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -2021,7 +2021,8 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
if display_text:
|
||||
try:
|
||||
shipping_btn = await self.web_find(
|
||||
By.CSS_SELECTOR, '[role="combobox"][id$=".versand"]',
|
||||
By.CSS_SELECTOR,
|
||||
'[role="combobox"][id$=".versand"]',
|
||||
timeout = self._timeout("quick_dom"),
|
||||
)
|
||||
btn_id = cast(str, shipping_btn.attrs.get("id"))
|
||||
@@ -2448,8 +2449,11 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
raise TimeoutError(_("Failed to set attribute '%s'") % special_attribute_key)
|
||||
|
||||
if normalized_special_attribute_key == "condition":
|
||||
await self.__set_condition(special_attribute_value_str)
|
||||
continue
|
||||
try:
|
||||
await self.__set_condition(special_attribute_value_str)
|
||||
continue
|
||||
except TimeoutError:
|
||||
LOG.info("Condition dialog not available, falling back to generic attribute handler for [%s]...", special_attribute_key)
|
||||
|
||||
LOG.debug("Setting special attribute [%s] to [%s]...", special_attribute_key, special_attribute_value_str)
|
||||
id_suffix_literal = _xpath_literal(f".{normalized_special_attribute_key}")
|
||||
|
||||
@@ -34,6 +34,16 @@ _BACKUP_DIR_PREFIX:Final[str] = ".bak-"
|
||||
_LOG_SNIPPET_LIMIT:Final[int] = 120
|
||||
_ELLIPSIS:Final[str] = "..."
|
||||
_ELLIPSIS_LEN:Final[int] = len(_ELLIPSIS)
|
||||
_CONDITION_DISPLAY_TO_API:Final[dict[str, str]] = {
|
||||
"neu": "new",
|
||||
"sehr gut": "like_new",
|
||||
"gut": "ok",
|
||||
"in ordnung": "alright",
|
||||
"defekt": "defect",
|
||||
}
|
||||
_LABEL_TO_KEY:Final[dict[str, str]] = {
|
||||
"zustand": "condition_s",
|
||||
}
|
||||
|
||||
|
||||
class AdExtractor(WebScrapingMixin):
|
||||
@@ -653,11 +663,55 @@ class AdExtractor(WebScrapingMixin):
|
||||
# e.g. "art_s:lautsprecher_kopfhoerer|condition_s:like_new|versand_s:t"
|
||||
special_attributes_str = belen_conf["universalAnalyticsOpts"]["dimensions"].get("ad_attributes")
|
||||
if not special_attributes_str:
|
||||
return {}
|
||||
return await self._extract_special_attributes_from_dom()
|
||||
special_attributes = dict(item.split(":") for item in special_attributes_str.split("|") if ":" in item)
|
||||
special_attributes = {k: v for k, v in special_attributes.items() if not k.endswith(".versand_s") and k != "versand_s"}
|
||||
return special_attributes
|
||||
|
||||
async def _extract_special_attributes_from_dom(self) -> dict[str, str]:
|
||||
"""Extract special attributes from the ad details section as a fallback.
|
||||
|
||||
Used when ``BelenConf.universalAnalyticsOpts.dimensions.ad_attributes`` is empty,
|
||||
which can happen for categories where condition is an optional field.
|
||||
|
||||
Scrapes ``#viewad-details .addetailslist--detail`` entries and maps
|
||||
display labels (e.g. "Zustand") to API keys (e.g. "condition_s").
|
||||
"""
|
||||
attributes:dict[str, str] = {}
|
||||
try:
|
||||
detail_items = await self.web_find_all(
|
||||
By.CSS_SELECTOR,
|
||||
"#viewad-details .addetailslist--detail",
|
||||
timeout = self._effective_timeout(),
|
||||
)
|
||||
except TimeoutError:
|
||||
LOG.debug("No ad details section found on view page for DOM-based attribute extraction.")
|
||||
return attributes
|
||||
|
||||
for item in detail_items:
|
||||
try:
|
||||
value_text = (await self.web_text(By.CSS_SELECTOR, ".addetailslist--detail--value", parent = item)).strip().lower()
|
||||
full_text = (await self._extract_visible_text(item)).strip().lower()
|
||||
except TimeoutError:
|
||||
LOG.debug("Skipping detail row without extractable value in DOM fallback.")
|
||||
continue
|
||||
label = full_text.removesuffix(value_text).strip()
|
||||
|
||||
attr_key = _LABEL_TO_KEY.get(label)
|
||||
if not attr_key:
|
||||
continue
|
||||
|
||||
if attr_key == "condition_s":
|
||||
api_value = _CONDITION_DISPLAY_TO_API.get(value_text)
|
||||
if api_value:
|
||||
attributes[attr_key] = api_value
|
||||
else:
|
||||
attributes[attr_key] = value_text
|
||||
|
||||
if attributes:
|
||||
LOG.debug("Extracted special attributes from DOM fallback: %s", attributes)
|
||||
return attributes
|
||||
|
||||
async def _extract_pricing_info_from_ad_page(self) -> tuple[float | None, str]:
|
||||
"""
|
||||
Extracts the pricing information (price and pricing type) from an ad page.
|
||||
|
||||
@@ -260,6 +260,7 @@ kleinanzeigen_bot/__init__.py:
|
||||
"Attribute field '%s' seems to be a checkbox...": "Attributfeld '%s' scheint eine Checkbox zu sein..."
|
||||
"Attribute field '%s' seems to be a text input...": "Attributfeld '%s' scheint ein Texteingabefeld zu sein..."
|
||||
"Attribute field '%s' seems to be a Combobox (i.e. text input with filtering dropdown)...": "Attributfeld '%s' scheint eine Combobox zu sein (d.h. Texteingabefeld mit Dropdown-Filter)..."
|
||||
"Condition dialog not available, falling back to generic attribute handler for [%s]...": "Zustandsdialog nicht verfügbar, falle auf generischen Attribut-Handler für [%s] zurück..."
|
||||
|
||||
download_ads:
|
||||
"Ads download directory: %s": "Anzeigen-Download-Verzeichnis: %s"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import json # isort: skip
|
||||
import asyncio
|
||||
import shutil
|
||||
from gettext import gettext as _
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, TypedDict
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
@@ -1194,7 +1193,7 @@ class TestAdExtractorCategory:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_category_fallback_to_legacy_selectors(self, extractor:extract_module.AdExtractor, caplog:pytest.LogCaptureFixture) -> None:
|
||||
async def test_extract_category_fallback_to_legacy_selectors(self, extractor:extract_module.AdExtractor) -> None:
|
||||
"""Test category extraction when breadcrumb links are not available and legacy selectors are used."""
|
||||
category_line = MagicMock()
|
||||
first_part = MagicMock()
|
||||
@@ -1202,8 +1201,6 @@ class TestAdExtractorCategory:
|
||||
second_part = MagicMock()
|
||||
second_part.attrs = {"href": 67890} # This will need str() conversion
|
||||
|
||||
caplog.set_level("DEBUG")
|
||||
expected_message = _("Falling back to legacy breadcrumb selectors; collected ids: %s") % []
|
||||
with (
|
||||
patch.object(extractor, "web_find", new_callable = AsyncMock) as mock_web_find,
|
||||
patch.object(extractor, "web_find_all", new_callable = AsyncMock, side_effect = TimeoutError) as mock_web_find_all,
|
||||
@@ -1212,7 +1209,6 @@ class TestAdExtractorCategory:
|
||||
|
||||
result = await extractor._extract_category_from_ad_page()
|
||||
assert result == "12345/67890"
|
||||
assert sum(1 for record in caplog.records if record.message == expected_message) == 1
|
||||
|
||||
mock_web_find.assert_any_call(By.ID, "vap-brdcrmb")
|
||||
mock_web_find.assert_any_call(By.CSS_SELECTOR, "a:nth-of-type(2)", parent = category_line)
|
||||
@@ -1220,8 +1216,8 @@ class TestAdExtractorCategory:
|
||||
mock_web_find_all.assert_awaited_once_with(By.CSS_SELECTOR, "a", parent = category_line)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_category_legacy_selectors_timeout(self, extractor:extract_module.AdExtractor, caplog:pytest.LogCaptureFixture) -> None:
|
||||
"""Ensure fallback timeout logs the error and re-raises with translated message."""
|
||||
async def test_extract_category_legacy_selectors_timeout(self, extractor:extract_module.AdExtractor) -> None:
|
||||
"""Ensure fallback timeout re-raises with translated message."""
|
||||
category_line = MagicMock()
|
||||
|
||||
async def fake_web_find(selector_type:By, selector_value:str, *, parent:Element | None = None, timeout:int | float | None = None) -> Element:
|
||||
@@ -1232,20 +1228,17 @@ class TestAdExtractorCategory:
|
||||
with (
|
||||
patch.object(extractor, "web_find", new_callable = AsyncMock, side_effect = fake_web_find),
|
||||
patch.object(extractor, "web_find_all", new_callable = AsyncMock, side_effect = TimeoutError),
|
||||
caplog.at_level("ERROR"),
|
||||
pytest.raises(TimeoutError, match = "Unable to locate breadcrumb fallback selectors"),
|
||||
):
|
||||
await extractor._extract_category_from_ad_page()
|
||||
|
||||
assert any("Legacy breadcrumb selectors not found" in record.message for record in caplog.records)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_special_attributes_empty(self, extractor:extract_module.AdExtractor) -> None:
|
||||
"""Test extraction of special attributes when empty."""
|
||||
with patch.object(extractor, "web_execute", new_callable = AsyncMock) as mock_web_execute:
|
||||
mock_web_execute.return_value = {"universalAnalyticsOpts": {"dimensions": {"ad_attributes": ""}}}
|
||||
result = await extractor._extract_special_attributes_from_ad_page(mock_web_execute.return_value)
|
||||
belen_conf:dict[str, Any] = {"universalAnalyticsOpts": {"dimensions": {"ad_attributes": ""}}}
|
||||
with patch.object(extractor, "_extract_special_attributes_from_dom", new_callable = AsyncMock, return_value = {}):
|
||||
result = await extractor._extract_special_attributes_from_ad_page(belen_conf)
|
||||
assert result == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1274,16 +1267,155 @@ class TestAdExtractorCategory:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_special_attributes_missing_ad_attributes(self, extractor:extract_module.AdExtractor) -> None:
|
||||
"""Test extraction of special attributes when ad_attributes key is missing."""
|
||||
belen_conf:dict[str, Any] = {
|
||||
"universalAnalyticsOpts": {
|
||||
"dimensions": {
|
||||
# ad_attributes key is completely missing
|
||||
}
|
||||
}
|
||||
}
|
||||
result = await extractor._extract_special_attributes_from_ad_page(belen_conf)
|
||||
async def test_extract_special_attributes_dom_fallback_when_missing(self, extractor:extract_module.AdExtractor) -> None:
|
||||
"""When ad_attributes is missing, special attributes should be extracted via DOM fallback."""
|
||||
belen_conf:dict[str, Any] = {"universalAnalyticsOpts": {"dimensions": {}}}
|
||||
with patch.object(
|
||||
extractor,
|
||||
"_extract_special_attributes_from_dom",
|
||||
new_callable = AsyncMock,
|
||||
return_value = {"condition_s": "new"},
|
||||
):
|
||||
result = await extractor._extract_special_attributes_from_ad_page(belen_conf)
|
||||
|
||||
assert result == {"condition_s": "new"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_special_attributes_dom_fallback_not_called_when_present(self, extractor:extract_module.AdExtractor) -> None:
|
||||
"""When ad_attributes is present, special attributes should be extracted from it directly."""
|
||||
belen_conf:dict[str, Any] = {"universalAnalyticsOpts": {"dimensions": {"ad_attributes": "condition_s:ok|versand_s:t"}}}
|
||||
with patch.object(
|
||||
extractor,
|
||||
"_extract_special_attributes_from_dom",
|
||||
new_callable = AsyncMock,
|
||||
return_value = {},
|
||||
):
|
||||
result = await extractor._extract_special_attributes_from_ad_page(belen_conf)
|
||||
|
||||
assert result == {"condition_s": "ok"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_special_attributes_from_dom_extracts_condition(self, extractor:extract_module.AdExtractor) -> None:
|
||||
"""DOM fallback should extract condition_s from #viewad-details section."""
|
||||
detail_item = MagicMock()
|
||||
detail_item.text = "Zustand Neu"
|
||||
|
||||
async def text_side_effect(by:Any, selector:str, *, parent:Any = None, **__:Any) -> str:
|
||||
if parent is detail_item:
|
||||
return "Neu"
|
||||
return ""
|
||||
|
||||
async def visible_text_side_effect(element:Any) -> str:
|
||||
if element is detail_item:
|
||||
return "Zustand Neu"
|
||||
return ""
|
||||
|
||||
with (
|
||||
patch.object(extractor, "web_find_all", new_callable = AsyncMock, return_value = [detail_item]),
|
||||
patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = text_side_effect),
|
||||
patch.object(extractor, "_extract_visible_text", new_callable = AsyncMock, side_effect = visible_text_side_effect),
|
||||
):
|
||||
result = await extractor._extract_special_attributes_from_dom()
|
||||
|
||||
assert result == {"condition_s": "new"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_special_attributes_from_dom_skips_malformed_row(self, extractor:extract_module.AdExtractor) -> None:
|
||||
"""DOM fallback should skip rows where web_text raises TimeoutError and still extract valid rows."""
|
||||
good_item = MagicMock()
|
||||
good_item.text = "Zustand Neu"
|
||||
|
||||
async def text_side_effect(by:Any, selector:str, *, parent:Any = None, **__:Any) -> str:
|
||||
if parent is good_item:
|
||||
return "Neu"
|
||||
raise TimeoutError("value span not found")
|
||||
|
||||
async def visible_text_side_effect(element:Any) -> str:
|
||||
if element is good_item:
|
||||
return "Zustand Neu"
|
||||
return ""
|
||||
|
||||
malformed_item = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
extractor,
|
||||
"web_find_all",
|
||||
new_callable = AsyncMock,
|
||||
return_value = [malformed_item, good_item],
|
||||
),
|
||||
patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = text_side_effect),
|
||||
patch.object(extractor, "_extract_visible_text", new_callable = AsyncMock, side_effect = visible_text_side_effect),
|
||||
):
|
||||
result = await extractor._extract_special_attributes_from_dom()
|
||||
|
||||
assert result == {"condition_s": "new"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_special_attributes_from_dom_returns_empty_when_no_details_section(self, extractor:extract_module.AdExtractor) -> None:
|
||||
"""DOM fallback should return empty dict when the details section is not found."""
|
||||
with patch.object(
|
||||
extractor,
|
||||
"web_find_all",
|
||||
new_callable = AsyncMock,
|
||||
side_effect = TimeoutError,
|
||||
):
|
||||
result = await extractor._extract_special_attributes_from_dom()
|
||||
|
||||
assert result == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_special_attributes_from_dom_skips_unrecognized_label(self, extractor:extract_module.AdExtractor) -> None:
|
||||
"""DOM fallback should skip rows whose label is not in the lookup map."""
|
||||
detail_item = MagicMock()
|
||||
|
||||
async def text_side_effect(by:Any, selector:str, *, parent:Any = None, **__:Any) -> str:
|
||||
if parent is detail_item:
|
||||
return "SomeValue"
|
||||
return ""
|
||||
|
||||
async def visible_text_side_effect(element:Any) -> str:
|
||||
if element is detail_item:
|
||||
return "UnrecognizedLabel SomeValue"
|
||||
return ""
|
||||
|
||||
with (
|
||||
patch.object(extractor, "web_find_all", new_callable = AsyncMock, return_value = [detail_item]),
|
||||
patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = text_side_effect),
|
||||
patch.object(extractor, "_extract_visible_text", new_callable = AsyncMock, side_effect = visible_text_side_effect),
|
||||
):
|
||||
result = await extractor._extract_special_attributes_from_dom()
|
||||
|
||||
assert result == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
# pylint: disable=protected-access
|
||||
async def test_extract_special_attributes_from_dom_skips_unmapped_condition_value(self, extractor:extract_module.AdExtractor) -> None:
|
||||
"""DOM fallback should skip condition rows whose display value is not in the API mapping."""
|
||||
detail_item = MagicMock()
|
||||
|
||||
async def text_side_effect(by:Any, selector:str, *, parent:Any = None, **__:Any) -> str:
|
||||
if parent is detail_item:
|
||||
return "Unbekannt"
|
||||
return ""
|
||||
|
||||
async def visible_text_side_effect(element:Any) -> str:
|
||||
if element is detail_item:
|
||||
return "Zustand Unbekannt"
|
||||
return ""
|
||||
|
||||
with (
|
||||
patch.object(extractor, "web_find_all", new_callable = AsyncMock, return_value = [detail_item]),
|
||||
patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = text_side_effect),
|
||||
patch.object(extractor, "_extract_visible_text", new_callable = AsyncMock, side_effect = visible_text_side_effect),
|
||||
):
|
||||
result = await extractor._extract_special_attributes_from_dom()
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
|
||||
@@ -635,7 +635,13 @@ class TestKleinanzeigenBotInitialization:
|
||||
|
||||
# Mock load_ads to return the saved_ad_ids
|
||||
saved_ads:list[tuple[str, MagicMock, dict[str, Any]]] = [
|
||||
(f"ad_{ad_id}.yaml", MagicMock(spec = Ad, id = ad_id), {}) for ad_id in scenario["saved_ad_ids"]]
|
||||
(
|
||||
f"ad_{ad_id}.yaml",
|
||||
MagicMock(spec = Ad, id = ad_id),
|
||||
{},
|
||||
)
|
||||
for ad_id in scenario["saved_ad_ids"]
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "_fetch_published_ads", new_callable = AsyncMock, return_value = scenario["published_ads"]) as mock_fetch_published_ads,
|
||||
@@ -3040,6 +3046,91 @@ class TestConditionSelector:
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_condition")("defect")
|
||||
|
||||
|
||||
class TestConditionFallbackToGenericHandler:
|
||||
"""Regression tests for condition_s falling back to generic attribute handler when dialog is unavailable.
|
||||
|
||||
When __set_condition raises TimeoutError (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
|
||||
async def test_condition_falls_back_to_generic_handler_on_dialog_timeout(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""When condition dialog is not available, generic handler should be used as fallback."""
|
||||
ad_cfg = Ad.model_validate(
|
||||
{
|
||||
"active": True,
|
||||
"type": "OFFER",
|
||||
"title": "Test Modellauto Neu im Karton",
|
||||
"description": "Test Description for ad",
|
||||
"category": "185/249",
|
||||
"special_attributes": {"condition_s": "new"},
|
||||
"price_type": "NEGOTIABLE",
|
||||
"shipping_type": "PICKUP",
|
||||
"sell_directly": False,
|
||||
"republication_interval": 7,
|
||||
"contact": {"name": "Test", "zipcode": "12345"},
|
||||
}
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
test_bot,
|
||||
"_KleinanzeigenBot__set_condition",
|
||||
new_callable = AsyncMock,
|
||||
side_effect = TimeoutError("dialog not available"),
|
||||
) as mock_set_condition,
|
||||
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = [button_elem]),
|
||||
patch.object(
|
||||
test_bot,
|
||||
"_KleinanzeigenBot__select_button_combobox",
|
||||
new_callable = AsyncMock,
|
||||
) as mock_select_combobox,
|
||||
):
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
|
||||
|
||||
mock_set_condition.assert_awaited_once_with("new")
|
||||
mock_select_combobox.assert_awaited_once_with("modellbau.condition", "new")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_condition_uses_dialog_when_available(self, test_bot:KleinanzeigenBot) -> None:
|
||||
"""When condition dialog works, it should be used without falling back."""
|
||||
ad_cfg = Ad.model_validate(
|
||||
{
|
||||
"active": True,
|
||||
"type": "OFFER",
|
||||
"title": "Test Artikel guter Zustand",
|
||||
"description": "Test Description for ad",
|
||||
"category": "161/176",
|
||||
"special_attributes": {"condition_s": "ok"},
|
||||
"price_type": "NEGOTIABLE",
|
||||
"shipping_type": "PICKUP",
|
||||
"sell_directly": False,
|
||||
"republication_interval": 7,
|
||||
"contact": {"name": "Test", "zipcode": "12345"},
|
||||
}
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
test_bot,
|
||||
"_KleinanzeigenBot__set_condition",
|
||||
new_callable = AsyncMock,
|
||||
) as mock_set_condition:
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
|
||||
|
||||
mock_set_condition.assert_awaited_once_with("ok")
|
||||
|
||||
|
||||
class TestShippingDialogFlow:
|
||||
"""Regression tests for shipping dialog flow using new radio selectors only."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user