diff --git a/src/kleinanzeigen_bot/download_flow.py b/src/kleinanzeigen_bot/download_flow.py index 096d5cd3..d63e1a2c 100644 --- a/src/kleinanzeigen_bot/download_flow.py +++ b/src/kleinanzeigen_bot/download_flow.py @@ -78,7 +78,7 @@ async def _download_ad_with_resolved_state( published_ad = published_ads_by_id.get(ad_id, {}) LOG.debug("Ad %d has state '%s'. Saving as inactive.", ad_id, published_ad.get("state", "unknown")) - await ad_extractor.download_ad(ad_id, active = resolved.active) + await ad_extractor.download_ad(ad_id, active = resolved.active, owned_overview = True) async def _fetch_published_ads_by_id( diff --git a/src/kleinanzeigen_bot/extract.py b/src/kleinanzeigen_bot/extract.py index 42a71746..a620cdf3 100644 --- a/src/kleinanzeigen_bot/extract.py +++ b/src/kleinanzeigen_bot/extract.py @@ -41,6 +41,10 @@ _LOG_SNIPPET_LIMIT:Final[int] = 120 _ELLIPSIS:Final[str] = "..." _ELLIPSIS_LEN:Final[int] = len(_ELLIPSIS) +_OWNED_AD_TITLE_DECORATION_PREFIXES:Final[tuple[str, ...]] = ("Gelöscht",) +_OWNED_AD_TITLE_DECORATION_RE:Final[re.Pattern[str]] = re.compile( + rf"^(?:{'|'.join(re.escape(prefix) for prefix in _OWNED_AD_TITLE_DECORATION_PREFIXES)})\s*•\s*" +) _CONDITION_DISPLAY_TO_API:Final[dict[str, str]] = { "neu": "new", "sehr gut": "like_new", @@ -282,20 +286,32 @@ def _render_download_ad_file_stem(self, ad_id:int, title:str) -> str: def _render_download_folder_name(self, ad_id:int, title:str) -> str: return self._render_download_name_with_budget(self.config.download.folder_name_template, ad_id, title, self.config.download.folder_name_max_length) - async def download_ad(self, ad_id:int, *, active:bool | None = None) -> None: + async def download_ad( + self, + ad_id:int, + *, + active:bool | None = None, + owned_overview:bool = False, + ) -> None: """ Downloads an ad to a specific location, specified by config and ad ID. NOTE: Requires that the driver session currently is on the ad page. :param ad_id: the ad ID :param active: optional override for ad activity state + :param owned_overview: whether the ad was discovered in the user's overview """ download_dir = self.download_dir LOG.info("Using download directory: %s", download_dir) # Extract ad info into a staging directory and determine final target directory - ad_cfg, staging_dir, final_dir, ad_file_stem = await self._extract_ad_page_info_with_directory_handling(download_dir, ad_id, active_override = active) + ad_cfg, staging_dir, final_dir, ad_file_stem = await self._extract_ad_page_info_with_directory_handling( + download_dir, + ad_id, + active_override = active, + owned_overview = owned_overview, + ) # Preserve local-only settings when re-downloading an existing ad await self._preserve_local_settings_from_existing_ad(ad_cfg, final_dir, ad_file_stem, ad_id) @@ -614,7 +630,7 @@ async def _extract_title_from_ad_page(self) -> str: """ return await self.web_text(By.ID, "viewad-title") - async def _resolve_download_title(self, ad_id:int) -> str: + async def _resolve_download_title(self, ad_id:int, *, owned_overview:bool = False) -> str: """Return the canonical title for a downloaded ad.""" cached_ad = self.published_ads_by_id.get(ad_id) if cached_ad is not None: @@ -622,7 +638,15 @@ async def _resolve_download_title(self, ad_id:int) -> str: if isinstance(title, str) and title.strip(): return html.unescape(title.strip()) - return await self._extract_title_from_ad_page() + page_title = await self._extract_title_from_ad_page() + if not owned_overview: + return page_title + + cleaned_title = _OWNED_AD_TITLE_DECORATION_RE.sub("", page_title, count = 1).strip() + if cleaned_title and cleaned_title != page_title: + LOG.info("Removed owned-ad status decoration from downloaded ad %s title.", ad_id) + return cleaned_title + return page_title async def _extract_ad_page_info( self, @@ -716,6 +740,7 @@ async def _extract_ad_page_info_with_directory_handling( ad_id:int, *, active_override:bool | None = None, + owned_overview:bool = False, ) -> tuple[AdPartial, Path, Path, str]: """ Extracts ad information and handles directory creation/renaming. @@ -723,9 +748,10 @@ async def _extract_ad_page_info_with_directory_handling( :param relative_directory: Base directory for downloads :param ad_id: The ad ID :param active_override: optional override for ad activity state + :param owned_overview: whether the ad was discovered in the user's overview :return: AdPartial with staging/final directory information and rendered ad file stem """ - title = await self._resolve_download_title(ad_id) + title = await self._resolve_download_title(ad_id, owned_overview = owned_overview) LOG.info('Resolved title for ad %s: "%s"', ad_id, title) # Determine the final directory path diff --git a/src/kleinanzeigen_bot/publishing_form.py b/src/kleinanzeigen_bot/publishing_form.py index 3c590c1d..aa59a980 100644 --- a/src/kleinanzeigen_bot/publishing_form.py +++ b/src/kleinanzeigen_bot/publishing_form.py @@ -35,11 +35,17 @@ from .utils.web_scraping_mixin import By, Element, Is, WebScrapingMixin LOG:Final[_loggers.Logger] = _loggers.get_logger(__name__) +_OPEN_SHIPPING_DIALOG_XPATH:Final[str] = '//*[self::dialog[@open] or (@role="dialog" and not(@aria-hidden="true"))]' _OTHER_SHIPPING_METHODS_XPATH:Final[str] = ( - '//*[self::dialog[@open] or (@role="dialog" and not(@aria-hidden="true"))]' - '//*[contains(normalize-space(.), "Andere Versandmethoden")' + _OPEN_SHIPPING_DIALOG_XPATH + + '//*[contains(normalize-space(.), "Andere Versandmethoden")' ' and not(.//*[contains(normalize-space(.), "Andere Versandmethoden")])]' ) +_SHIPPING_SIZE_RADIO_XPATH:Final[str] = ( + f'{_OPEN_SHIPPING_DIALOG_XPATH}//input[@type="radio" and ' + '(@value="SMALL" or @value="MEDIUM" or @value="LARGE")]' +) +_SHIPPING_BACK_XPATH:Final[str] = f'{_OPEN_SHIPPING_DIALOG_XPATH}//button[contains(., "Zurück")]' async def set_category(web:WebScrapingMixin, *, root_url:str, category:str | None, ad_file:str) -> None: @@ -730,23 +736,53 @@ async def _set_configured_shipping_options( LOG.debug("Shipping enabled toggle not found before options dialog: %s", ex) await web.web_click(By.ID, "ad-shipping-options") - if mode == AdUpdateStrategy.MODIFY: - try: - await web.web_find(By.XPATH, _OTHER_SHIPPING_METHODS_XPATH, timeout = short_timeout) - except TimeoutError: - await web.web_click(By.XPATH, '//button[contains(., "Zurück")]', timeout = short_timeout) - try: - await web.web_find(By.XPATH, _OTHER_SHIPPING_METHODS_XPATH, timeout = short_timeout) - except TimeoutError: - await web.web_click(By.XPATH, '//button[contains(., "Zurück")]', timeout = short_timeout) - - # The redesign has rendered this action as different element types. Click - # its deepest text-bearing node so the event bubbles to whichever clickable - # ancestor the current dialog uses. - await web.web_click(By.XPATH, _OTHER_SHIPPING_METHODS_XPATH, timeout = short_timeout) + await _open_shipping_size_selection(web, short_timeout) await set_shipping_options(web, ad_cfg, mode) +async def _open_shipping_size_selection( + web:WebScrapingMixin, + short_timeout:int | float, +) -> None: + """Reach a supported shipping size pane across Kleinanzeigen A/B variants. + + A size pane may be visible directly or behind ``Andere Versandmethoden``. + Nested panes are unwound by at most two back steps; unsupported dialog + states fail with a clear timeout error. + + Examples: + A direct size pane returns immediately. An alternate-methods action is + opened before returning. A nested carrier pane is unwound with bounded + back navigation. A dialog exposing none of these routes times out. + """ + max_back_steps = 2 + try: + for back_steps in range(max_back_steps + 1): + size_radio = await web.web_probe(By.XPATH, _SHIPPING_SIZE_RADIO_XPATH, timeout = short_timeout) + if size_radio is not None: + LOG.debug("Shipping dialog route: direct size selection (%s back step(s)).", back_steps) + return + + other_methods = await web.web_probe(By.XPATH, _OTHER_SHIPPING_METHODS_XPATH, timeout = short_timeout) + if other_methods is not None: + await web.web_click(By.XPATH, _OTHER_SHIPPING_METHODS_XPATH, timeout = short_timeout) + await web.web_find(By.XPATH, _SHIPPING_SIZE_RADIO_XPATH, timeout = short_timeout) + LOG.debug("Shipping dialog route: Andere Versandmethoden (%s back step(s)).", back_steps) + return + + if back_steps == max_back_steps: + break + back_button = await web.web_probe(By.XPATH, _SHIPPING_BACK_XPATH, timeout = short_timeout) + if back_button is None: + break + await web.web_click(By.XPATH, _SHIPPING_BACK_XPATH, timeout = short_timeout) + await web.web_sleep(300, 500) + except TimeoutError as ex: + raise TimeoutError(_("Failed to configure shipping options in dialog!")) from ex + + raise TimeoutError(_("Failed to configure shipping options in dialog!")) + + async def _enable_platform_default_shipping( web:WebScrapingMixin, short_timeout:int | float ) -> None: @@ -782,7 +818,7 @@ async def set_shipping_options(web:WebScrapingMixin, ad_cfg:Ad, mode:AdUpdateStr all_codes_for_size = CARRIER_CODES_BY_SIZE[shipping_size] short_timeout = web.timeout("quick_dom") - dialog = '//*[self::dialog or @role="dialog"]' + dialog = _OPEN_SHIPPING_DIALOG_XPATH try: # Select the size group via radio button value (e.g. "SMALL", "MEDIUM", "LARGE") diff --git a/src/kleinanzeigen_bot/publishing_submission.py b/src/kleinanzeigen_bot/publishing_submission.py index 689e4dca..61e82b47 100644 --- a/src/kleinanzeigen_bot/publishing_submission.py +++ b/src/kleinanzeigen_bot/publishing_submission.py @@ -11,10 +11,11 @@ import re import urllib.parse as urllib_parse from gettext import gettext as _ +from typing import Final from nodriver.core.connection import ProtocolException -from . import captcha_flow +from . import captcha_flow, published_ads from .model.ad_model import Ad, AdUpdateStrategy from .model.config_model import CaptchaConfig from .utils import loggers as _loggers @@ -23,6 +24,78 @@ from .utils.web_scraping_mixin import By, WebScrapingMixin LOG = _loggers.get_logger(__name__) +_PUBLISHED_AD_RECOVERY_DELAYS_MS:Final[tuple[int, ...]] = (0, 1_000, 2_000, 4_000) + + +async def _is_idless_publish_success_page(web:WebScrapingMixin) -> bool: + """Detect the redesigned successful publish page that exposes no ad ID.""" + try: + result = await web.web_execute(r""" +(() => { + const bodyText = (document.body?.innerText || '').replace(/\s+/g, ' ').trim(); + const hasManageAdsControl = [...document.querySelectorAll('a, button')].some((element) => + (element.innerText || '').replace(/\s+/g, ' ').trim().includes('Zu meinen Anzeigen') + ); + return bodyText.includes('Geschafft!') && hasManageAdsControl; +})() +""") + except (TimeoutError, ProtocolException): + return False + return result is True + + +async def _try_recover_ad_id_from_published_ads( + web:WebScrapingMixin, + *, + root_url:str, + title:str, + known_published_ad_ids:frozenset[int] | None, +) -> int | None: + """Recover one newly published exact-title ad from a complete list. + + Recovery makes four strict fetch attempts, after delays of 0, 1, 2, and 4 + seconds. It returns ``None`` when the pre-submit baseline is unavailable, + no unique match appears, or multiple matching candidates make the result + ambiguous. + """ + if known_published_ad_ids is None: + LOG.warning("Published-ad ID recovery skipped because the pre-submit list was incomplete") + return None + + for delay_ms in _PUBLISHED_AD_RECOVERY_DELAYS_MS: + if delay_ms: + await web.web_sleep(delay_ms) + try: + current_ads = await published_ads.fetch_published_ads(web, root_url, strict = True) + except published_ads.PublishedAdsFetchIncompleteError as ex: + LOG.debug("Strict published-ad recovery fetch failed: %s", ex) + continue + + candidates:set[int] = set() + for published_ad in current_ads: + if published_ad.get("title") != title: + continue + raw_id = published_ad.get("id") + if raw_id is None: + continue + try: + candidate_id = int(raw_id) + except (TypeError, ValueError): + continue + if candidate_id not in known_published_ad_ids: + candidates.add(candidate_id) + + if len(candidates) == 1: + return next(iter(candidates)) + if len(candidates) > 1: + LOG.warning( + "Published-ad ID recovery was ambiguous for '%s'; refusing candidate IDs: %s", + title, + sorted(candidates), + ) + return None + + return None async def _try_recover_ad_id_from_redirect( @@ -91,6 +164,8 @@ async def submit_and_confirm_ad( mode:AdUpdateStrategy, *, captcha_config:CaptchaConfig, + root_url:str, + known_published_ad_ids:frozenset[int] | None = None, ) -> int: """Submit the ad form, handle post-submit dialogs, wait for confirmation, and extract the published ad ID. @@ -130,6 +205,7 @@ async def submit_and_confirm_ad( # Everything after the first click is uncertain: the ad may already have been submitted. ad_id:int | None = None + idless_success_detected = False try: quick_dom = web.timeout("quick_dom") @@ -171,34 +247,65 @@ async def submit_and_confirm_ad( confirmation_timeout = web.timeout("publishing_confirmation") - async def _check_confirmation_url() -> bool: + async def _check_confirmation_state() -> bool: + nonlocal idless_success_detected url = str(await web.web_execute("window.location.href")) - return "p-anzeige-aufgeben-bestaetigung.html?adId=" in url - - await web.web_await(_check_confirmation_url, timeout = confirmation_timeout) - - # extract the ad id from the URL's query parameter (use JS for fresh URL, not stale page url) - current_url = str(await web.web_execute("window.location.href")) - current_url_query_params = urllib_parse.parse_qs(urllib_parse.urlparse(current_url).query) - ad_id = int(current_url_query_params.get("adId", [])[0]) + if "p-anzeige-aufgeben-bestaetigung.html?adId=" in url: + return True + if mode == AdUpdateStrategy.REPLACE and await _is_idless_publish_success_page(web): + idless_success_detected = True + return True + return False + + await web.web_await(_check_confirmation_state, timeout = confirmation_timeout) + + if idless_success_detected: + try: + ad_id = await _try_recover_ad_id_from_published_ads( + web, + root_url = root_url, + title = ad_cfg.title, + known_published_ad_ids = known_published_ad_ids, + ) + except Exception as recovery_ex: # noqa: BLE001 + LOG.debug("Published-ad list fallback failed: %s", recovery_ex) + raise PublishSubmissionUncertainError( + "publish succeeded but no ad ID could be recovered" + ) from recovery_ex + if ad_id is None: + raise PublishSubmissionUncertainError( + "publish succeeded but no ad ID could be recovered" + ) + LOG.warning( + "Confirmation page exposed no ad ID; recovered ad ID %s from the published ads list", + ad_id, + ) + else: + # Use the live URL because the page object URL may be stale after redirects. + current_url = str(await web.web_execute("window.location.href")) + current_url_query_params = urllib_parse.parse_qs(urllib_parse.urlparse(current_url).query) + ad_id = int(current_url_query_params.get("adId", [])[0]) except (TimeoutError, ProtocolException, IndexError, ValueError, TypeError) as ex: # The confirmation page may have auto-redirected before we could poll it, # or the URL was redirected between polling and extraction (race condition). # Try to recover the ad ID from tracking data on the current page. LOG.debug("Confirmation URL polling or extraction failed (%s), attempting tracking data fallback...", type(ex).__name__) + recovered_from_tracking = False try: ad_id = await _try_recover_ad_id_from_redirect(web, pre_submit_referrer = pre_submit_referrer) + recovered_from_tracking = ad_id is not None except Exception as fallback_ex: # noqa: BLE001 LOG.debug("Tracking data fallback failed: %s", fallback_ex) if ad_id is None: raise PublishSubmissionUncertainError("submission may have succeeded before failure") from ex - LOG.warning( - "Confirmation page redirected too fast; extracted ad ID %s from page tracking data", - ad_id, - ) + if recovered_from_tracking: + LOG.warning( + "Confirmation page redirected too fast; extracted ad ID %s from page tracking data", + ad_id, + ) # Defensive guard: ad_id must be set by now — either from the confirmation URL # (try block) or the tracking fallback (except block). The except block always diff --git a/src/kleinanzeigen_bot/publishing_workflow.py b/src/kleinanzeigen_bot/publishing_workflow.py index 9e9a5cff..5b694530 100644 --- a/src/kleinanzeigen_bot/publishing_workflow.py +++ b/src/kleinanzeigen_bot/publishing_workflow.py @@ -101,7 +101,8 @@ async def publish_ad( config:Config, keep_old_ads:bool, config_file_path:str, -) -> None: + known_published_ad_ids:frozenset[int] | None = None, +) -> int: """Publish or update an ad on Kleinanzeigen. Args: @@ -118,6 +119,12 @@ async def publish_ad( keep_old_ads: If True, skip old-ad deletion. config_file_path: Path to the config file (for relative path resolution). + known_published_ad_ids: IDs from a complete pre-submit snapshot. ``None`` + disables ID-less publish recovery because ownership cannot be + established safely. + + Returns: + The resolved ID of the published or updated ad. """ old_ad_id = ad_cfg.id @@ -167,6 +174,8 @@ async def publish_ad( ad_id = await _publishing_submission.submit_and_confirm_ad( web, ad_file, ad_cfg, mode, captcha_config = config.captcha, + root_url = root_url, + known_published_ad_ids = known_published_ad_ids, ) try: @@ -182,6 +191,8 @@ async def publish_ad( ) raise PostPublishPersistenceError(ad_id = ad_id, ad_title = ad_cfg.title, original = ex) from ex + return ad_id + async def _fetch_published_ads_for_publish( web:WebScrapingMixin, @@ -191,33 +202,58 @@ async def _fetch_published_ads_for_publish( *, keep_old_ads:bool, ) -> tuple[list[PublishedAd], list[PublishedAd] | None, bool]: - """Fetch published ads for publish flow, strictly when title cleanup needs it.""" + """Fetch published ads and a complete baseline for ownership-critical work. + + Returns: + The ads used for matching, the complete pre-submit snapshot (or + ``None`` when unavailable), and whether publishing must fail closed + without that complete snapshot. + """ require_strict_fetch = ( not keep_old_ads and config.publishing.delete_old_ads == "BEFORE_PUBLISH" and config.publishing.delete_old_ads_by_title and any(ad_cfg.id is None for _ad_file, ad_cfg, _ad_cfg_orig in ad_cfgs) ) - published_ads_list = await published_ads.fetch_published_ads(web, root_url) - strict_published_ads_list:list[PublishedAd] | None = None - - if require_strict_fetch: - try: - strict_published_ads_list = await published_ads.fetch_published_ads( - web, - root_url, - strict = True, - ) - except PublishedAdsFetchIncompleteError as ex: + try: + strict_published_ads_list = await published_ads.fetch_published_ads( + web, + root_url, + strict = True, + ) + published_ads_list = strict_published_ads_list + except PublishedAdsFetchIncompleteError as ex: + strict_published_ads_list = None + if require_strict_fetch: LOG.error( "Skipping title-based publishes because full published-ad list could not " "be fetched before publish: %s", ex, ) + else: + LOG.warning( + "Complete published-ad snapshot unavailable; no-ID publish recovery is disabled: %s", + ex, + ) + published_ads_list = await published_ads.fetch_published_ads(web, root_url) return published_ads_list, strict_published_ads_list, require_strict_fetch +def _published_ad_ids(ads:list[PublishedAd]) -> set[int]: + """Return parseable IDs from a complete published-ad snapshot.""" + result:set[int] = set() + for published_ad in ads: + raw_id = published_ad.get("id") + if raw_id is None: + continue + try: + result.add(int(raw_id)) + except (TypeError, ValueError): + continue + return result + + async def publish_ads( web:WebScrapingMixin, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]], @@ -252,6 +288,11 @@ async def publish_ads( ad_cfgs, keep_old_ads = keep_old_ads, ) + known_published_ad_ids = ( + _published_ad_ids(strict_published_ads_list) + if strict_published_ads_list is not None + else None + ) for idx, (ad_file, ad_cfg, ad_cfg_orig) in enumerate(ad_cfgs, start = 1): LOG.info("Processing %s/%s: '%s' from [%s]...", idx, len(ad_cfgs), ad_cfg.title, ad_file) @@ -287,13 +328,20 @@ async def publish_ads( # so retries remain idempotent for a single eligible reduction cycle. ad_cfg.price = baseline_price ad_cfg.price_reduction_count = baseline_price_reduction_count - await publish_ad( + resolved_ad_id = await publish_ad( web, ad_file, ad_cfg, ad_cfg_orig, published_ads_for_matching, AdUpdateStrategy.REPLACE, root_url = root_url, config = config, keep_old_ads = keep_old_ads, config_file_path = config_file_path, + known_published_ad_ids = ( + frozenset(known_published_ad_ids) + if known_published_ad_ids is not None + else None + ), ) + if known_published_ad_ids is not None and isinstance(resolved_ad_id, int): + known_published_ad_ids.add(resolved_ad_id) success = True break # Publish succeeded, exit retry loop except asyncio.CancelledError: @@ -328,6 +376,8 @@ async def publish_ads( failed_count += 1 break except PostPublishPersistenceError as ex: + if known_published_ad_ids is not None and ex.ad_id is not None: + known_published_ad_ids.add(ex.ad_id) if capture_diagnostics: await capture_diagnostics(ad_cfg, ad_cfg_orig, ad_file, attempt, ex) LOG.warning( @@ -451,6 +501,7 @@ async def update_ads( root_url = root_url, config = config, keep_old_ads = keep_old_ads, config_file_path = config_file_path, + known_published_ad_ids = None, ) success = True break diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 23818c96..83885807 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -187,6 +187,7 @@ kleinanzeigen_bot/publishing_workflow.py: _fetch_published_ads_for_publish: "Skipping title-based publishes because full published-ad list could not be fetched before publish: %s": "Titelbasierte Veröffentlichungen werden übersprungen, weil die vollständige Liste veröffentlichter Anzeigen vor der Veröffentlichung nicht abgerufen werden konnte: %s" + "Complete published-ad snapshot unavailable; no-ID publish recovery is disabled: %s": "Vollständiger Stand der veröffentlichten Anzeigen nicht verfügbar; die Wiederherstellung nach Veröffentlichung ohne Anzeigen-ID ist deaktiviert: %s" publish_ads: "Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..." @@ -249,6 +250,9 @@ kleinanzeigen_bot/published_ads.py: ################################################# kleinanzeigen_bot/publishing_form.py: ################################################# + _open_shipping_size_selection: + "Failed to configure shipping options in dialog!": "Versandoptionen konnten im Dialog nicht konfiguriert werden!" + set_contact_fields: "Could not set contact street.": "Kontaktstraße konnte nicht gesetzt werden." "Could not set contact name.": "Kontaktname konnte nicht gesetzt werden." @@ -371,12 +375,17 @@ kleinanzeigen_bot/publishing_persistence.py: ################################################# kleinanzeigen_bot/publishing_submission.py: ################################################# + _try_recover_ad_id_from_published_ads: + "Published-ad ID recovery skipped because the pre-submit list was incomplete": "Wiederherstellung der Anzeigen-ID übersprungen, weil die Liste vor dem Absenden unvollständig war" + "Published-ad ID recovery was ambiguous for '%s'; refusing candidate IDs: %s": "Wiederherstellung der Anzeigen-ID für '%s' war mehrdeutig; mögliche IDs werden abgelehnt: %s" + submit_and_confirm_ad: "############################################": "############################################" "Dismissing upsell dialog...": "Upsell-Dialog schließen..." "# Payment form detected! Please proceed with payment.": "# Bestellformular gefunden! Bitte mit der Bezahlung fortfahren." "Press a key to continue...": "Eine Taste drücken, um fortzufahren..." "Confirmation page redirected too fast; extracted ad ID %s from page tracking data": "Bestätigungsseite wurde zu schnell weitergeleitet; Anzeigen-ID %s aus Seiten-Trackingdaten extrahiert" + "Confirmation page exposed no ad ID; recovered ad ID %s from the published ads list": "Bestätigungsseite enthielt keine Anzeigen-ID; Anzeigen-ID %s aus der Liste veröffentlichter Anzeigen wiederhergestellt" "ad_id is unexpectedly None after confirmation flow for %s": "ad_id ist unerwartet None nach dem Bestätigungsablauf für %s" ################################################# @@ -587,6 +596,9 @@ kleinanzeigen_bot/price_reduction.py: ################################################# kleinanzeigen_bot/extract.py: ################################################# + _resolve_download_title: + "Removed owned-ad status decoration from downloaded ad %s title.": "Statuspräfix aus dem Titel der eigenen heruntergeladenen Anzeige %s entfernt." + download_ad: "Using download directory: %s": "Verwende Download-Verzeichnis: %s" diff --git a/tests/unit/test_download_flow.py b/tests/unit/test_download_flow.py index 9688932f..4e0bf3e7 100644 --- a/tests/unit/test_download_flow.py +++ b/tests/unit/test_download_flow.py @@ -288,7 +288,11 @@ async def test_download_ads_all_selector_resolves_and_passes_active_state( mock_fetch_published_ads.assert_awaited_once_with(test_bot, test_bot.root_url, strict = False) # Verify download_ad called with correct active parameter - extractor_mock.download_ad.assert_awaited_once_with(123, active = scenario["expected_active"]) + extractor_mock.download_ad.assert_awaited_once_with( + 123, + active = scenario["expected_active"], + owned_overview = True, + ) # Verify ownership warning only when expected ownership_warnings = [msg for msg in caplog.messages if "found in overview but not in published profile" in msg] @@ -404,7 +408,11 @@ async def test_download_ads_new_selector_resolves_and_passes_active_state( mock_fetch_published_ads.assert_awaited_once_with(test_bot, test_bot.root_url, strict = False) # Verify download_ad called with correct active parameter - extractor_mock.download_ad.assert_awaited_once_with(999, active = scenario["expected_active"]) + extractor_mock.download_ad.assert_awaited_once_with( + 999, + active = scenario["expected_active"], + owned_overview = True, + ) @pytest.mark.asyncio async def test_download_ads_new_selector_skips_already_saved( @@ -512,7 +520,11 @@ async def test_download_ads_new_selector_passes_inactive_for_ad_not_in_published ) # Verify download_ad was called with active=False (not in profile) - extractor_mock.download_ad.assert_awaited_once_with(999, active = False) + extractor_mock.download_ad.assert_awaited_once_with( + 999, + active = False, + owned_overview = True, + ) @pytest.mark.asyncio async def test_download_ads_all_selector_skips_when_navigation_fails( @@ -582,4 +594,8 @@ async def test_download_ads_all_selector_treats_unexpected_states_as_inactive( ) # All non-"active" states should result in active=False - extractor_mock.download_ad.assert_awaited_once_with(123, active = False) + extractor_mock.download_ad.assert_awaited_once_with( + 123, + active = False, + owned_overview = True, + ) diff --git a/tests/unit/test_extract.py b/tests/unit/test_extract.py index 80533c30..438990d7 100644 --- a/tests/unit/test_extract.py +++ b/tests/unit/test_extract.py @@ -1000,12 +1000,15 @@ async def test_extract_ad_page_info_uses_css_selector_for_creation_date( assert ad_cfg.created_on.isoformat().startswith("2025-02-03") @pytest.mark.asyncio - async def test_resolve_download_title_prefers_published_metadata(self, test_extractor:extract_module.AdExtractor) -> None: - """Use the clean manage-ads title for owned ads instead of the page title.""" + async def test_resolve_download_title_prefers_published_metadata_for_owned_overview( + self, + test_extractor:extract_module.AdExtractor, + ) -> None: + """Use the clean manage-ads title even for an owned-overview download.""" test_extractor.published_ads_by_id = {12345: {"id": 12345, "title": " Clean API Title "}} with patch.object(test_extractor, "_extract_title_from_ad_page", new_callable = AsyncMock) as mock_extract_title: - title = await test_extractor._resolve_download_title(12345) + title = await test_extractor._resolve_download_title(12345, owned_overview = True) assert title == "Clean API Title" mock_extract_title.assert_not_called() @@ -1031,6 +1034,49 @@ async def test_resolve_download_title_falls_back_to_page_title(self, test_extrac assert title == "Page Title" + @pytest.mark.asyncio + async def test_resolve_download_title_strips_deleted_decoration_for_owned_overview_ad( + self, + test_extractor:extract_module.AdExtractor, + ) -> None: + """Strip a verified status decoration only on an owned overview fallback.""" + test_extractor.published_ads_by_id = {} + + with patch.object( + test_extractor, + "_extract_title_from_ad_page", + new_callable = AsyncMock, + return_value = "Gelöscht • Original title", + ): + title = await test_extractor._resolve_download_title(12345, owned_overview = True) + + assert title == "Original title" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "page_title", + ["Gelöscht • Original title", "Pausiert • Original title"], + ) + async def test_resolve_download_title_keeps_decorations_outside_verified_owned_context( + self, + test_extractor:extract_module.AdExtractor, + page_title:str, + ) -> None: + """Manual downloads and unverified status words keep the literal page title.""" + test_extractor.published_ads_by_id = {} + + with patch.object( + test_extractor, + "_extract_title_from_ad_page", + new_callable = AsyncMock, + return_value = page_title, + ): + manual_title = await test_extractor._resolve_download_title(12345) + owned_title = await test_extractor._resolve_download_title(12345, owned_overview = True) + + assert manual_title == page_title + assert owned_title == ("Original title" if page_title.startswith("Gelöscht") else page_title) + @pytest.mark.asyncio async def test_cached_title_entities_are_decoded_before_title_validation( self, @@ -1664,7 +1710,10 @@ async def test_download_ad_passes_active_override(self, extractor:extract_module await extractor.download_ad(12345, active = False) - mock_extract_with_dir.assert_awaited_once_with(download_base, 12345, active_override = False) + await_args = mock_extract_with_dir.await_args + assert await_args is not None + assert await_args.args == (download_base, 12345) + assert await_args.kwargs["active_override"] is False @pytest.mark.asyncio async def test_download_ad_writes_schema_compliant_yaml(self, extractor:extract_module.AdExtractor, tmp_path:Path) -> None: diff --git a/tests/unit/test_publishing_form.py b/tests/unit/test_publishing_form.py index a048befa..eca9ce21 100644 --- a/tests/unit/test_publishing_form.py +++ b/tests/unit/test_publishing_form.py @@ -21,6 +21,8 @@ from kleinanzeigen_bot.model.config_model import PublishingConfig from kleinanzeigen_bot.publishing_form import ( _OTHER_SHIPPING_METHODS_XPATH, # noqa: PLC2701 + _SHIPPING_BACK_XPATH, # noqa: PLC2701 + _SHIPPING_SIZE_RADIO_XPATH, # noqa: PLC2701 _select_button_combobox, # noqa: PLC2701 - needed for coverage of React fiber selection _set_condition, # noqa: PLC2701 _set_configured_shipping_options, # noqa: PLC2701 @@ -1534,6 +1536,13 @@ async def test_open_dialog_action_is_tag_agnostic( with ( patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click, + patch.object( + test_bot, + "web_probe", + new_callable = AsyncMock, + side_effect = [None, MagicMock()], + ), + patch.object(test_bot, "web_find", new_callable = AsyncMock), patch.object(test_bot, "web_sleep", new_callable = AsyncMock), patch("kleinanzeigen_bot.publishing_form.set_shipping_options", new_callable = AsyncMock), ): @@ -1553,46 +1562,142 @@ async def test_open_dialog_action_is_tag_agnostic( ) @pytest.mark.asyncio - async def test_modify_navigates_back_to_tag_agnostic_dialog_action( + async def test_reports_clear_error_when_alternate_action_disappears( self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], ) -> None: - """MODIFY mode retries the tag-agnostic action after navigating back one dialog step.""" + """Report the workflow error when a probed alternate action disappears.""" ad_cfg = self._make_ad_with_options(base_ad_config, ["DHL_5"]) - events:list[str] = [] - async def find_action(*_:Any, **__:Any) -> None: - events.append("find") - raise TimeoutError("action not on current step") + with ( + patch.object( + test_bot, + "web_click", + new_callable = AsyncMock, + side_effect = [None, None, TimeoutError("action disappeared")], + ), + patch.object( + test_bot, + "web_probe", + new_callable = AsyncMock, + side_effect = [None, MagicMock()], + ), + patch.object(test_bot, "web_sleep", new_callable = AsyncMock), + patch("kleinanzeigen_bot.publishing_form.set_shipping_options", new_callable = AsyncMock) as options_mock, + pytest.raises(TimeoutError, match = "Failed to configure shipping options in dialog"), + ): + await _set_configured_shipping_options( + test_bot, + ad_cfg, + AdUpdateStrategy.REPLACE, + test_bot.timeout("quick_dom"), + ) + + options_mock.assert_not_awaited() - async def record_click(selector_type:By, selector_value:str, **_:Any) -> None: - if selector_type != By.XPATH: - return - if selector_value == '//button[contains(., "Zurück")]': - events.append("back") - elif selector_value == _OTHER_SHIPPING_METHODS_XPATH: - events.append("action") + @pytest.mark.asyncio + async def test_navigates_back_to_direct_size_selection( + self, + test_bot:KleinanzeigenBot, + base_ad_config:dict[str, Any], + ) -> None: + """A nested dialog state is unwound before selecting a package size.""" + ad_cfg = self._make_ad_with_options(base_ad_config, ["DHL_5"]) with ( patch.object( test_bot, - "web_find", + "web_probe", new_callable = AsyncMock, - side_effect = find_action, + side_effect = [None, None, MagicMock(), MagicMock()], ), - patch.object(test_bot, "web_click", new_callable = AsyncMock, side_effect = record_click), + patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click, patch.object(test_bot, "web_sleep", new_callable = AsyncMock), patch("kleinanzeigen_bot.publishing_form.set_shipping_options", new_callable = AsyncMock), ): await _set_configured_shipping_options( test_bot, ad_cfg, - AdUpdateStrategy.MODIFY, + AdUpdateStrategy.REPLACE, + test_bot.timeout("quick_dom"), + ) + + assert any( + click.args == (By.XPATH, _SHIPPING_BACK_XPATH) + for click in mock_click.await_args_list + ) + assert not any( + click.args == (By.XPATH, _OTHER_SHIPPING_METHODS_XPATH) + for click in mock_click.await_args_list + ) + + @pytest.mark.asyncio + async def test_fails_when_dialog_has_no_supported_navigation( + self, + test_bot:KleinanzeigenBot, + base_ad_config:dict[str, Any], + ) -> None: + """Fail clearly when neither a size pane nor a back route is present.""" + ad_cfg = self._make_ad_with_options(base_ad_config, ["DHL_5"]) + with ( + patch.object(test_bot, "web_click", new_callable = AsyncMock), + patch.object( + test_bot, + "web_probe", + new_callable = AsyncMock, + side_effect = [None, None, None], + ), + patch.object(test_bot, "web_sleep", new_callable = AsyncMock), + patch("kleinanzeigen_bot.publishing_form.set_shipping_options", new_callable = AsyncMock) as options_mock, + pytest.raises(TimeoutError, match = "Failed to configure shipping options"), + ): + await _set_configured_shipping_options( + test_bot, + ad_cfg, + AdUpdateStrategy.REPLACE, test_bot.timeout("quick_dom"), ) - assert events == ["find", "back", "find", "back", "action"] + options_mock.assert_not_awaited() + + @pytest.mark.asyncio + async def test_fails_after_bounded_back_navigation( + self, + test_bot:KleinanzeigenBot, + base_ad_config:dict[str, Any], + ) -> None: + """Stop after two back steps when no supported size pane appears.""" + ad_cfg = self._make_ad_with_options(base_ad_config, ["DHL_5"]) + with ( + patch.object( + test_bot, + "web_probe", + new_callable = AsyncMock, + side_effect = [ + None, None, MagicMock(), + None, None, MagicMock(), + None, None, + ], + ), + patch.object(test_bot, "web_click", new_callable = AsyncMock) as click_mock, + patch.object(test_bot, "web_sleep", new_callable = AsyncMock), + patch("kleinanzeigen_bot.publishing_form.set_shipping_options", new_callable = AsyncMock) as options_mock, + pytest.raises(TimeoutError, match = "Failed to configure shipping options"), + ): + await _set_configured_shipping_options( + test_bot, + ad_cfg, + AdUpdateStrategy.REPLACE, + test_bot.timeout("quick_dom"), + ) + + back_clicks = [ + call for call in click_mock.await_args_list + if call.args == (By.XPATH, _SHIPPING_BACK_XPATH) + ] + assert len(back_clicks) == 2 + options_mock.assert_not_awaited() @pytest.mark.parametrize( "case", @@ -1915,6 +2020,8 @@ async def mock_web_execute(script:str) -> Any: 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 + if selector_type == By.XPATH and selector_value == _SHIPPING_SIZE_RADIO_XPATH: + return shipping_size_radio return None mock_probe.side_effect = probe_side_effect diff --git a/tests/unit/test_publishing_submission.py b/tests/unit/test_publishing_submission.py index 640807a9..58f1c0d4 100644 --- a/tests/unit/test_publishing_submission.py +++ b/tests/unit/test_publishing_submission.py @@ -3,6 +3,8 @@ # SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ """Tests for publishing submission functionality.""" +from collections.abc import Awaitable, Callable +from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -10,6 +12,7 @@ from kleinanzeigen_bot import publishing_submission from kleinanzeigen_bot.app import KleinanzeigenBot from kleinanzeigen_bot.model.ad_model import Ad, AdUpdateStrategy +from kleinanzeigen_bot.published_ads import PublishedAdsFetchIncompleteError from kleinanzeigen_bot.utils.exceptions import PublishSubmissionUncertainError from kleinanzeigen_bot.utils.web_scraping_mixin import By @@ -36,6 +39,21 @@ def _make_min_ad() -> Ad: }) +def _idless_success_execute(root_url:str) -> Callable[[str], Awaitable[Any]]: + """Return browser-script behavior for the redesigned ID-less success page.""" + + async def execute(script:str) -> Any: + if "document.referrer" in script: + return "" + if "Geschafft!" in script: + return True + if "window.location.href" in script: + return f"{root_url}/done" + return None + + return execute + + class TestTrackingFallback: """Tests for _try_recover_ad_id_from_redirect helper method.""" @@ -135,6 +153,7 @@ async def test_returns_ad_id_on_success(self, test_bot:KleinanzeigenBot) -> None result = await publishing_submission.submit_and_confirm_ad( test_bot, "test.yaml", ad, AdUpdateStrategy.REPLACE, captcha_config = captcha_config, + root_url = test_bot.root_url, ) assert result == 12345 @@ -163,6 +182,7 @@ async def test_dismisses_upsell_dialog(self, test_bot:KleinanzeigenBot) -> None: result = await publishing_submission.submit_and_confirm_ad( test_bot, "test.yaml", ad, AdUpdateStrategy.REPLACE, captcha_config = captcha_config, + root_url = test_bot.root_url, ) assert result == 12345 @@ -195,6 +215,7 @@ async def test_confirms_no_image_warning(self, test_bot:KleinanzeigenBot) -> Non result = await publishing_submission.submit_and_confirm_ad( test_bot, "test.yaml", ad, AdUpdateStrategy.REPLACE, captcha_config = captcha_config, + root_url = test_bot.root_url, ) assert result == 12345 @@ -221,6 +242,7 @@ async def test_detects_payment_form(self, test_bot:KleinanzeigenBot) -> None: result = await publishing_submission.submit_and_confirm_ad( test_bot, "test.yaml", ad, AdUpdateStrategy.REPLACE, captcha_config = captcha_config, + root_url = test_bot.root_url, ) assert result == 12345 @@ -247,6 +269,7 @@ async def test_falls_back_to_tracking_when_confirmation_fails(self, test_bot:Kle result = await publishing_submission.submit_and_confirm_ad( test_bot, "test.yaml", ad, AdUpdateStrategy.REPLACE, captcha_config = captcha_config, + root_url = test_bot.root_url, ) assert result == 99999 @@ -272,4 +295,205 @@ async def test_raises_uncertainty_error_when_recovery_fails(self, test_bot:Klein await publishing_submission.submit_and_confirm_ad( test_bot, "test.yaml", ad, AdUpdateStrategy.REPLACE, captcha_config = captcha_config, + root_url = test_bot.root_url, + ) + + +class TestPublishedAdsRecovery: + """Tests for fail-closed recovery from a complete published-ad snapshot.""" + + @pytest.mark.asyncio + async def test_requires_complete_pre_submit_baseline(self, test_bot:KleinanzeigenBot) -> None: + with patch( + "kleinanzeigen_bot.publishing_submission.published_ads.fetch_published_ads", + new_callable = AsyncMock, + ) as fetch_mock: + result = await publishing_submission._try_recover_ad_id_from_published_ads( + test_bot, + root_url = test_bot.root_url, + title = "Test Ad Title", + known_published_ad_ids = None, + ) + + assert result is None + fetch_mock.assert_not_awaited() + + @pytest.mark.asyncio + async def test_retries_until_one_new_exact_title_id_appears(self, test_bot:KleinanzeigenBot) -> None: + with ( + patch( + "kleinanzeigen_bot.publishing_submission.published_ads.fetch_published_ads", + new_callable = AsyncMock, + side_effect = [ + [{"id": 10, "state": "active", "title": "Test Ad Title"}], + [ + {"id": 10, "state": "active", "title": "Test Ad Title"}, + {"id": "11", "state": "active", "title": "Test Ad Title"}, + {"id": 12, "state": "active", "title": "Test Ad Title extra"}, + ], + ], + ) as fetch_mock, + patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock, + ): + result = await publishing_submission._try_recover_ad_id_from_published_ads( + test_bot, + root_url = test_bot.root_url, + title = "Test Ad Title", + known_published_ad_ids = frozenset({10}), ) + + assert result == 11 + assert fetch_mock.await_count == 2 + sleep_mock.assert_awaited_once_with(1_000) + + @pytest.mark.asyncio + async def test_rejects_ambiguous_new_exact_title_ids(self, test_bot:KleinanzeigenBot) -> None: + with ( + patch( + "kleinanzeigen_bot.publishing_submission.published_ads.fetch_published_ads", + new_callable = AsyncMock, + return_value = [ + {"id": 11, "state": "active", "title": "Test Ad Title"}, + {"id": 12, "state": "active", "title": "Test Ad Title"}, + ], + ), + patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock, + ): + result = await publishing_submission._try_recover_ad_id_from_published_ads( + test_bot, + root_url = test_bot.root_url, + title = "Test Ad Title", + known_published_ad_ids = frozenset({10}), + ) + + assert result is None + sleep_mock.assert_not_awaited() + + @pytest.mark.asyncio + async def test_recovery_continues_after_incomplete_fetch_and_ignores_invalid_ids( + self, + test_bot:KleinanzeigenBot, + ) -> None: + recovered_ads = [ + {"title": "Test Ad Title"}, + {"id": "not-an-id", "title": "Test Ad Title"}, + {"id": 10, "title": "Test Ad Title"}, + {"id": 11, "title": "Different title"}, + {"id": 12, "title": "Test Ad Title"}, + ] + with ( + patch( + "kleinanzeigen_bot.publishing_submission.published_ads.fetch_published_ads", + new_callable = AsyncMock, + side_effect = [ + PublishedAdsFetchIncompleteError("incomplete"), + recovered_ads, + ], + ) as fetch_mock, + patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock, + ): + result = await publishing_submission._try_recover_ad_id_from_published_ads( + test_bot, + root_url = test_bot.root_url, + title = "Test Ad Title", + known_published_ad_ids = frozenset({10}), + ) + + assert result == 12 + assert fetch_mock.await_count == 2 + sleep_mock.assert_awaited_once_with(1_000) + + @pytest.mark.asyncio + async def test_submit_recovers_id_after_explicit_idless_success(self, test_bot:KleinanzeigenBot) -> None: + ad = _make_min_ad() + + async def await_condition(condition:Any, **_:object) -> bool: + return bool(await condition()) + + with ( + patch("kleinanzeigen_bot.captcha_flow.check_and_wait_for_captcha", new_callable = AsyncMock), + patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock), + patch.object(test_bot, "web_click", new_callable = AsyncMock), + patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = [None] * 4), + patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = await_condition), + patch.object( + test_bot, + "web_execute", + new_callable = AsyncMock, + side_effect = _idless_success_execute(test_bot.root_url), + ), + patch( + "kleinanzeigen_bot.publishing_submission._try_recover_ad_id_from_redirect", + new_callable = AsyncMock, + return_value = None, + ) as redirect_recover_mock, + patch( + "kleinanzeigen_bot.publishing_submission._try_recover_ad_id_from_published_ads", + new_callable = AsyncMock, + return_value = 777, + ) as recover_mock, + ): + result = await publishing_submission.submit_and_confirm_ad( + test_bot, + "test.yaml", + ad, + AdUpdateStrategy.REPLACE, + captcha_config = test_bot.config.captcha, + root_url = test_bot.root_url, + known_published_ad_ids = frozenset({10}), + ) + + assert result == 777 + redirect_recover_mock.assert_not_awaited() + recover_mock.assert_awaited_once_with( + test_bot, + root_url = test_bot.root_url, + title = ad.title, + known_published_ad_ids = frozenset({10}), + ) + + @pytest.mark.asyncio + async def test_submit_fails_closed_when_published_ads_recovery_raises( + self, + test_bot:KleinanzeigenBot, + ) -> None: + ad = _make_min_ad() + + async def await_condition(condition:Any, **_:object) -> bool: + return bool(await condition()) + + with ( + patch("kleinanzeigen_bot.captcha_flow.check_and_wait_for_captcha", new_callable = AsyncMock), + patch.object(test_bot, "web_set_input_value", new_callable = AsyncMock), + patch.object(test_bot, "web_click", new_callable = AsyncMock), + patch.object(test_bot, "web_probe", new_callable = AsyncMock, side_effect = [None] * 4), + patch.object(test_bot, "web_await", new_callable = AsyncMock, side_effect = await_condition), + patch.object( + test_bot, + "web_execute", + new_callable = AsyncMock, + side_effect = _idless_success_execute(test_bot.root_url), + ), + patch( + "kleinanzeigen_bot.publishing_submission._try_recover_ad_id_from_redirect", + new_callable = AsyncMock, + return_value = None, + ) as redirect_recover_mock, + patch( + "kleinanzeigen_bot.publishing_submission._try_recover_ad_id_from_published_ads", + new_callable = AsyncMock, + side_effect = RuntimeError("API unavailable"), + ), + pytest.raises(PublishSubmissionUncertainError), + ): + await publishing_submission.submit_and_confirm_ad( + test_bot, + "test.yaml", + ad, + AdUpdateStrategy.REPLACE, + captcha_config = test_bot.config.captcha, + root_url = test_bot.root_url, + known_published_ad_ids = frozenset({10}), + ) + + redirect_recover_mock.assert_not_awaited() diff --git a/tests/unit/test_publishing_workflow.py b/tests/unit/test_publishing_workflow.py index 408b2d9a..79eebb21 100644 --- a/tests/unit/test_publishing_workflow.py +++ b/tests/unit/test_publishing_workflow.py @@ -416,30 +416,33 @@ async def test_publish_ads_fetches_published_ads_strictly_for_id_less_title_clea ad_cfg_orig = copy.deepcopy(base_ad_config) ad_file = "ad.yaml" - non_strict_ads = [{"id": 10, "state": "active"}] strict_ads = [ {"id": 10, "state": "active"}, - {"id": 11, "state": "active"}, + {"id": "11", "state": "active"}, + {"id": None, "state": "active"}, + {"id": "not-an-id", "state": "active"}, ] with ( patch( "kleinanzeigen_bot.published_ads.fetch_published_ads", new_callable = AsyncMock, - side_effect = [non_strict_ads, strict_ads], + return_value = strict_ads, ) as fetch_mock, - patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock) as publish_mock, + patch( + "kleinanzeigen_bot.publishing_workflow.publish_ad", + new_callable = AsyncMock, + return_value = 12, + ) as publish_mock, patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True), patch("kleinanzeigen_bot.delete_flow.delete_ad", new_callable = AsyncMock), ): await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)]) - fetch_mock.assert_has_awaits([ - call(test_bot, test_bot.root_url), - call(test_bot, test_bot.root_url, strict = True), - ]) + fetch_mock.assert_awaited_once_with(test_bot, test_bot.root_url, strict = True) assert publish_mock.await_count == 1 assert publish_mock.call_args.args[4] == strict_ads + assert publish_mock.call_args.kwargs["known_published_ad_ids"] == frozenset({10, 11}) summary = [record for record in caplog.records if "DONE:" in record.getMessage()] assert any("DONE: (Re-)published 1" in record.getMessage() for record in summary) @@ -462,7 +465,7 @@ async def test_publish_ads_fails_closed_when_strict_published_ads_fetch_fails_fo patch( "kleinanzeigen_bot.published_ads.fetch_published_ads", new_callable = AsyncMock, - side_effect = [[], PublishedAdsFetchIncompleteError("incomplete published-ad fetch")], + side_effect = [PublishedAdsFetchIncompleteError("incomplete published-ad fetch"), []], ) as fetch_mock, patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock) as publish_mock, patch.object(test_bot, "web_sleep", new_callable = AsyncMock) as sleep_mock, @@ -482,7 +485,7 @@ async def test_publish_ads_fails_closed_when_strict_published_ads_fetch_fails_fo assert any("DONE: (Re-)published 0 ads (1 failed after retries)" in record.getMessage() for record in summary) @pytest.mark.asyncio - async def test_publish_ads_keep_old_does_not_require_strict_title_cleanup_fetch( + async def test_publish_ads_keep_old_falls_back_when_strict_recovery_snapshot_fails( self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], @@ -500,7 +503,7 @@ async def test_publish_ads_keep_old_does_not_require_strict_title_cleanup_fetch( patch( "kleinanzeigen_bot.published_ads.fetch_published_ads", new_callable = AsyncMock, - return_value = published_ads, + side_effect = [PublishedAdsFetchIncompleteError("incomplete published-ad fetch"), published_ads], ) as fetch_mock, patch("kleinanzeigen_bot.publishing_workflow.publish_ad", new_callable = AsyncMock) as publish_mock, patch.object(test_bot, "web_await", new_callable = AsyncMock, return_value = True), @@ -508,9 +511,13 @@ async def test_publish_ads_keep_old_does_not_require_strict_title_cleanup_fetch( ): await test_bot.publish_ads([(ad_file, ad_cfg, ad_cfg_orig)]) - fetch_mock.assert_awaited_once_with(test_bot, test_bot.root_url) + fetch_mock.assert_has_awaits([ + call(test_bot, test_bot.root_url, strict = True), + call(test_bot, test_bot.root_url), + ]) publish_mock.assert_awaited_once() assert publish_mock.call_args.args[4] == published_ads + assert publish_mock.call_args.kwargs["known_published_ad_ids"] is None delete_mock.assert_not_awaited() @pytest.mark.asyncio