mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-07-09 12:41:05 +02:00
fix(publish): persist individual shipping costs past React re-render (#1011)
This commit is contained in:
committed by
GitHub
parent
53d0c9646e
commit
d26f39de03
@@ -3378,11 +3378,31 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
raise TimeoutError(_("Unable to select individual shipping option!")) from ex
|
||||
|
||||
if ad_cfg.shipping_costs is not None:
|
||||
price_str = str(ad_cfg.shipping_costs).replace(".", ",")
|
||||
# Native DOM setter + React-aware events: send_keys gets wiped by
|
||||
# React re-render after the ad-individual-shipping-checkbox-control click.
|
||||
# A re-render between web_find and web_execute inside __set_input_value can
|
||||
# also leave the write as a silent no-op, so verify and retry before "Fertig".
|
||||
max_attempts = 3
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
await self.web_input(By.ID, "ad-individual-shipping-price", str(ad_cfg.shipping_costs).replace(".", ","))
|
||||
await self.__set_input_value("ad-individual-shipping-price", price_str)
|
||||
actual = await self.web_execute("document.getElementById('ad-individual-shipping-price')?.value")
|
||||
except TimeoutError as ex:
|
||||
# A re-render landing on web_find inside __set_input_value or on the
|
||||
# readback web_execute can raise here; treat either as a transient
|
||||
# failure so the outer loop can retry instead of bailing.
|
||||
LOG.debug(ex, exc_info = True)
|
||||
if attempt >= max_attempts:
|
||||
raise TimeoutError(_("Unable to set shipping price!")) from ex
|
||||
await self.web_sleep(300, 500)
|
||||
continue
|
||||
if actual == price_str:
|
||||
break
|
||||
if attempt >= max_attempts:
|
||||
raise TimeoutError(_("Unable to set shipping price!"))
|
||||
LOG.debug("shipping price not persisted (attempt %d/%d): got %r, expected %r", attempt, max_attempts, actual, price_str)
|
||||
await self.web_sleep(300, 500)
|
||||
else:
|
||||
LOG.debug(
|
||||
"Shipping option 'ad-individual-shipping-checkbox-control' selected but no shipping_costs provided; "
|
||||
|
||||
@@ -3956,7 +3956,8 @@ class TestShippingDialogFlow:
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
||||
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
|
||||
patch.object(test_bot, "web_input", new_callable = AsyncMock) as mock_input,
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_input_value", new_callable = AsyncMock) as mock_set_input,
|
||||
patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = "4,95"),
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||
):
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
|
||||
@@ -3964,7 +3965,7 @@ class TestShippingDialogFlow:
|
||||
click_args = [c.args for c in mock_click.await_args_list]
|
||||
assert any(len(a) >= 2 and a[0] == By.ID and a[1] == "ad-individual-shipping-checkbox-control" for a in click_args)
|
||||
assert any("Fertig" in str(a[1]) for a in click_args if len(a) >= 2)
|
||||
mock_input.assert_awaited_once_with(By.ID, "ad-individual-shipping-price", "4,95")
|
||||
mock_set_input.assert_awaited_once_with("ad-individual-shipping-price", "4,95")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shipping_finish_timeout_raises(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
|
||||
@@ -3997,14 +3998,104 @@ class TestShippingDialogFlow:
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
||||
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = MagicMock()),
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
|
||||
patch.object(test_bot, "web_input", new_callable = AsyncMock) as mock_input,
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_input_value", new_callable = AsyncMock) as mock_set_input,
|
||||
patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = "4,95"),
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||
):
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
|
||||
|
||||
click_args = [c.args for c in mock_click.await_args_list]
|
||||
assert not any(len(a) >= 2 and a[0] == By.ID and a[1] == "ad-individual-shipping-checkbox-control" for a in click_args)
|
||||
mock_input.assert_awaited_once_with(By.ID, "ad-individual-shipping-price", "4,95")
|
||||
mock_set_input.assert_awaited_once_with("ad-individual-shipping-price", "4,95")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shipping_price_lost_to_react_rerender_raises(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
) -> None:
|
||||
"""When React re-render swallows the shipping price, the dialog must NOT be closed."""
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
|
||||
|
||||
async def set_input_side_effect(element_id:str, value:str) -> None:
|
||||
# Simulate __set_input_value succeeding (no TimeoutError) but
|
||||
# the JS returning early because the element is null after re-render.
|
||||
# The real __set_input_value does a web_find + web_execute whose JS
|
||||
# silently returns on `if(!el) return;`. By mocking at this level we
|
||||
# model the observable effect: the call completes without error, but
|
||||
# the DOM value was never written.
|
||||
pass
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
||||
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_input_value", new_callable = AsyncMock, side_effect = set_input_side_effect),
|
||||
patch.object(test_bot, "web_execute", new_callable = AsyncMock, return_value = None),
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||
pytest.raises(TimeoutError, match = "Unable to set shipping price!"),
|
||||
):
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
|
||||
|
||||
# Fertig must never be clicked when the price was not confirmed
|
||||
fertig_clicks = [c for c in mock_click.await_args_list if c.args and len(c.args) >= 2 and "Fertig" in str(c.args[1])]
|
||||
assert not fertig_clicks, "Fertig was clicked despite shipping price not being set"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shipping_price_recovers_when_readback_matches_on_retry(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
) -> None:
|
||||
"""First attempt mismatches (readback empty), second attempt succeeds — must close the dialog normally."""
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
||||
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_input_value", new_callable = AsyncMock) as mock_set_input,
|
||||
patch.object(test_bot, "web_execute", new_callable = AsyncMock, side_effect = [None, "4,95"]),
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as mock_sleep,
|
||||
):
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
|
||||
|
||||
assert mock_set_input.await_count == 2, "expected exactly one retry after the first mismatch"
|
||||
inter_attempt_sleeps = [c for c in mock_sleep.await_args_list if c.args == (300, 500)]
|
||||
assert len(inter_attempt_sleeps) == 1, "expected one inter-attempt backoff sleep"
|
||||
fertig_clicks = [c for c in mock_click.await_args_list if c.args and len(c.args) >= 2 and "Fertig" in str(c.args[1])]
|
||||
assert fertig_clicks, "Fertig must be clicked once the readback confirms the price"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shipping_price_retries_when_readback_raises_transiently(
|
||||
self,
|
||||
test_bot:KleinanzeigenBot,
|
||||
base_ad_config:dict[str, Any],
|
||||
) -> None:
|
||||
"""A TimeoutError from the readback web_execute must be retried, not propagated on the first occurrence."""
|
||||
ad_cfg = Ad.model_validate(base_ad_config | {"shipping_type": "SHIPPING", "shipping_options": [], "shipping_costs": 4.95})
|
||||
|
||||
readback_results:list[str | Exception] = [TimeoutError("readback raced with re-render"), "4,95"]
|
||||
|
||||
async def readback_side_effect(*_args:Any, **_kwargs:Any) -> str | None:
|
||||
result = readback_results.pop(0)
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
with (
|
||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
||||
patch.object(test_bot, "web_probe", new_callable = AsyncMock, return_value = None),
|
||||
patch.object(test_bot, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
|
||||
patch.object(test_bot, "_KleinanzeigenBot__set_input_value", new_callable = AsyncMock) as mock_set_input,
|
||||
patch.object(test_bot, "web_execute", new_callable = AsyncMock, side_effect = readback_side_effect),
|
||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||
):
|
||||
await getattr(test_bot, "_KleinanzeigenBot__set_shipping")(ad_cfg)
|
||||
|
||||
assert mock_set_input.await_count == 2, "expected one retry after the readback raised"
|
||||
fertig_clicks = [c for c in mock_click.await_args_list if c.args and len(c.args) >= 2 and "Fertig" in str(c.args[1])]
|
||||
assert fertig_clicks, "Fertig must be clicked once a later readback confirms the price"
|
||||
|
||||
|
||||
class TestShippingOptionsDialog:
|
||||
|
||||
Reference in New Issue
Block a user