diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index 6506941..53222cf 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -20,7 +20,7 @@ from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, Contact, calc from .model.config_model import Config from .update_checker import UpdateChecker from .utils import diagnostics, dicts, error_handlers, loggers, misc, xdg_paths -from .utils.exceptions import CaptchaEncountered +from .utils.exceptions import CaptchaEncountered, PublishSubmissionUncertainError from .utils.files import abspath from .utils.i18n import Locale, get_current_locale, pluralize, set_current_locale from .utils.misc import ainput, ensure, is_frozen @@ -1438,7 +1438,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 return False - async def _fetch_published_ads(self, *, strict:bool = False) -> list[dict[str, Any]]: + async def _fetch_published_ads(self) -> list[dict[str, Any]]: """Fetch all published ads, handling API pagination. Returns: @@ -1458,14 +1458,10 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 try: response = await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT&pageNum={page}") except TimeoutError as ex: - if strict: - raise LOG.warning("Pagination request failed on page %s: %s", page, ex) break if not isinstance(response, dict): - if strict: - raise TypeError(f"Unexpected pagination response type on page {page}: {type(response).__name__}") LOG.warning("Unexpected pagination response type on page %s: %s", page, type(response).__name__) break @@ -1475,8 +1471,6 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 if isinstance(content, bytes): content = content.decode("utf-8", errors = "replace") if not isinstance(content, str): - if strict: - raise TypeError(f"Unexpected response content type on page {page}: {type(content).__name__}") LOG.warning("Unexpected response content type on page %s: %s", page, type(content).__name__) break @@ -1484,27 +1478,19 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 json_data = json.loads(content) except (json.JSONDecodeError, TypeError) as ex: if not content: - if strict: - raise ValueError(f"Empty JSON response content on page {page}") from ex LOG.warning("Empty JSON response content on page %s", page) break - if strict: - raise ValueError(f"Failed to parse JSON response on page {page}: {ex}") from ex snippet = content[:SNIPPET_LIMIT] + ("..." if len(content) > SNIPPET_LIMIT else "") LOG.warning("Failed to parse JSON response on page %s: %s (content: %s)", page, ex, snippet) break if not isinstance(json_data, dict): - if strict: - raise TypeError(f"Unexpected JSON payload type on page {page}: {type(json_data).__name__}") snippet = content[:SNIPPET_LIMIT] + ("..." if len(content) > SNIPPET_LIMIT else "") LOG.warning("Unexpected JSON payload on page %s (content: %s)", page, snippet) break page_ads = json_data.get("ads", []) if not isinstance(page_ads, list): - if strict: - raise TypeError(f"Unexpected 'ads' type on page {page}: {type(page_ads).__name__}") preview = str(page_ads) if len(preview) > SNIPPET_LIMIT: preview = preview[:SNIPPET_LIMIT] + "..." @@ -1519,8 +1505,6 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 filtered_page_ads.append(entry) continue rejected_count += 1 - if strict: - raise TypeError(f"Unexpected ad entry type on page {page}: {type(entry).__name__}") if rejected_preview is None: rejected_preview = repr(entry) @@ -1534,8 +1518,6 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 paging = json_data.get("paging") if not isinstance(paging, dict): - if strict: - raise ValueError(f"Missing or invalid paging info on page {page}: {type(paging).__name__}") LOG.debug("No paging dict found on page %s, assuming single page", page) break @@ -1544,14 +1526,10 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 total_pages = misc.coerce_page_number(paging.get("last")) if current_page_num is None: - if strict: - raise ValueError(f"Invalid 'pageNum' in paging info: {paging.get('pageNum')}") LOG.warning("Invalid 'pageNum' in paging info: %s, stopping pagination", paging.get("pageNum")) break if total_pages is None: - if strict: - raise ValueError("No pagination info found") LOG.debug("No pagination info found, assuming single page") break @@ -1570,8 +1548,6 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 # Use API's next field for navigation (more robust than our counter) next_page = misc.coerce_page_number(paging.get("next")) if next_page is None: - if strict: - raise ValueError(f"Invalid 'next' page value in paging info: {paging.get('next')}") LOG.warning("Invalid 'next' page value in paging info: %s, stopping pagination", paging.get("next")) break page = next_page @@ -1739,28 +1715,6 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 # Check for success messages return await self.web_check(By.ID, "checking-done", Is.DISPLAYED) or await self.web_check(By.ID, "not-completed", Is.DISPLAYED) - async def _detect_new_published_ad_ids(self, ads_before_publish:set[str], ad_title:str) -> set[str] | None: - try: - current_ads = await self._fetch_published_ads(strict = True) - current_ad_ids:set[str] = set() - for current_ad in current_ads: - if not isinstance(current_ad, dict): - # Keep duplicate-prevention verification fail-closed: malformed entries - # must abort retries rather than risk creating duplicate listings. - entry_length = len(current_ad) if hasattr(current_ad, "__len__") else None - LOG.debug("Malformed ad entry in strict duplicate verification: type=%s length=%s", type(current_ad).__name__, entry_length) - raise TypeError(f"Unexpected ad entry type: {type(current_ad).__name__}") - if current_ad.get("id"): - current_ad_ids.add(str(current_ad["id"])) - except Exception as ex: # noqa: BLE001 - LOG.warning( - "Could not verify published ads after failed attempt for '%s': %s -- aborting retries to prevent duplicates.", - ad_title, - ex, - ) - return None - return current_ad_ids - ads_before_publish - async def publish_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None: count = 0 failed_count = 0 @@ -1778,15 +1732,6 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 count += 1 success = False - # Retry loop only for publish_ad (before submission completes) - # Fetch a fresh baseline right before the retry loop to avoid stale state - # from earlier successful publishes in multi-ad runs (see #874) - try: - pre_publish_ads = await self._fetch_published_ads() - ads_before_publish:set[str] = {str(x["id"]) for x in pre_publish_ads if x.get("id")} - except Exception as ex: # noqa: BLE001 - LOG.warning("Could not fetch fresh published-ads baseline for '%s': %s. Falling back to initial snapshot.", ad_cfg.title, ex) - ads_before_publish = {str(x["id"]) for x in published_ads if x.get("id")} for attempt in range(1, max_retries + 1): try: await self.publish_ad(ad_file, ad_cfg, ad_cfg_orig, published_ads, AdUpdateStrategy.REPLACE) @@ -1794,6 +1739,22 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 break # Publish succeeded, exit retry loop except asyncio.CancelledError: raise # Respect task cancellation + except PublishSubmissionUncertainError as ex: + await self._capture_publish_error_diagnostics_if_enabled(ad_cfg, ad_cfg_orig, ad_file, attempt, ex) + LOG.warning( + "Attempt %s/%s for '%s' reached submit boundary but failed: %s. Not retrying to prevent duplicate listings.", + attempt, + max_retries, + ad_cfg.title, + ex, + ) + LOG.warning("Manual recovery required for '%s'. Check 'Meine Anzeigen' to confirm whether the ad was posted.", ad_cfg.title) + LOG.warning( + "If posted, sync local state with 'kleinanzeigen-bot download --ads=new' or 'kleinanzeigen-bot download --ads='; " + "otherwise rerun publish for this ad." + ) + failed_count += 1 + break except (TimeoutError, ProtocolException) as ex: await self._capture_publish_error_diagnostics_if_enabled(ad_cfg, ad_cfg_orig, ad_file, attempt, ex) if attempt >= max_retries: @@ -1801,26 +1762,6 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 failed_count += 1 continue - # Before retrying, check if the ad was already created despite the error. - # A partially successful submission followed by a retry would create a duplicate listing, - # which violates kleinanzeigen.de terms of service and can lead to account suspension. - new_ad_ids = await self._detect_new_published_ad_ids(ads_before_publish, ad_cfg.title) - if new_ad_ids is None: - failed_count += 1 - break - if new_ad_ids: - LOG.warning( - "Attempt %s/%s failed for '%s': %s. " - "However, a new ad was detected (id: %s) -- aborting retries to prevent duplicates.", - attempt, - max_retries, - ad_cfg.title, - ex, - ", ".join(new_ad_ids), - ) - failed_count += 1 - break - LOG.warning("Attempt %s/%s failed for '%s': %s. Retrying...", attempt, max_retries, ad_cfg.title, ex) await self.web_sleep(2_000) # Wait before retry @@ -1972,40 +1913,47 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 ############################# # submit ############################# + submission_attempted = False try: - await self.web_click(By.ID, "pstad-submit") - except TimeoutError: - # https://github.com/Second-Hand-Friends/kleinanzeigen-bot/issues/40 - await self.web_click(By.XPATH, "//fieldset[@id='postad-publish']//*[contains(., 'Anzeige aufgeben')]") - await self.web_click(By.ID, "imprint-guidance-submit") + submission_attempted = True + try: + await self.web_click(By.ID, "pstad-submit") + except TimeoutError: + # https://github.com/Second-Hand-Friends/kleinanzeigen-bot/issues/40 + await self.web_click(By.XPATH, "//fieldset[@id='postad-publish']//*[contains(., 'Anzeige aufgeben')]") + await self.web_click(By.ID, "imprint-guidance-submit") - # check for no image question - try: - image_hint_xpath = '//button[contains(., "Ohne Bild veröffentlichen")]' - if not ad_cfg.images and await self.web_check(By.XPATH, image_hint_xpath, Is.DISPLAYED): - await self.web_click(By.XPATH, image_hint_xpath) - except TimeoutError: - # Image hint not shown; continue publish flow. - pass # nosec + # check for no image question + try: + image_hint_xpath = '//button[contains(., "Ohne Bild veröffentlichen")]' + if not ad_cfg.images and await self.web_check(By.XPATH, image_hint_xpath, Is.DISPLAYED): + await self.web_click(By.XPATH, image_hint_xpath) + except TimeoutError: + # Image hint not shown; continue publish flow. + pass # nosec - ############################# - # wait for payment form if commercial account is used - ############################# - try: - short_timeout = self._timeout("quick_dom") - await self.web_find(By.ID, "myftr-shppngcrt-frm", timeout = short_timeout) + ############################# + # wait for payment form if commercial account is used + ############################# + try: + short_timeout = self._timeout("quick_dom") + await self.web_find(By.ID, "myftr-shppngcrt-frm", timeout = short_timeout) - LOG.warning("############################################") - LOG.warning("# Payment form detected! Please proceed with payment.") - LOG.warning("############################################") - await self.web_scroll_page_down() - await ainput(_("Press a key to continue...")) - except TimeoutError: - # Payment form not present. - pass + LOG.warning("############################################") + LOG.warning("# Payment form detected! Please proceed with payment.") + LOG.warning("############################################") + await self.web_scroll_page_down() + await ainput(_("Press a key to continue...")) + except TimeoutError: + # Payment form not present. + pass - confirmation_timeout = self._timeout("publishing_confirmation") - await self.web_await(lambda: "p-anzeige-aufgeben-bestaetigung.html?adId=" in self.page.url, timeout = confirmation_timeout) + confirmation_timeout = self._timeout("publishing_confirmation") + await self.web_await(lambda: "p-anzeige-aufgeben-bestaetigung.html?adId=" in self.page.url, timeout = confirmation_timeout) + except (TimeoutError, ProtocolException) as ex: + if submission_attempted: + raise PublishSubmissionUncertainError("submission may have succeeded before failure") from ex + raise # extract the ad id from the URL's query parameter current_url_query_params = urllib_parse.parse_qs(urllib_parse.urlparse(self.page.url).query) diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 383b4b9..33c27f7 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -177,16 +177,15 @@ kleinanzeigen_bot/__init__.py: "Skipping because ad is reserved": "Überspringen, da Anzeige reserviert ist" " -> Could not confirm publishing for '%s', but ad may be online": " -> Veröffentlichung für '%s' konnte nicht bestätigt werden, aber Anzeige ist möglicherweise online" "Attempt %s/%s failed for '%s': %s. Retrying...": "Versuch %s/%s fehlgeschlagen für '%s': %s. Erneuter Versuch..." - "Attempt %s/%s failed for '%s': %s. However, a new ad was detected (id: %s) -- aborting retries to prevent duplicates.": "Versuch %s/%s fehlgeschlagen für '%s': %s. Jedoch wurde eine neue Anzeige erkannt (ID: %s) -- Wiederholungen werden abgebrochen, um Duplikate zu vermeiden." - "Could not fetch fresh published-ads baseline for '%s': %s. Falling back to initial snapshot.": "Konnte keine aktuelle Anzeigen-Baseline für '%s' abrufen: %s. Verwende initialen Snapshot." + "Attempt %s/%s for '%s' reached submit boundary but failed: %s. Not retrying to prevent duplicate listings.": "Versuch %s/%s für '%s' hat die Submit-Grenze erreicht, ist aber fehlgeschlagen: %s. Kein erneuter Versuch, um doppelte Anzeigen zu vermeiden." + "Manual recovery required for '%s'. Check 'Meine Anzeigen' to confirm whether the ad was posted.": "Manuelle Wiederherstellung für '%s' erforderlich. Prüfen Sie in 'Meine Anzeigen', ob die Anzeige veröffentlicht wurde." + ? "If posted, sync local state with 'kleinanzeigen-bot download --ads=new' or 'kleinanzeigen-bot download --ads='; otherwise rerun publish for this ad." + : "Falls veröffentlicht, lokalen Stand mit 'kleinanzeigen-bot download --ads=new' oder 'kleinanzeigen-bot download --ads=' synchronisieren; andernfalls Veröffentlichung für diese Anzeige erneut starten." "All %s attempts failed for '%s': %s. Skipping ad.": "Alle %s Versuche fehlgeschlagen für '%s': %s. Überspringe Anzeige." "DONE: (Re-)published %s (%s failed after retries)": "FERTIG: %s (erneut) veröffentlicht (%s fehlgeschlagen nach Wiederholungen)" "DONE: (Re-)published %s": "FERTIG: %s (erneut) veröffentlicht" "ad": "Anzeige" - _detect_new_published_ad_ids: - "Could not verify published ads after failed attempt for '%s': %s -- aborting retries to prevent duplicates.": "Veröffentlichte Anzeigen konnten nach fehlgeschlagenem Versuch für '%s' nicht geprüft werden: %s -- Wiederholungen werden abgebrochen, um Duplikate zu vermeiden." - apply_auto_price_reduction: "Auto price reduction is enabled for [%s] but no price is configured.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber es wurde kein Preis konfiguriert." "Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber min_price entspricht dem Preis (%s) - es werden keine Reduktionen auftreten." diff --git a/src/kleinanzeigen_bot/utils/exceptions.py b/src/kleinanzeigen_bot/utils/exceptions.py index d10916e..0a73089 100644 --- a/src/kleinanzeigen_bot/utils/exceptions.py +++ b/src/kleinanzeigen_bot/utils/exceptions.py @@ -14,3 +14,10 @@ class CaptchaEncountered(KleinanzeigenBotError): def __init__(self, restart_delay:timedelta) -> None: super().__init__() self.restart_delay = restart_delay + + +class PublishSubmissionUncertainError(KleinanzeigenBotError): + """Raised when publish submission may have reached the server state boundary.""" + + def __init__(self, reason:str) -> None: + super().__init__(reason) diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 866ade4..f756f6e 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -10,6 +10,7 @@ from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch import pytest +from nodriver.core.connection import ProtocolException from pydantic import ValidationError from kleinanzeigen_bot import LOG, PUBLISH_MAX_RETRIES, AdUpdateStrategy, KleinanzeigenBot, LoginState, misc @@ -17,6 +18,7 @@ from kleinanzeigen_bot._version import __version__ from kleinanzeigen_bot.model.ad_model import Ad from kleinanzeigen_bot.model.config_model import AdDefaults, Config, DiagnosticsConfig, PublishingConfig from kleinanzeigen_bot.utils import dicts, loggers, xdg_paths +from kleinanzeigen_bot.utils.exceptions import PublishSubmissionUncertainError from kleinanzeigen_bot.utils.web_scraping_mixin import By, Element @@ -1171,10 +1173,9 @@ class TestKleinanzeigenBotBasics: ): await test_bot.publish_ads(ad_cfgs) - # web_request is called twice: once for initial fetch, once for pre-retry-loop baseline + # web_request is called once for initial published-ads snapshot expected_url = f"{test_bot.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT&pageNum=1" - assert web_request_mock.await_count == 2 - web_request_mock.assert_any_await(expected_url) + web_request_mock.assert_awaited_once_with(expected_url) publish_ad_mock.assert_awaited_once_with("ad.yaml", ad_cfgs[0][1], {}, [], AdUpdateStrategy.REPLACE) web_await_mock.assert_awaited_once() delete_ad_mock.assert_awaited_once_with(ad_cfgs[0][1], [], delete_old_ads_by_title = False) @@ -1198,103 +1199,136 @@ class TestKleinanzeigenBotBasics: with ( patch.object(test_bot, "web_request", new_callable = AsyncMock, return_value = ads_response), patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = [TimeoutError("transient"), None]) as publish_mock, - patch.object(test_bot, "_detect_new_published_ad_ids", new_callable = AsyncMock, return_value = set()) as detect_mock, patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock, patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True), ): await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)]) assert publish_mock.await_count == 2 - detect_mock.assert_awaited_once() sleep_mock.assert_awaited_once_with(2_000) @pytest.mark.asyncio - async def test_publish_ads_aborts_retry_on_duplicate_detection( + async def test_publish_ads_does_not_retry_when_submission_state_is_uncertain( self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], mock_page:MagicMock, ) -> None: - """Ensure retries are aborted when a new ad is detected after a failed attempt to prevent duplicates.""" + """Post-submit uncertainty must fail closed and skip retries.""" test_bot.page = mock_page + test_bot.keep_old_ads = True ad_cfg = Ad.model_validate(base_ad_config) ad_cfg_orig = copy.deepcopy(base_ad_config) ad_file = "ad.yaml" - # 1st _fetch_published_ads call (initial, before loop): no ads - # 2nd call (fresh baseline, before retry loop): no ads - # 3rd call (after first failed attempt): a new ad appeared — duplicate detected - fetch_responses = [ - {"content": json.dumps({"ads": []})}, # initial fetch - {"content": json.dumps({"ads": []})}, # fresh baseline - {"content": json.dumps({"ads": [{"id": "99999", "state": "active"}]})}, # duplicate detected - ] - with ( - patch.object(test_bot, "web_request", new_callable = AsyncMock, side_effect = fetch_responses), - patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("image upload timeout")) as publish_mock, + patch.object( + test_bot, + "web_request", + new_callable = AsyncMock, + return_value = {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}, + ), + patch.object( + test_bot, + "publish_ad", + new_callable = AsyncMock, + side_effect = PublishSubmissionUncertainError("submission may have succeeded before failure"), + ) as publish_mock, + patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock, ): await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)]) - # publish_ad should have been called only once — retry was aborted due to duplicate detection assert publish_mock.await_count == 1 + sleep_mock.assert_not_awaited() @pytest.mark.asyncio - async def test_publish_ads_aborts_retry_when_duplicate_verification_fetch_is_malformed( + async def test_publish_ad_keeps_pre_submit_timeouts_retryable( self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], - mock_page:MagicMock, ) -> None: - """Retry verification must fail closed on malformed published-ads responses.""" - test_bot.page = mock_page - - ad_cfg = Ad.model_validate(base_ad_config) + """Timeouts before submit boundary should remain plain retryable failures.""" + ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"}) ad_cfg_orig = copy.deepcopy(base_ad_config) - ad_file = "ad.yaml" - - fetch_responses = [ - {"content": json.dumps({"ads": []})}, - {"content": json.dumps({"ads": []})}, - [], - ] with ( - patch.object(test_bot, "web_request", new_callable = AsyncMock, side_effect = fetch_responses), - patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("image upload timeout")) as publish_mock, + patch.object(test_bot, "web_open", new_callable = AsyncMock), + patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock), + patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock, side_effect = TimeoutError("image upload timeout")), + pytest.raises(TimeoutError, match = "image upload timeout"), ): - await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)]) - - assert publish_mock.await_count == 1 + await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY) @pytest.mark.asyncio - async def test_publish_ads_aborts_retry_when_duplicate_verification_ads_entries_are_malformed( + async def test_publish_ad_marks_post_submit_timeout_as_uncertain( self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], mock_page:MagicMock, ) -> None: - """Retry verification must fail closed when strict fetch returns non-dict ad entries.""" + """Timeouts after submit click should be converted to non-retryable uncertainty.""" test_bot.page = mock_page - - ad_cfg = Ad.model_validate(base_ad_config) + ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"}) ad_cfg_orig = copy.deepcopy(base_ad_config) - ad_file = "ad.yaml" - fetch_responses = [ - {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}, - {"content": json.dumps({"ads": [], "paging": {"pageNum": 1, "last": 1}})}, - {"content": json.dumps({"ads": [42], "paging": {"pageNum": 1, "last": 1}})}, - ] + async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock: + if selector_type == By.ID and selector_value == "myftr-shppngcrt-frm": + raise TimeoutError("no payment form") + return MagicMock() with ( - patch.object(test_bot, "web_request", new_callable = AsyncMock, side_effect = fetch_responses), - patch.object(test_bot, "publish_ad", new_callable = AsyncMock, side_effect = TimeoutError("image upload timeout")) as publish_mock, + patch.object(test_bot, "web_open", new_callable = AsyncMock), + patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock), + patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock), + patch.object(test_bot, "_KleinanzeigenBot__set_special_attributes", new_callable = AsyncMock), + patch.object(test_bot, "_KleinanzeigenBot__set_contact_fields", new_callable = AsyncMock), + patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock), + patch.object(test_bot, "web_input", new_callable = AsyncMock), + patch.object(test_bot, "web_click", new_callable = AsyncMock), + patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False), + patch.object(test_bot, "web_execute", new_callable = AsyncMock), + patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = find_side_effect), + patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []), + patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = TimeoutError("confirmation timeout")), + pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"), ): - await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)]) + await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY) - assert publish_mock.await_count == 1 + @pytest.mark.asyncio + async def test_publish_ad_marks_post_submit_protocol_exception_as_uncertain( + self, + test_bot:KleinanzeigenBot, + base_ad_config:dict[str, Any], + mock_page:MagicMock, + ) -> None: + """Protocol exceptions after submit click should be converted to uncertainty.""" + test_bot.page = mock_page + ad_cfg = Ad.model_validate(base_ad_config | {"id": 12345, "shipping_type": "NOT_APPLICABLE", "price_type": "NOT_APPLICABLE"}) + ad_cfg_orig = copy.deepcopy(base_ad_config) + + async def find_side_effect(selector_type:By, selector_value:str, **_:Any) -> MagicMock: + if selector_type == By.ID and selector_value == "myftr-shppngcrt-frm": + raise TimeoutError("no payment form") + return MagicMock() + + with ( + patch.object(test_bot, "web_open", new_callable = AsyncMock), + patch.object(test_bot, "_dismiss_consent_banner", new_callable = AsyncMock), + patch.object(test_bot, "_KleinanzeigenBot__set_category", new_callable = AsyncMock), + patch.object(test_bot, "_KleinanzeigenBot__set_special_attributes", new_callable = AsyncMock), + patch.object(test_bot, "_KleinanzeigenBot__set_contact_fields", new_callable = AsyncMock), + patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock), + patch.object(test_bot, "web_input", new_callable = AsyncMock), + patch.object(test_bot, "web_click", new_callable = AsyncMock), + patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = False), + patch.object(test_bot, "web_execute", new_callable = AsyncMock), + patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = find_side_effect), + patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []), + patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = ProtocolException(MagicMock(), "connection lost", 0)), + pytest.raises(PublishSubmissionUncertainError, match = "submission may have succeeded before failure"), + ): + await test_bot.publish_ad("ad.yaml", ad_cfg, ad_cfg_orig, [], AdUpdateStrategy.MODIFY) def test_get_root_url(self, test_bot:KleinanzeigenBot) -> None: """Test root URL retrieval.""" diff --git a/tests/unit/test_json_pagination.py b/tests/unit/test_json_pagination.py index 047d6c7..fbf0d9c 100644 --- a/tests/unit/test_json_pagination.py +++ b/tests/unit/test_json_pagination.py @@ -187,17 +187,6 @@ class TestJSONPagination: pytest.fail(f"expected 2 ads, got {len(result)}") mock_request.assert_awaited_once() - @pytest.mark.asyncio - async def test_fetch_published_ads_strict_raises_on_missing_paging_dict(self, bot:KleinanzeigenBot) -> None: - """Strict mode should fail closed when paging metadata is missing.""" - response_data = {"ads": [{"id": 1}, {"id": 2}]} - - with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request: - mock_request.return_value = {"content": json.dumps(response_data)} - - with pytest.raises(ValueError, match = "Missing or invalid paging info on page 1: NoneType"): - await bot._fetch_published_ads(strict = True) - @pytest.mark.asyncio async def test_fetch_published_ads_non_integer_paging_values(self, bot:KleinanzeigenBot) -> None: """Test handling of non-integer paging values.""" @@ -231,26 +220,15 @@ class TestJSONPagination: pytest.fail(f"expected empty list when 'ads' is not a list, got: {result}") @pytest.mark.asyncio - async def test_fetch_published_ads_strict_rejects_non_dict_entries(self, bot:KleinanzeigenBot) -> None: - """Strict mode should reject malformed entries inside ads list.""" - response_data = {"ads": [42, {"id": 1}], "paging": {"pageNum": 1, "last": 1}} - - with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request: - mock_request.return_value = {"content": json.dumps(response_data)} - - with pytest.raises(TypeError, match = "Unexpected ad entry type on page 1: int"): - await bot._fetch_published_ads(strict = True) - - @pytest.mark.asyncio - async def test_fetch_published_ads_non_strict_filters_non_dict_entries(self, bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None: - """Non-strict mode should filter malformed entries and continue.""" + async def test_fetch_published_ads_filters_non_dict_entries(self, bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None: + """Malformed entries should be filtered and logged.""" response_data = {"ads": [42, {"id": 1}, "broken"], "paging": {"pageNum": 1, "last": 1}} with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request: mock_request.return_value = {"content": json.dumps(response_data)} with caplog.at_level("WARNING"): - result = await bot._fetch_published_ads(strict = False) + result = await bot._fetch_published_ads() if result != [{"id": 1}]: pytest.fail(f"expected malformed entries to be filtered out, got: {result}") @@ -269,24 +247,15 @@ class TestJSONPagination: pytest.fail(f"Expected empty list on timeout, got {result}") @pytest.mark.asyncio - async def test_fetch_published_ads_non_strict_handles_non_string_content_type(self, bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None: - """Non-strict mode should gracefully stop on unexpected non-string content types.""" + async def test_fetch_published_ads_handles_non_string_content_type(self, bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None: + """Unexpected non-string content types should stop pagination with warning.""" with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request: mock_request.return_value = {"content": None} with caplog.at_level("WARNING"): - result = await bot._fetch_published_ads(strict = False) + result = await bot._fetch_published_ads() if result != []: - pytest.fail(f"expected empty result on non-string content in non-strict mode, got: {result}") + pytest.fail(f"expected empty result on non-string content, got: {result}") if "Unexpected response content type on page 1: NoneType" not in caplog.text: pytest.fail(f"expected non-string content warning in logs, got: {caplog.text}") - - @pytest.mark.asyncio - async def test_fetch_published_ads_strict_raises_on_non_string_content_type(self, bot:KleinanzeigenBot) -> None: - """Strict mode should fail closed on unexpected non-string content types.""" - with patch.object(bot, "web_request", new_callable = AsyncMock) as mock_request: - mock_request.return_value = {"content": None} - - with pytest.raises(TypeError, match = "Unexpected response content type on page 1: NoneType"): - await bot._fetch_published_ads(strict = True)