mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 02:31:45 +01:00
fix: extend command fails with >25 ads due to pagination (#793)
This commit is contained in:
@@ -999,15 +999,23 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
|||||||
LOG.info("Extending ad '%s' (ID: %s)...", ad_cfg.title, ad_cfg.id)
|
LOG.info("Extending ad '%s' (ID: %s)...", ad_cfg.title, ad_cfg.id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Navigate to ad management page
|
# Navigate to ad management page and find extend button across all pages
|
||||||
await self.web_open(f"{self.root_url}/m-meine-anzeigen.html")
|
|
||||||
|
|
||||||
# Find and click "Verlängern" (extend) button for this ad
|
|
||||||
extend_button_xpath = f'//li[@data-adid="{ad_cfg.id}"]//button[contains(., "Verlängern")]'
|
extend_button_xpath = f'//li[@data-adid="{ad_cfg.id}"]//button[contains(., "Verlängern")]'
|
||||||
|
|
||||||
try:
|
async def find_and_click_extend_button(page_num:int) -> bool:
|
||||||
await self.web_click(By.XPATH, extend_button_xpath)
|
"""Try to find and click extend button on current page."""
|
||||||
except TimeoutError:
|
try:
|
||||||
|
extend_button = await self.web_find(By.XPATH, extend_button_xpath, timeout = self._timeout("quick_dom"))
|
||||||
|
LOG.info("Found extend button on page %s", page_num)
|
||||||
|
await extend_button.click()
|
||||||
|
return True # Success - stop pagination
|
||||||
|
except TimeoutError:
|
||||||
|
LOG.debug("Extend button not found on page %s", page_num)
|
||||||
|
return False # Continue to next page
|
||||||
|
|
||||||
|
success = await self._navigate_paginated_ad_overview(find_and_click_extend_button, page_url = f"{self.root_url}/m-meine-anzeigen.html")
|
||||||
|
|
||||||
|
if not success:
|
||||||
LOG.error(" -> FAILED: Could not find extend button for ad ID %s", ad_cfg.id)
|
LOG.error(" -> FAILED: Could not find extend button for ad ID %s", ad_cfg.id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -148,104 +148,34 @@ class AdExtractor(WebScrapingMixin):
|
|||||||
|
|
||||||
:return: the links to your ad pages
|
:return: the links to your ad pages
|
||||||
"""
|
"""
|
||||||
# navigate to "your ads" page
|
|
||||||
await self.web_open("https://www.kleinanzeigen.de/m-meine-anzeigen.html")
|
|
||||||
await self.web_sleep(2000, 3000) # Consider replacing with explicit waits later
|
|
||||||
|
|
||||||
# Try to find the main ad list container first
|
|
||||||
try:
|
|
||||||
_ = await self.web_find(By.ID, "my-manageitems-adlist")
|
|
||||||
except TimeoutError:
|
|
||||||
LOG.warning("Ad list container #my-manageitems-adlist not found. Maybe no ads present?")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# --- Pagination handling ---
|
|
||||||
multi_page = False
|
|
||||||
pagination_timeout = self._timeout("pagination_initial")
|
|
||||||
try:
|
|
||||||
# Correct selector: Use uppercase '.Pagination'
|
|
||||||
pagination_section = await self.web_find(By.CSS_SELECTOR, ".Pagination", timeout = pagination_timeout) # Increased timeout slightly
|
|
||||||
# Correct selector: Use 'aria-label'
|
|
||||||
# Also check if the button is actually present AND potentially enabled (though enabled check isn't strictly necessary here, only for clicking later)
|
|
||||||
next_buttons = await self.web_find_all(By.CSS_SELECTOR, 'button[aria-label="Nächste"]', parent = pagination_section)
|
|
||||||
if next_buttons:
|
|
||||||
# Check if at least one 'Nächste' button is not disabled (optional but good practice)
|
|
||||||
enabled_next_buttons = [btn for btn in next_buttons if not btn.attrs.get("disabled")]
|
|
||||||
if enabled_next_buttons:
|
|
||||||
multi_page = True
|
|
||||||
LOG.info("Multiple ad pages detected.")
|
|
||||||
else:
|
|
||||||
LOG.info("Next button found but is disabled. Assuming single effective page.")
|
|
||||||
|
|
||||||
else:
|
|
||||||
LOG.info('No "Naechste" button found within pagination. Assuming single page.')
|
|
||||||
except TimeoutError:
|
|
||||||
# This will now correctly trigger only if the '.Pagination' div itself is not found
|
|
||||||
LOG.info("No pagination controls found. Assuming single page.")
|
|
||||||
except Exception as e:
|
|
||||||
LOG.exception("Error during pagination detection: %s", e)
|
|
||||||
LOG.info("Assuming single page due to error during pagination check.")
|
|
||||||
# --- End Pagination Handling ---
|
|
||||||
|
|
||||||
refs:list[str] = []
|
refs:list[str] = []
|
||||||
current_page = 1
|
|
||||||
while True: # Loop reference extraction
|
|
||||||
LOG.info("Extracting ads from page %s...", current_page)
|
|
||||||
# scroll down to load dynamically if necessary
|
|
||||||
await self.web_scroll_page_down()
|
|
||||||
await self.web_sleep(2000, 3000) # Consider replacing with explicit waits
|
|
||||||
|
|
||||||
# Re-find the ad list container on the current page/state
|
async def extract_page_refs(page_num:int) -> bool:
|
||||||
|
"""Extract ad reference URLs from the current page.
|
||||||
|
|
||||||
|
:param page_num: The current page number being processed
|
||||||
|
:return: True to stop pagination (e.g. ads container disappeared), False to continue to next page
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
ad_list_container = await self.web_find(By.ID, "my-manageitems-adlist")
|
ad_list_container = await self.web_find(By.ID, "my-manageitems-adlist")
|
||||||
list_items = await self.web_find_all(By.CLASS_NAME, "cardbox", parent = ad_list_container)
|
list_items = await self.web_find_all(By.CLASS_NAME, "cardbox", parent = ad_list_container)
|
||||||
LOG.info("Found %s ad items on page %s.", len(list_items), current_page)
|
LOG.info("Found %s ad items on page %s.", len(list_items), page_num)
|
||||||
except TimeoutError:
|
|
||||||
LOG.warning("Could not find ad list container or items on page %s.", current_page)
|
|
||||||
break # Stop if ads disappear
|
|
||||||
|
|
||||||
# Extract references using the CORRECTED selector
|
|
||||||
try:
|
|
||||||
page_refs:list[str] = [str((await self.web_find(By.CSS_SELECTOR, "div h3 a.text-onSurface", parent = li)).attrs["href"]) for li in list_items]
|
page_refs:list[str] = [str((await self.web_find(By.CSS_SELECTOR, "div h3 a.text-onSurface", parent = li)).attrs["href"]) for li in list_items]
|
||||||
refs.extend(page_refs)
|
refs.extend(page_refs)
|
||||||
LOG.info("Successfully extracted %s refs from page %s.", len(page_refs), current_page)
|
LOG.info("Successfully extracted %s refs from page %s.", len(page_refs), page_num)
|
||||||
except Exception as e:
|
return False # Continue to next page
|
||||||
# Log the error if extraction fails for some items, but try to continue
|
|
||||||
LOG.exception("Error extracting refs on page %s: %s", current_page, e)
|
|
||||||
|
|
||||||
if not multi_page: # only one iteration for single-page overview
|
|
||||||
break
|
|
||||||
|
|
||||||
# --- Navigate to next page ---
|
|
||||||
follow_up_timeout = self._timeout("pagination_follow_up")
|
|
||||||
try:
|
|
||||||
# Find the pagination section again (scope might have changed after scroll/wait)
|
|
||||||
pagination_section = await self.web_find(By.CSS_SELECTOR, ".Pagination", timeout = follow_up_timeout)
|
|
||||||
# Find the "Next" button using the correct aria-label selector and ensure it's not disabled
|
|
||||||
next_button_element = None
|
|
||||||
possible_next_buttons = await self.web_find_all(By.CSS_SELECTOR, 'button[aria-label="Nächste"]', parent = pagination_section)
|
|
||||||
for btn in possible_next_buttons:
|
|
||||||
if not btn.attrs.get("disabled"): # Check if the button is enabled
|
|
||||||
next_button_element = btn
|
|
||||||
break # Found an enabled next button
|
|
||||||
|
|
||||||
if next_button_element:
|
|
||||||
LOG.info("Navigating to next page...")
|
|
||||||
await next_button_element.click()
|
|
||||||
current_page += 1
|
|
||||||
# Wait for page load - consider waiting for a specific element on the new page instead of fixed sleep
|
|
||||||
await self.web_sleep(3000, 4000)
|
|
||||||
else:
|
|
||||||
LOG.info('Last ad overview page explored (no enabled "Naechste" button found).')
|
|
||||||
break
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
# This might happen if pagination disappears on the last page after loading
|
LOG.warning("Could not find ad list container or items on page %s.", page_num)
|
||||||
LOG.info("No pagination controls found after scrolling/waiting. Assuming last page.")
|
return True # Stop pagination (ads disappeared)
|
||||||
break
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.exception("Error during pagination navigation: %s", e)
|
# Continue despite error for resilience against transient web scraping issues
|
||||||
break
|
# (e.g., DOM structure changes, network glitches). LOG.exception ensures visibility.
|
||||||
# --- End Navigation ---
|
LOG.exception("Error extracting refs on page %s: %s", page_num, e)
|
||||||
|
return False # Continue to next page
|
||||||
|
|
||||||
|
await self._navigate_paginated_ad_overview(extract_page_refs)
|
||||||
|
|
||||||
if not refs:
|
if not refs:
|
||||||
LOG.warning("No ad URLs were extracted.")
|
LOG.warning("No ad URLs were extracted.")
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ kleinanzeigen_bot/__init__.py:
|
|||||||
" -> FAILED: Timeout while extending ad '%s': %s": " -> FEHLER: Zeitüberschreitung beim Verlängern der Anzeige '%s': %s"
|
" -> FAILED: Timeout while extending ad '%s': %s": " -> FEHLER: Zeitüberschreitung beim Verlängern der Anzeige '%s': %s"
|
||||||
" -> FAILED: Could not persist extension for ad '%s': %s": " -> FEHLER: Verlängerung der Anzeige '%s' konnte nicht gespeichert werden: %s"
|
" -> FAILED: Could not persist extension for ad '%s': %s": " -> FEHLER: Verlängerung der Anzeige '%s' konnte nicht gespeichert werden: %s"
|
||||||
|
|
||||||
|
find_and_click_extend_button:
|
||||||
|
"Found extend button on page %s": "'Verlängern'-Button auf Seite %s gefunden"
|
||||||
|
|
||||||
finalize_installation_mode:
|
finalize_installation_mode:
|
||||||
"Config file: %s": "Konfigurationsdatei: %s"
|
"Config file: %s": "Konfigurationsdatei: %s"
|
||||||
"First run detected, prompting user for installation mode": "Erster Start erkannt, frage Benutzer nach Installationsmodus"
|
"First run detected, prompting user for installation mode": "Erster Start erkannt, frage Benutzer nach Installationsmodus"
|
||||||
@@ -259,21 +262,11 @@ kleinanzeigen_bot/extract.py:
|
|||||||
"Failed to extract ad ID from URL '%s': %s": "Fehler beim Extrahieren der Anzeigen-ID aus der URL '%s': %s"
|
"Failed to extract ad ID from URL '%s': %s": "Fehler beim Extrahieren der Anzeigen-ID aus der URL '%s': %s"
|
||||||
|
|
||||||
extract_own_ads_urls:
|
extract_own_ads_urls:
|
||||||
"Ad list container #my-manageitems-adlist not found. Maybe no ads present?": "Anzeigenlistencontainer #my-manageitems-adlist nicht gefunden. Vielleicht sind keine Anzeigen vorhanden?"
|
|
||||||
"Multiple ad pages detected.": "Mehrere Anzeigenseiten erkannt."
|
|
||||||
"Next button found but is disabled. Assuming single effective page.": "Weiter-Button gefunden, aber deaktiviert. Es wird von einer einzelnen effektiven Seite ausgegangen."
|
|
||||||
"No \"Naechste\" button found within pagination. Assuming single page.": "Kein \"Nächste\"-Button in der Paginierung gefunden. Es wird von einer einzelnen Seite ausgegangen."
|
|
||||||
"No pagination controls found. Assuming single page.": "Keine Paginierungssteuerung gefunden. Es wird von einer einzelnen Seite ausgegangen."
|
|
||||||
"Assuming single page due to error during pagination check.": "Es wird von einer einzelnen Seite ausgegangen wegen eines Fehlers bei der Paginierungsprüfung."
|
|
||||||
"Navigating to next page...": "Navigiere zur nächsten Seite..."
|
|
||||||
"Last ad overview page explored (no enabled \"Naechste\" button found).": "Letzte Anzeigenübersichtsseite erkundet (kein aktivierter \"Nächste\"-Button gefunden)."
|
|
||||||
"No pagination controls found after scrolling/waiting. Assuming last page.": "Keine Paginierungssteuerung nach dem Scrollen/Warten gefunden. Es wird von der letzten Seite ausgegangen."
|
|
||||||
"No ad URLs were extracted.": "Es wurden keine Anzeigen-URLs extrahiert."
|
"No ad URLs were extracted.": "Es wurden keine Anzeigen-URLs extrahiert."
|
||||||
|
|
||||||
|
extract_page_refs:
|
||||||
"Could not find ad list container or items on page %s.": "Anzeigenlistencontainer oder Elemente auf Seite %s nicht gefunden."
|
"Could not find ad list container or items on page %s.": "Anzeigenlistencontainer oder Elemente auf Seite %s nicht gefunden."
|
||||||
"Error during pagination detection: %s": "Fehler bei der Paginierungserkennung: %s"
|
|
||||||
"Error during pagination navigation: %s": "Fehler bei der Paginierungsnavigation: %s"
|
|
||||||
"Error extracting refs on page %s: %s": "Fehler beim Extrahieren der Referenzen auf Seite %s: %s"
|
"Error extracting refs on page %s: %s": "Fehler beim Extrahieren der Referenzen auf Seite %s: %s"
|
||||||
"Extracting ads from page %s...": "Extrahiere Anzeigen von Seite %s..."
|
|
||||||
"Found %s ad items on page %s.": "%s Anzeigen-Elemente auf Seite %s gefunden."
|
"Found %s ad items on page %s.": "%s Anzeigen-Elemente auf Seite %s gefunden."
|
||||||
"Successfully extracted %s refs from page %s.": "%s Referenzen von Seite %s erfolgreich extrahiert."
|
"Successfully extracted %s refs from page %s.": "%s Referenzen von Seite %s erfolgreich extrahiert."
|
||||||
|
|
||||||
@@ -488,6 +481,18 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py:
|
|||||||
"Combobox missing aria-controls attribute": "Combobox fehlt aria-controls Attribut"
|
"Combobox missing aria-controls attribute": "Combobox fehlt aria-controls Attribut"
|
||||||
"No matching option found in combobox: '%s'": "Keine passende Option in Combobox gefunden: '%s'"
|
"No matching option found in combobox: '%s'": "Keine passende Option in Combobox gefunden: '%s'"
|
||||||
|
|
||||||
|
_navigate_paginated_ad_overview:
|
||||||
|
"Failed to open ad overview page at %s: timeout": "Fehler beim Öffnen der Anzeigenübersichtsseite unter %s: Zeitüberschreitung"
|
||||||
|
"Scroll timeout on page %s (non-critical, continuing)": "Zeitüberschreitung beim Scrollen auf Seite %s (nicht kritisch, wird fortgesetzt)"
|
||||||
|
"Page action timed out on page %s": "Seitenaktion hat auf Seite %s eine Zeitüberschreitung erreicht"
|
||||||
|
"Ad list container not found. Maybe no ads present?": "Anzeigenlistencontainer nicht gefunden. Vielleicht sind keine Anzeigen vorhanden?"
|
||||||
|
"Multiple ad pages detected.": "Mehrere Anzeigenseiten erkannt."
|
||||||
|
"No pagination controls found. Assuming single page.": "Keine Paginierungssteuerung gefunden. Es wird von einer einzelnen Seite ausgegangen."
|
||||||
|
"Processing page %s...": "Verarbeite Seite %s..."
|
||||||
|
"Navigating to page %s...": "Navigiere zu Seite %s..."
|
||||||
|
"Last page reached (no enabled 'Naechste' button found).": "Letzte Seite erreicht (kein aktivierter 'Naechste'-Button gefunden)."
|
||||||
|
"No pagination controls found. Assuming last page.": "Keine Paginierungssteuerung gefunden. Es wird von der letzten Seite ausgegangen."
|
||||||
|
|
||||||
close_browser_session:
|
close_browser_session:
|
||||||
"Closing Browser session...": "Schließe Browser-Sitzung..."
|
"Closing Browser session...": "Schließe Browser-Sitzung..."
|
||||||
|
|
||||||
|
|||||||
@@ -969,6 +969,111 @@ class WebScrapingMixin:
|
|||||||
)
|
)
|
||||||
await self.page.sleep(duration / 1_000)
|
await self.page.sleep(duration / 1_000)
|
||||||
|
|
||||||
|
async def _navigate_paginated_ad_overview(
|
||||||
|
self,
|
||||||
|
page_action:Callable[[int], Awaitable[bool]],
|
||||||
|
page_url:str = "https://www.kleinanzeigen.de/m-meine-anzeigen.html",
|
||||||
|
*,
|
||||||
|
max_pages:int = 10,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Navigate through paginated ad overview page, calling page_action on each page.
|
||||||
|
|
||||||
|
This helper guarantees to return a boolean result and never propagates TimeoutError.
|
||||||
|
All timeout conditions are handled internally and logged appropriately.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page_action: Async callable that receives current_page number and returns True if action succeeded/should stop
|
||||||
|
page_url: URL of the paginated overview page (default: kleinanzeigen ad management page)
|
||||||
|
max_pages: Maximum number of pages to navigate (safety limit)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if page_action returned True on any page, False otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
async def find_ad_callback(page_num: int) -> bool:
|
||||||
|
element = await self.web_find(By.XPATH, "//div[@id='my-ad']")
|
||||||
|
if element:
|
||||||
|
await element.click()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
success = await self._navigate_paginated_ad_overview(find_ad_callback)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self.web_open(page_url)
|
||||||
|
except TimeoutError:
|
||||||
|
LOG.warning("Failed to open ad overview page at %s: timeout", page_url)
|
||||||
|
return False
|
||||||
|
|
||||||
|
await self.web_sleep(2000, 3000)
|
||||||
|
|
||||||
|
# Check if ad list container exists
|
||||||
|
try:
|
||||||
|
_ = await self.web_find(By.ID, "my-manageitems-adlist")
|
||||||
|
except TimeoutError:
|
||||||
|
LOG.warning("Ad list container not found. Maybe no ads present?")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for pagination controls
|
||||||
|
multi_page = False
|
||||||
|
pagination_timeout = self._timeout("pagination_initial")
|
||||||
|
try:
|
||||||
|
pagination_section = await self.web_find(By.CSS_SELECTOR, ".Pagination", timeout = pagination_timeout)
|
||||||
|
next_buttons = await self.web_find_all(By.CSS_SELECTOR, 'button[aria-label="Nächste"]', parent = pagination_section)
|
||||||
|
if next_buttons:
|
||||||
|
enabled_next_buttons = [btn for btn in next_buttons if not btn.attrs.get("disabled")]
|
||||||
|
if enabled_next_buttons:
|
||||||
|
multi_page = True
|
||||||
|
LOG.info("Multiple ad pages detected.")
|
||||||
|
except TimeoutError:
|
||||||
|
LOG.info("No pagination controls found. Assuming single page.")
|
||||||
|
|
||||||
|
current_page = 1
|
||||||
|
while current_page <= max_pages:
|
||||||
|
LOG.info("Processing page %s...", current_page)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.web_scroll_page_down()
|
||||||
|
except TimeoutError:
|
||||||
|
LOG.debug("Scroll timeout on page %s (non-critical, continuing)", current_page)
|
||||||
|
|
||||||
|
await self.web_sleep(2000, 3000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if await page_action(current_page):
|
||||||
|
return True
|
||||||
|
except TimeoutError:
|
||||||
|
LOG.warning("Page action timed out on page %s", current_page)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not multi_page:
|
||||||
|
break
|
||||||
|
|
||||||
|
follow_up_timeout = self._timeout("pagination_follow_up")
|
||||||
|
try:
|
||||||
|
pagination_section = await self.web_find(By.CSS_SELECTOR, ".Pagination", timeout = follow_up_timeout)
|
||||||
|
next_button_element = None
|
||||||
|
possible_next_buttons = await self.web_find_all(By.CSS_SELECTOR, 'button[aria-label="Nächste"]', parent = pagination_section)
|
||||||
|
for btn in possible_next_buttons:
|
||||||
|
if not btn.attrs.get("disabled"):
|
||||||
|
next_button_element = btn
|
||||||
|
break
|
||||||
|
|
||||||
|
if next_button_element:
|
||||||
|
LOG.info("Navigating to page %s...", current_page + 1)
|
||||||
|
await next_button_element.click()
|
||||||
|
await self.web_sleep(3000, 4000)
|
||||||
|
current_page += 1
|
||||||
|
else:
|
||||||
|
LOG.info("Last page reached (no enabled 'Naechste' button found).")
|
||||||
|
break
|
||||||
|
except TimeoutError:
|
||||||
|
LOG.info("No pagination controls found. Assuming last page.")
|
||||||
|
break
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
async def web_request(self, url:str, method:str = "GET", valid_response_codes:int | Iterable[int] = 200, headers:dict[str, str] | None = None) -> Any:
|
async def web_request(self, url:str, method:str = "GET", valid_response_codes:int | Iterable[int] = 200, headers:dict[str, str] | None = None) -> Any:
|
||||||
method = method.upper()
|
method = method.upper()
|
||||||
LOG.debug(" -> HTTP %s [%s]...", method, url)
|
LOG.debug(" -> HTTP %s [%s]...", method, url)
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import json # isort: skip
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from kleinanzeigen_bot import KleinanzeigenBot, misc
|
from kleinanzeigen_bot import KleinanzeigenBot, misc
|
||||||
from kleinanzeigen_bot.model.ad_model import Ad
|
from kleinanzeigen_bot.model.ad_model import Ad
|
||||||
from kleinanzeigen_bot.utils import dicts
|
from kleinanzeigen_bot.utils import dicts
|
||||||
|
from kleinanzeigen_bot.utils.web_scraping_mixin import By, Element
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -34,13 +35,7 @@ def base_ad_config_with_id() -> dict[str, Any]:
|
|||||||
"republication_interval": 7,
|
"republication_interval": 7,
|
||||||
"created_on": "2024-12-07T10:00:00",
|
"created_on": "2024-12-07T10:00:00",
|
||||||
"updated_on": "2024-12-10T15:20:00",
|
"updated_on": "2024-12-10T15:20:00",
|
||||||
"contact": {
|
"contact": {"name": "Test User", "zipcode": "12345", "location": "Test City", "street": "", "phone": ""},
|
||||||
"name": "Test User",
|
|
||||||
"zipcode": "12345",
|
|
||||||
"location": "Test City",
|
|
||||||
"street": "",
|
|
||||||
"phone": ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -50,9 +45,7 @@ class TestExtendCommand:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_extend_command_no_ads(self, test_bot:KleinanzeigenBot) -> None:
|
async def test_run_extend_command_no_ads(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
"""Test running extend command with no ads."""
|
"""Test running extend command with no ads."""
|
||||||
with patch.object(test_bot, "load_config"), \
|
with patch.object(test_bot, "load_config"), patch.object(test_bot, "load_ads", return_value = []), patch("kleinanzeigen_bot.UpdateChecker"):
|
||||||
patch.object(test_bot, "load_ads", return_value = []), \
|
|
||||||
patch("kleinanzeigen_bot.UpdateChecker"):
|
|
||||||
await test_bot.run(["script.py", "extend"])
|
await test_bot.run(["script.py", "extend"])
|
||||||
assert test_bot.command == "extend"
|
assert test_bot.command == "extend"
|
||||||
assert test_bot.ads_selector == "all"
|
assert test_bot.ads_selector == "all"
|
||||||
@@ -60,11 +53,13 @@ class TestExtendCommand:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_extend_command_with_specific_ids(self, test_bot:KleinanzeigenBot) -> None:
|
async def test_run_extend_command_with_specific_ids(self, test_bot:KleinanzeigenBot) -> None:
|
||||||
"""Test running extend command with specific ad IDs."""
|
"""Test running extend command with specific ad IDs."""
|
||||||
with patch.object(test_bot, "load_config"), \
|
with (
|
||||||
patch.object(test_bot, "load_ads", return_value = []), \
|
patch.object(test_bot, "load_config"),
|
||||||
patch.object(test_bot, "create_browser_session", new_callable = AsyncMock), \
|
patch.object(test_bot, "load_ads", return_value = []),
|
||||||
patch.object(test_bot, "login", new_callable = AsyncMock), \
|
patch.object(test_bot, "create_browser_session", new_callable = AsyncMock),
|
||||||
patch("kleinanzeigen_bot.UpdateChecker"):
|
patch.object(test_bot, "login", new_callable = AsyncMock),
|
||||||
|
patch("kleinanzeigen_bot.UpdateChecker"),
|
||||||
|
):
|
||||||
await test_bot.run(["script.py", "extend", "--ads=12345,67890"])
|
await test_bot.run(["script.py", "extend", "--ads=12345,67890"])
|
||||||
assert test_bot.command == "extend"
|
assert test_bot.command == "extend"
|
||||||
assert test_bot.ads_selector == "12345,67890"
|
assert test_bot.ads_selector == "12345,67890"
|
||||||
@@ -74,19 +69,14 @@ class TestExtendAdsMethod:
|
|||||||
"""Tests for the extend_ads() method."""
|
"""Tests for the extend_ads() method."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ads_skips_unpublished_ad(
|
async def test_extend_ads_skips_unpublished_ad(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any]) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Test that extend_ads skips ads without an ID (unpublished)."""
|
"""Test that extend_ads skips ads without an ID (unpublished)."""
|
||||||
# Create ad without ID
|
# Create ad without ID
|
||||||
ad_config = base_ad_config_with_id.copy()
|
ad_config = base_ad_config_with_id.copy()
|
||||||
ad_config["id"] = None
|
ad_config["id"] = None
|
||||||
ad_cfg = Ad.model_validate(ad_config)
|
ad_cfg = Ad.model_validate(ad_config)
|
||||||
|
|
||||||
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, \
|
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, patch.object(test_bot, "web_sleep", new_callable = AsyncMock):
|
||||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock):
|
|
||||||
mock_request.return_value = {"content": '{"ads": []}'}
|
mock_request.return_value = {"content": '{"ads": []}'}
|
||||||
|
|
||||||
await test_bot.extend_ads([("test.yaml", ad_cfg, ad_config)])
|
await test_bot.extend_ads([("test.yaml", ad_cfg, ad_config)])
|
||||||
@@ -95,16 +85,11 @@ class TestExtendAdsMethod:
|
|||||||
mock_request.assert_called_once() # Only the API call to get published ads
|
mock_request.assert_called_once() # Only the API call to get published ads
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ads_skips_ad_not_in_published_list(
|
async def test_extend_ads_skips_ad_not_in_published_list(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any]) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Test that extend_ads skips ads not found in the published ads API response."""
|
"""Test that extend_ads skips ads not found in the published ads API response."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, \
|
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, patch.object(test_bot, "web_sleep", new_callable = AsyncMock):
|
||||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock):
|
|
||||||
# Return empty published ads list
|
# Return empty published ads list
|
||||||
mock_request.return_value = {"content": '{"ads": []}'}
|
mock_request.return_value = {"content": '{"ads": []}'}
|
||||||
|
|
||||||
@@ -114,11 +99,7 @@ class TestExtendAdsMethod:
|
|||||||
mock_request.assert_called_once()
|
mock_request.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ads_skips_inactive_ad(
|
async def test_extend_ads_skips_inactive_ad(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any]) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Test that extend_ads skips ads with state != 'active'."""
|
"""Test that extend_ads skips ads with state != 'active'."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -128,14 +109,16 @@ class TestExtendAdsMethod:
|
|||||||
"id": 12345,
|
"id": 12345,
|
||||||
"title": "Test Ad Title",
|
"title": "Test Ad Title",
|
||||||
"state": "paused", # Not active
|
"state": "paused", # Not active
|
||||||
"endDate": "05.02.2026"
|
"endDate": "05.02.2026",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, \
|
with (
|
||||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \
|
patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request,
|
||||||
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad:
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad,
|
||||||
|
):
|
||||||
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
||||||
|
|
||||||
await test_bot.extend_ads([("test.yaml", ad_cfg, base_ad_config_with_id)])
|
await test_bot.extend_ads([("test.yaml", ad_cfg, base_ad_config_with_id)])
|
||||||
@@ -144,11 +127,7 @@ class TestExtendAdsMethod:
|
|||||||
mock_extend_ad.assert_not_called()
|
mock_extend_ad.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ads_skips_ad_without_enddate(
|
async def test_extend_ads_skips_ad_without_enddate(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any]) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Test that extend_ads skips ads without endDate in API response."""
|
"""Test that extend_ads skips ads without endDate in API response."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -157,15 +136,17 @@ class TestExtendAdsMethod:
|
|||||||
{
|
{
|
||||||
"id": 12345,
|
"id": 12345,
|
||||||
"title": "Test Ad Title",
|
"title": "Test Ad Title",
|
||||||
"state": "active"
|
"state": "active",
|
||||||
# No endDate field
|
# No endDate field
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, \
|
with (
|
||||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \
|
patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request,
|
||||||
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad:
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad,
|
||||||
|
):
|
||||||
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
||||||
|
|
||||||
await test_bot.extend_ads([("test.yaml", ad_cfg, base_ad_config_with_id)])
|
await test_bot.extend_ads([("test.yaml", ad_cfg, base_ad_config_with_id)])
|
||||||
@@ -174,11 +155,7 @@ class TestExtendAdsMethod:
|
|||||||
mock_extend_ad.assert_not_called()
|
mock_extend_ad.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ads_skips_ad_outside_window(
|
async def test_extend_ads_skips_ad_outside_window(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any]) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Test that extend_ads skips ads expiring more than 8 days in the future."""
|
"""Test that extend_ads skips ads expiring more than 8 days in the future."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -186,20 +163,13 @@ class TestExtendAdsMethod:
|
|||||||
future_date = misc.now() + timedelta(days = 30)
|
future_date = misc.now() + timedelta(days = 30)
|
||||||
end_date_str = future_date.strftime("%d.%m.%Y")
|
end_date_str = future_date.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
published_ads_json = {
|
published_ads_json = {"ads": [{"id": 12345, "title": "Test Ad Title", "state": "active", "endDate": end_date_str}]}
|
||||||
"ads": [
|
|
||||||
{
|
|
||||||
"id": 12345,
|
|
||||||
"title": "Test Ad Title",
|
|
||||||
"state": "active",
|
|
||||||
"endDate": end_date_str
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, \
|
with (
|
||||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \
|
patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request,
|
||||||
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad:
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad,
|
||||||
|
):
|
||||||
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
||||||
|
|
||||||
await test_bot.extend_ads([("test.yaml", ad_cfg, base_ad_config_with_id)])
|
await test_bot.extend_ads([("test.yaml", ad_cfg, base_ad_config_with_id)])
|
||||||
@@ -208,11 +178,7 @@ class TestExtendAdsMethod:
|
|||||||
mock_extend_ad.assert_not_called()
|
mock_extend_ad.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ads_extends_ad_within_window(
|
async def test_extend_ads_extends_ad_within_window(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any]) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Test that extend_ads extends ads within the 8-day window."""
|
"""Test that extend_ads extends ads within the 8-day window."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -220,20 +186,13 @@ class TestExtendAdsMethod:
|
|||||||
future_date = misc.now() + timedelta(days = 5)
|
future_date = misc.now() + timedelta(days = 5)
|
||||||
end_date_str = future_date.strftime("%d.%m.%Y")
|
end_date_str = future_date.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
published_ads_json = {
|
published_ads_json = {"ads": [{"id": 12345, "title": "Test Ad Title", "state": "active", "endDate": end_date_str}]}
|
||||||
"ads": [
|
|
||||||
{
|
|
||||||
"id": 12345,
|
|
||||||
"title": "Test Ad Title",
|
|
||||||
"state": "active",
|
|
||||||
"endDate": end_date_str
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, \
|
with (
|
||||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \
|
patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request,
|
||||||
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad:
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad,
|
||||||
|
):
|
||||||
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
||||||
mock_extend_ad.return_value = True
|
mock_extend_ad.return_value = True
|
||||||
|
|
||||||
@@ -243,11 +202,7 @@ class TestExtendAdsMethod:
|
|||||||
mock_extend_ad.assert_called_once()
|
mock_extend_ad.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ads_no_eligible_ads(
|
async def test_extend_ads_no_eligible_ads(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any]) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Test extend_ads when no ads are eligible for extension."""
|
"""Test extend_ads when no ads are eligible for extension."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -255,20 +210,13 @@ class TestExtendAdsMethod:
|
|||||||
future_date = misc.now() + timedelta(days = 30)
|
future_date = misc.now() + timedelta(days = 30)
|
||||||
end_date_str = future_date.strftime("%d.%m.%Y")
|
end_date_str = future_date.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
published_ads_json = {
|
published_ads_json = {"ads": [{"id": 12345, "title": "Test Ad Title", "state": "active", "endDate": end_date_str}]}
|
||||||
"ads": [
|
|
||||||
{
|
|
||||||
"id": 12345,
|
|
||||||
"title": "Test Ad Title",
|
|
||||||
"state": "active",
|
|
||||||
"endDate": end_date_str
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, \
|
with (
|
||||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \
|
patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request,
|
||||||
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad:
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad,
|
||||||
|
):
|
||||||
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
||||||
|
|
||||||
await test_bot.extend_ads([("test.yaml", ad_cfg, base_ad_config_with_id)])
|
await test_bot.extend_ads([("test.yaml", ad_cfg, base_ad_config_with_id)])
|
||||||
@@ -277,11 +225,7 @@ class TestExtendAdsMethod:
|
|||||||
mock_extend_ad.assert_not_called()
|
mock_extend_ad.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ads_handles_multiple_ads(
|
async def test_extend_ads_handles_multiple_ads(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any]) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Test that extend_ads processes multiple ads correctly."""
|
"""Test that extend_ads processes multiple ads correctly."""
|
||||||
ad_cfg1 = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg1 = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -297,46 +241,36 @@ class TestExtendAdsMethod:
|
|||||||
|
|
||||||
published_ads_json = {
|
published_ads_json = {
|
||||||
"ads": [
|
"ads": [
|
||||||
{
|
{"id": 12345, "title": "Test Ad Title", "state": "active", "endDate": within_window.strftime("%d.%m.%Y")},
|
||||||
"id": 12345,
|
{"id": 67890, "title": "Second Test Ad", "state": "active", "endDate": outside_window.strftime("%d.%m.%Y")},
|
||||||
"title": "Test Ad Title",
|
|
||||||
"state": "active",
|
|
||||||
"endDate": within_window.strftime("%d.%m.%Y")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 67890,
|
|
||||||
"title": "Second Test Ad",
|
|
||||||
"state": "active",
|
|
||||||
"endDate": outside_window.strftime("%d.%m.%Y")
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, \
|
with (
|
||||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \
|
patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request,
|
||||||
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad:
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad,
|
||||||
|
):
|
||||||
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
||||||
mock_extend_ad.return_value = True
|
mock_extend_ad.return_value = True
|
||||||
|
|
||||||
await test_bot.extend_ads([
|
await test_bot.extend_ads([("test1.yaml", ad_cfg1, base_ad_config_with_id), ("test2.yaml", ad_cfg2, ad_config2)])
|
||||||
("test1.yaml", ad_cfg1, base_ad_config_with_id),
|
|
||||||
("test2.yaml", ad_cfg2, ad_config2)
|
|
||||||
])
|
|
||||||
|
|
||||||
# Verify extend_ad was called only once (for the ad within window)
|
# Verify extend_ad was called only once (for the ad within window)
|
||||||
assert mock_extend_ad.call_count == 1
|
assert mock_extend_ad.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
class TestExtendAdMethod:
|
class TestExtendAdMethod:
|
||||||
"""Tests for the extend_ad() method."""
|
"""Tests for the extend_ad() method.
|
||||||
|
|
||||||
|
Note: These tests mock `_navigate_paginated_ad_overview` rather than individual browser methods
|
||||||
|
(web_find, web_click, etc.) because the pagination helper involves complex multi-step browser
|
||||||
|
interactions that would require extensive, brittle mock choreography. Mocking at this level
|
||||||
|
keeps tests focused on extend_ad's own logic (dialog handling, YAML persistence, error paths).
|
||||||
|
"""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ad_success(
|
async def test_extend_ad_success(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any], tmp_path:Path) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any],
|
|
||||||
tmp_path:Path
|
|
||||||
) -> None:
|
|
||||||
"""Test successful ad extension."""
|
"""Test successful ad extension."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -344,27 +278,27 @@ class TestExtendAdMethod:
|
|||||||
ad_file = tmp_path / "test_ad.yaml"
|
ad_file = tmp_path / "test_ad.yaml"
|
||||||
dicts.save_dict(str(ad_file), base_ad_config_with_id)
|
dicts.save_dict(str(ad_file), base_ad_config_with_id)
|
||||||
|
|
||||||
with patch.object(test_bot, "web_open", new_callable = AsyncMock), \
|
with (
|
||||||
patch.object(test_bot, "web_click", new_callable = AsyncMock), \
|
patch.object(test_bot, "_navigate_paginated_ad_overview", new_callable = AsyncMock) as mock_paginate,
|
||||||
patch("kleinanzeigen_bot.misc.now") as mock_now:
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
||||||
|
patch("kleinanzeigen_bot.misc.now") as mock_now,
|
||||||
|
):
|
||||||
# Test mock datetime - timezone not relevant for timestamp formatting test
|
# Test mock datetime - timezone not relevant for timestamp formatting test
|
||||||
mock_now.return_value = datetime(2025, 1, 28, 14, 30, 0) # noqa: DTZ001
|
mock_now.return_value = datetime(2025, 1, 28, 14, 30, 0) # noqa: DTZ001
|
||||||
|
|
||||||
|
mock_paginate.return_value = True
|
||||||
|
|
||||||
result = await test_bot.extend_ad(str(ad_file), ad_cfg, base_ad_config_with_id)
|
result = await test_bot.extend_ad(str(ad_file), ad_cfg, base_ad_config_with_id)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
|
assert mock_paginate.call_count == 1
|
||||||
|
|
||||||
# Verify updated_on was updated in the YAML file
|
# Verify updated_on was updated in the YAML file
|
||||||
updated_config = dicts.load_dict(str(ad_file))
|
updated_config = dicts.load_dict(str(ad_file))
|
||||||
assert updated_config["updated_on"] == "2025-01-28T14:30:00"
|
assert updated_config["updated_on"] == "2025-01-28T14:30:00"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ad_button_not_found(
|
async def test_extend_ad_button_not_found(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any], tmp_path:Path) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any],
|
|
||||||
tmp_path:Path
|
|
||||||
) -> None:
|
|
||||||
"""Test extend_ad when the Verlängern button is not found."""
|
"""Test extend_ad when the Verlängern button is not found."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -372,22 +306,17 @@ class TestExtendAdMethod:
|
|||||||
ad_file = tmp_path / "test_ad.yaml"
|
ad_file = tmp_path / "test_ad.yaml"
|
||||||
dicts.save_dict(str(ad_file), base_ad_config_with_id)
|
dicts.save_dict(str(ad_file), base_ad_config_with_id)
|
||||||
|
|
||||||
with patch.object(test_bot, "web_open", new_callable = AsyncMock), \
|
with patch.object(test_bot, "_navigate_paginated_ad_overview", new_callable = AsyncMock) as mock_paginate:
|
||||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click:
|
# Simulate button not found by having pagination return False (not found on any page)
|
||||||
# Simulate button not found by raising TimeoutError
|
mock_paginate.return_value = False
|
||||||
mock_click.side_effect = TimeoutError("Button not found")
|
|
||||||
|
|
||||||
result = await test_bot.extend_ad(str(ad_file), ad_cfg, base_ad_config_with_id)
|
result = await test_bot.extend_ad(str(ad_file), ad_cfg, base_ad_config_with_id)
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
assert mock_paginate.call_count == 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ad_dialog_timeout(
|
async def test_extend_ad_dialog_timeout(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any], tmp_path:Path) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any],
|
|
||||||
tmp_path:Path
|
|
||||||
) -> None:
|
|
||||||
"""Test extend_ad when the confirmation dialog times out (no dialog appears)."""
|
"""Test extend_ad when the confirmation dialog times out (no dialog appears)."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -395,14 +324,18 @@ class TestExtendAdMethod:
|
|||||||
ad_file = tmp_path / "test_ad.yaml"
|
ad_file = tmp_path / "test_ad.yaml"
|
||||||
dicts.save_dict(str(ad_file), base_ad_config_with_id)
|
dicts.save_dict(str(ad_file), base_ad_config_with_id)
|
||||||
|
|
||||||
with patch.object(test_bot, "web_open", new_callable = AsyncMock), \
|
with (
|
||||||
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click, \
|
patch.object(test_bot, "_navigate_paginated_ad_overview", new_callable = AsyncMock) as mock_paginate,
|
||||||
patch("kleinanzeigen_bot.misc.now") as mock_now:
|
patch.object(test_bot, "web_click", new_callable = AsyncMock) as mock_click,
|
||||||
|
patch("kleinanzeigen_bot.misc.now") as mock_now,
|
||||||
|
):
|
||||||
# Test mock datetime - timezone not relevant for timestamp formatting test
|
# Test mock datetime - timezone not relevant for timestamp formatting test
|
||||||
mock_now.return_value = datetime(2025, 1, 28, 14, 30, 0) # noqa: DTZ001
|
mock_now.return_value = datetime(2025, 1, 28, 14, 30, 0) # noqa: DTZ001
|
||||||
|
|
||||||
# First click (Verlängern button) succeeds, second click (dialog close) times out
|
# Pagination succeeds (button found and clicked)
|
||||||
mock_click.side_effect = [None, TimeoutError("Dialog not found")]
|
mock_paginate.return_value = True
|
||||||
|
# Dialog close button times out
|
||||||
|
mock_click.side_effect = TimeoutError("Dialog not found")
|
||||||
|
|
||||||
result = await test_bot.extend_ad(str(ad_file), ad_cfg, base_ad_config_with_id)
|
result = await test_bot.extend_ad(str(ad_file), ad_cfg, base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -410,12 +343,7 @@ class TestExtendAdMethod:
|
|||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ad_exception_handling(
|
async def test_extend_ad_exception_handling(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any], tmp_path:Path) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any],
|
|
||||||
tmp_path:Path
|
|
||||||
) -> None:
|
|
||||||
"""Test extend_ad propagates unexpected exceptions."""
|
"""Test extend_ad propagates unexpected exceptions."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -423,20 +351,15 @@ class TestExtendAdMethod:
|
|||||||
ad_file = tmp_path / "test_ad.yaml"
|
ad_file = tmp_path / "test_ad.yaml"
|
||||||
dicts.save_dict(str(ad_file), base_ad_config_with_id)
|
dicts.save_dict(str(ad_file), base_ad_config_with_id)
|
||||||
|
|
||||||
with patch.object(test_bot, "web_open", new_callable = AsyncMock) as mock_open:
|
with patch.object(test_bot, "_navigate_paginated_ad_overview", new_callable = AsyncMock) as mock_paginate:
|
||||||
# Simulate unexpected exception
|
# Simulate unexpected exception during pagination
|
||||||
mock_open.side_effect = Exception("Unexpected error")
|
mock_paginate.side_effect = Exception("Unexpected error")
|
||||||
|
|
||||||
with pytest.raises(Exception, match = "Unexpected error"):
|
with pytest.raises(Exception, match = "Unexpected error"):
|
||||||
await test_bot.extend_ad(str(ad_file), ad_cfg, base_ad_config_with_id)
|
await test_bot.extend_ad(str(ad_file), ad_cfg, base_ad_config_with_id)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ad_updates_yaml_file(
|
async def test_extend_ad_updates_yaml_file(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any], tmp_path:Path) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any],
|
|
||||||
tmp_path:Path
|
|
||||||
) -> None:
|
|
||||||
"""Test that extend_ad correctly updates the YAML file with new timestamp."""
|
"""Test that extend_ad correctly updates the YAML file with new timestamp."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -445,12 +368,17 @@ class TestExtendAdMethod:
|
|||||||
original_updated_on = base_ad_config_with_id["updated_on"]
|
original_updated_on = base_ad_config_with_id["updated_on"]
|
||||||
dicts.save_dict(str(ad_file), base_ad_config_with_id)
|
dicts.save_dict(str(ad_file), base_ad_config_with_id)
|
||||||
|
|
||||||
with patch.object(test_bot, "web_open", new_callable = AsyncMock), \
|
with (
|
||||||
patch.object(test_bot, "web_click", new_callable = AsyncMock), \
|
patch.object(test_bot, "_navigate_paginated_ad_overview", new_callable = AsyncMock) as mock_paginate,
|
||||||
patch("kleinanzeigen_bot.misc.now") as mock_now:
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
||||||
|
patch("kleinanzeigen_bot.misc.now") as mock_now,
|
||||||
|
):
|
||||||
# Test mock datetime - timezone not relevant for timestamp formatting test
|
# Test mock datetime - timezone not relevant for timestamp formatting test
|
||||||
mock_now.return_value = datetime(2025, 1, 28, 14, 30, 0) # noqa: DTZ001
|
mock_now.return_value = datetime(2025, 1, 28, 14, 30, 0) # noqa: DTZ001
|
||||||
|
|
||||||
|
# Pagination succeeds (button found and clicked)
|
||||||
|
mock_paginate.return_value = True
|
||||||
|
|
||||||
await test_bot.extend_ad(str(ad_file), ad_cfg, base_ad_config_with_id)
|
await test_bot.extend_ad(str(ad_file), ad_cfg, base_ad_config_with_id)
|
||||||
|
|
||||||
# Load the updated file and verify the timestamp changed
|
# Load the updated file and verify the timestamp changed
|
||||||
@@ -458,16 +386,67 @@ class TestExtendAdMethod:
|
|||||||
assert updated_config["updated_on"] != original_updated_on
|
assert updated_config["updated_on"] != original_updated_on
|
||||||
assert updated_config["updated_on"] == "2025-01-28T14:30:00"
|
assert updated_config["updated_on"] == "2025-01-28T14:30:00"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extend_ad_with_web_mocks(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any], tmp_path:Path) -> None:
|
||||||
|
"""Test extend_ad with web-level mocks to exercise the find_and_click_extend_button callback."""
|
||||||
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
|
# Create temporary YAML file
|
||||||
|
ad_file = tmp_path / "test_ad.yaml"
|
||||||
|
dicts.save_dict(str(ad_file), base_ad_config_with_id)
|
||||||
|
|
||||||
|
extend_button_mock = AsyncMock()
|
||||||
|
extend_button_mock.click = AsyncMock()
|
||||||
|
|
||||||
|
pagination_section = MagicMock()
|
||||||
|
|
||||||
|
find_call_count = {"count": 0}
|
||||||
|
|
||||||
|
async def mock_web_find(selector_type:By, selector_value:str, **kwargs:Any) -> Element:
|
||||||
|
find_call_count["count"] += 1
|
||||||
|
# Ad list container (called by pagination helper)
|
||||||
|
if selector_type == By.ID and selector_value == "my-manageitems-adlist":
|
||||||
|
return MagicMock()
|
||||||
|
# Pagination section (called by pagination helper)
|
||||||
|
if selector_type == By.CSS_SELECTOR and selector_value == ".Pagination":
|
||||||
|
# Raise TimeoutError on first call (pagination detection) to indicate single page
|
||||||
|
if find_call_count["count"] == 2:
|
||||||
|
raise TimeoutError("No pagination")
|
||||||
|
return pagination_section
|
||||||
|
# Extend button (called by find_and_click_extend_button callback)
|
||||||
|
if selector_type == By.XPATH and "Verlängern" in selector_value:
|
||||||
|
return extend_button_mock
|
||||||
|
raise TimeoutError(f"Unexpected find: {selector_type} {selector_value}")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(test_bot, "web_open", new_callable = AsyncMock),
|
||||||
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(test_bot, "web_find", new_callable = AsyncMock, side_effect = mock_web_find),
|
||||||
|
patch.object(test_bot, "web_find_all", new_callable = AsyncMock, return_value = []),
|
||||||
|
patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock),
|
||||||
|
patch.object(test_bot, "web_click", new_callable = AsyncMock),
|
||||||
|
patch.object(test_bot, "_timeout", return_value = 10),
|
||||||
|
patch("kleinanzeigen_bot.misc.now") as mock_now,
|
||||||
|
):
|
||||||
|
# Test mock datetime - timezone not relevant for timestamp formatting test
|
||||||
|
mock_now.return_value = datetime(2025, 1, 28, 15, 0, 0) # noqa: DTZ001
|
||||||
|
|
||||||
|
result = await test_bot.extend_ad(str(ad_file), ad_cfg, base_ad_config_with_id)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
# Verify the extend button was found and clicked
|
||||||
|
extend_button_mock.click.assert_awaited_once()
|
||||||
|
|
||||||
|
# Verify updated_on was updated
|
||||||
|
updated_config = dicts.load_dict(str(ad_file))
|
||||||
|
assert updated_config["updated_on"] == "2025-01-28T15:00:00"
|
||||||
|
|
||||||
|
|
||||||
class TestExtendEdgeCases:
|
class TestExtendEdgeCases:
|
||||||
"""Tests for edge cases and boundary conditions."""
|
"""Tests for edge cases and boundary conditions."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ads_exactly_8_days(
|
async def test_extend_ads_exactly_8_days(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any]) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Test that ads expiring exactly in 8 days are eligible for extension."""
|
"""Test that ads expiring exactly in 8 days are eligible for extension."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -475,20 +454,13 @@ class TestExtendEdgeCases:
|
|||||||
future_date = misc.now() + timedelta(days = 8)
|
future_date = misc.now() + timedelta(days = 8)
|
||||||
end_date_str = future_date.strftime("%d.%m.%Y")
|
end_date_str = future_date.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
published_ads_json = {
|
published_ads_json = {"ads": [{"id": 12345, "title": "Test Ad Title", "state": "active", "endDate": end_date_str}]}
|
||||||
"ads": [
|
|
||||||
{
|
|
||||||
"id": 12345,
|
|
||||||
"title": "Test Ad Title",
|
|
||||||
"state": "active",
|
|
||||||
"endDate": end_date_str
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, \
|
with (
|
||||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \
|
patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request,
|
||||||
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad:
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad,
|
||||||
|
):
|
||||||
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
||||||
mock_extend_ad.return_value = True
|
mock_extend_ad.return_value = True
|
||||||
|
|
||||||
@@ -498,11 +470,7 @@ class TestExtendEdgeCases:
|
|||||||
mock_extend_ad.assert_called_once()
|
mock_extend_ad.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ads_exactly_9_days(
|
async def test_extend_ads_exactly_9_days(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any]) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Test that ads expiring in exactly 9 days are not eligible for extension."""
|
"""Test that ads expiring in exactly 9 days are not eligible for extension."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -510,20 +478,13 @@ class TestExtendEdgeCases:
|
|||||||
future_date = misc.now() + timedelta(days = 9)
|
future_date = misc.now() + timedelta(days = 9)
|
||||||
end_date_str = future_date.strftime("%d.%m.%Y")
|
end_date_str = future_date.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
published_ads_json = {
|
published_ads_json = {"ads": [{"id": 12345, "title": "Test Ad Title", "state": "active", "endDate": end_date_str}]}
|
||||||
"ads": [
|
|
||||||
{
|
|
||||||
"id": 12345,
|
|
||||||
"title": "Test Ad Title",
|
|
||||||
"state": "active",
|
|
||||||
"endDate": end_date_str
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, \
|
with (
|
||||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \
|
patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request,
|
||||||
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad:
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad,
|
||||||
|
):
|
||||||
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
mock_request.return_value = {"content": json.dumps(published_ads_json)}
|
||||||
|
|
||||||
await test_bot.extend_ads([("test.yaml", ad_cfg, base_ad_config_with_id)])
|
await test_bot.extend_ads([("test.yaml", ad_cfg, base_ad_config_with_id)])
|
||||||
@@ -532,11 +493,7 @@ class TestExtendEdgeCases:
|
|||||||
mock_extend_ad.assert_not_called()
|
mock_extend_ad.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_extend_ads_date_parsing_german_format(
|
async def test_extend_ads_date_parsing_german_format(self, test_bot:KleinanzeigenBot, base_ad_config_with_id:dict[str, Any]) -> None:
|
||||||
self,
|
|
||||||
test_bot:KleinanzeigenBot,
|
|
||||||
base_ad_config_with_id:dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Test that extend_ads correctly parses German date format (DD.MM.YYYY)."""
|
"""Test that extend_ads correctly parses German date format (DD.MM.YYYY)."""
|
||||||
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
ad_cfg = Ad.model_validate(base_ad_config_with_id)
|
||||||
|
|
||||||
@@ -547,15 +504,17 @@ class TestExtendEdgeCases:
|
|||||||
"id": 12345,
|
"id": 12345,
|
||||||
"title": "Test Ad Title",
|
"title": "Test Ad Title",
|
||||||
"state": "active",
|
"state": "active",
|
||||||
"endDate": "05.02.2026" # German format: DD.MM.YYYY
|
"endDate": "05.02.2026", # German format: DD.MM.YYYY
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request, \
|
with (
|
||||||
patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \
|
patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request,
|
||||||
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad, \
|
patch.object(test_bot, "web_sleep", new_callable = AsyncMock),
|
||||||
patch("kleinanzeigen_bot.misc.now") as mock_now:
|
patch.object(test_bot, "extend_ad", new_callable = AsyncMock) as mock_extend_ad,
|
||||||
|
patch("kleinanzeigen_bot.misc.now") as mock_now,
|
||||||
|
):
|
||||||
# Mock now() to return a date where 05.02.2026 would be within 8 days
|
# Mock now() to return a date where 05.02.2026 would be within 8 days
|
||||||
# Test mock datetime - timezone not relevant for date comparison test
|
# Test mock datetime - timezone not relevant for date comparison test
|
||||||
mock_now.return_value = datetime(2026, 1, 28) # noqa: DTZ001
|
mock_now.return_value = datetime(2026, 1, 28) # noqa: DTZ001
|
||||||
|
|||||||
@@ -662,6 +662,80 @@ class TestAdExtractorNavigation:
|
|||||||
assert refs == ["/s-anzeige/page-one/111", "/s-anzeige/page-two/222"]
|
assert refs == ["/s-anzeige/page-one/111", "/s-anzeige/page-two/222"]
|
||||||
next_button_enabled.click.assert_awaited() # triggered once during navigation
|
next_button_enabled.click.assert_awaited() # triggered once during navigation
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extract_own_ads_urls_timeout_in_callback(self, test_extractor:AdExtractor) -> None:
|
||||||
|
"""Test that TimeoutError in extract_page_refs callback stops pagination."""
|
||||||
|
with (
|
||||||
|
patch.object(test_extractor, "web_open", new_callable = AsyncMock),
|
||||||
|
patch.object(test_extractor, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(test_extractor, "web_find", new_callable = AsyncMock) as mock_web_find,
|
||||||
|
patch.object(test_extractor, "web_find_all", new_callable = AsyncMock, return_value = []),
|
||||||
|
patch.object(test_extractor, "web_scroll_page_down", new_callable = AsyncMock),
|
||||||
|
patch.object(test_extractor, "web_execute", new_callable = AsyncMock),
|
||||||
|
):
|
||||||
|
# Setup: ad list container exists, but web_find_all for cardbox raises TimeoutError
|
||||||
|
ad_list_container_mock = MagicMock()
|
||||||
|
|
||||||
|
call_count = {"count": 0}
|
||||||
|
|
||||||
|
def mock_find_side_effect(*args:Any, **kwargs:Any) -> Element:
|
||||||
|
call_count["count"] += 1
|
||||||
|
if call_count["count"] == 1:
|
||||||
|
# First call: ad list container (before pagination loop)
|
||||||
|
return ad_list_container_mock
|
||||||
|
# Second call: ad list container (inside callback)
|
||||||
|
return ad_list_container_mock
|
||||||
|
|
||||||
|
mock_web_find.side_effect = mock_find_side_effect
|
||||||
|
|
||||||
|
# Make web_find_all for cardbox raise TimeoutError (simulating missing ad items)
|
||||||
|
async def mock_find_all_side_effect(*args:Any, **kwargs:Any) -> list[Element]:
|
||||||
|
raise TimeoutError("Ad items not found")
|
||||||
|
|
||||||
|
with patch.object(test_extractor, "web_find_all", new_callable = AsyncMock, side_effect = mock_find_all_side_effect):
|
||||||
|
refs = await test_extractor.extract_own_ads_urls()
|
||||||
|
|
||||||
|
# Pagination should stop (TimeoutError in callback returns True)
|
||||||
|
assert refs == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extract_own_ads_urls_generic_exception_in_callback(self, test_extractor:AdExtractor) -> None:
|
||||||
|
"""Test that generic Exception in extract_page_refs callback continues pagination."""
|
||||||
|
with (
|
||||||
|
patch.object(test_extractor, "web_open", new_callable = AsyncMock),
|
||||||
|
patch.object(test_extractor, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(test_extractor, "web_find", new_callable = AsyncMock) as mock_web_find,
|
||||||
|
patch.object(test_extractor, "web_scroll_page_down", new_callable = AsyncMock),
|
||||||
|
):
|
||||||
|
# Setup: ad list container exists, but web_find_all raises generic Exception
|
||||||
|
ad_list_container_mock = MagicMock()
|
||||||
|
|
||||||
|
call_count = {"count": 0}
|
||||||
|
|
||||||
|
def mock_find_side_effect(*args:Any, **kwargs:Any) -> Element:
|
||||||
|
call_count["count"] += 1
|
||||||
|
if call_count["count"] == 1:
|
||||||
|
# First call: ad list container (before pagination loop)
|
||||||
|
return ad_list_container_mock
|
||||||
|
# Second call: pagination check - raise TimeoutError to indicate no pagination
|
||||||
|
if call_count["count"] == 2:
|
||||||
|
raise TimeoutError("No pagination")
|
||||||
|
# Third call: ad list container (inside callback)
|
||||||
|
return ad_list_container_mock
|
||||||
|
|
||||||
|
mock_web_find.side_effect = mock_find_side_effect
|
||||||
|
|
||||||
|
# Make web_find_all raise a generic exception
|
||||||
|
async def mock_find_all_side_effect(*args:Any, **kwargs:Any) -> list[Element]:
|
||||||
|
raise AttributeError("Unexpected error")
|
||||||
|
|
||||||
|
with patch.object(test_extractor, "web_find_all", new_callable = AsyncMock, side_effect = mock_find_all_side_effect):
|
||||||
|
refs = await test_extractor.extract_own_ads_urls()
|
||||||
|
|
||||||
|
# Pagination should continue despite exception (callback returns False)
|
||||||
|
# Since it's a single page (no pagination), refs should be empty
|
||||||
|
assert refs == []
|
||||||
|
|
||||||
|
|
||||||
class TestAdExtractorContent:
|
class TestAdExtractorContent:
|
||||||
"""Tests for content extraction functionality."""
|
"""Tests for content extraction functionality."""
|
||||||
|
|||||||
181
tests/unit/test_web_scraping_pagination.py
Normal file
181
tests/unit/test_web_scraping_pagination.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||||
|
"""Tests for the _navigate_paginated_ad_overview helper method."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from kleinanzeigen_bot.utils.web_scraping_mixin import By, Element, WebScrapingMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TestNavigatePaginatedAdOverview:
|
||||||
|
"""Tests for _navigate_paginated_ad_overview method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_single_page_action_succeeds(self) -> None:
|
||||||
|
"""Test pagination on single page where action succeeds."""
|
||||||
|
mixin = WebScrapingMixin()
|
||||||
|
|
||||||
|
# Mock callback that succeeds
|
||||||
|
callback = AsyncMock(return_value = True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(mixin, "web_open", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "web_find", new_callable = AsyncMock) as mock_find,
|
||||||
|
patch.object(mixin, "web_find_all", new_callable = AsyncMock, return_value = []),
|
||||||
|
patch.object(mixin, "web_scroll_page_down", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "_timeout", return_value = 10),
|
||||||
|
):
|
||||||
|
# Ad list container exists
|
||||||
|
mock_find.return_value = MagicMock()
|
||||||
|
|
||||||
|
result = await mixin._navigate_paginated_ad_overview(callback)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
callback.assert_awaited_once_with(1)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_single_page_action_returns_false(self) -> None:
|
||||||
|
"""Test pagination on single page where action returns False."""
|
||||||
|
mixin = WebScrapingMixin()
|
||||||
|
|
||||||
|
# Mock callback that returns False (doesn't find what it's looking for)
|
||||||
|
callback = AsyncMock(return_value = False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(mixin, "web_open", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "web_find", new_callable = AsyncMock) as mock_find,
|
||||||
|
patch.object(mixin, "web_find_all", new_callable = AsyncMock, return_value = []),
|
||||||
|
patch.object(mixin, "web_scroll_page_down", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "_timeout", return_value = 10),
|
||||||
|
):
|
||||||
|
# Ad list container exists
|
||||||
|
mock_find.return_value = MagicMock()
|
||||||
|
|
||||||
|
result = await mixin._navigate_paginated_ad_overview(callback)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
callback.assert_awaited_once_with(1)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multi_page_action_succeeds_on_page_2(self) -> None:
|
||||||
|
"""Test pagination across multiple pages where action succeeds on page 2."""
|
||||||
|
mixin = WebScrapingMixin()
|
||||||
|
|
||||||
|
# Mock callback that returns False on page 1, True on page 2
|
||||||
|
callback_results = [False, True]
|
||||||
|
callback = AsyncMock(side_effect = callback_results)
|
||||||
|
|
||||||
|
pagination_section = MagicMock()
|
||||||
|
next_button_enabled = MagicMock()
|
||||||
|
next_button_enabled.attrs = {} # No "disabled" attribute = enabled
|
||||||
|
next_button_enabled.click = AsyncMock()
|
||||||
|
|
||||||
|
find_call_count = {"count": 0}
|
||||||
|
|
||||||
|
async def mock_find_side_effect(selector_type:By, selector_value:str, **kwargs:Any) -> Element:
|
||||||
|
find_call_count["count"] += 1
|
||||||
|
if selector_type == By.ID and selector_value == "my-manageitems-adlist":
|
||||||
|
return MagicMock() # Ad list container
|
||||||
|
if selector_type == By.CSS_SELECTOR and selector_value == ".Pagination":
|
||||||
|
return pagination_section
|
||||||
|
raise TimeoutError("Unexpected find")
|
||||||
|
|
||||||
|
find_all_call_count = {"count": 0}
|
||||||
|
|
||||||
|
async def mock_find_all_side_effect(selector_type:By, selector_value:str, **kwargs:Any) -> list[Element]:
|
||||||
|
find_all_call_count["count"] += 1
|
||||||
|
if selector_type == By.CSS_SELECTOR and 'aria-label="Nächste"' in selector_value:
|
||||||
|
# Return enabled next button on both calls (initial detection and navigation)
|
||||||
|
return [next_button_enabled]
|
||||||
|
return []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(mixin, "web_open", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "web_find", new_callable = AsyncMock, side_effect = mock_find_side_effect),
|
||||||
|
patch.object(mixin, "web_find_all", new_callable = AsyncMock, side_effect = mock_find_all_side_effect),
|
||||||
|
patch.object(mixin, "web_scroll_page_down", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "_timeout", return_value = 10),
|
||||||
|
):
|
||||||
|
result = await mixin._navigate_paginated_ad_overview(callback)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert callback.await_count == 2
|
||||||
|
next_button_enabled.click.assert_awaited_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_web_open_raises_timeout(self) -> None:
|
||||||
|
"""Test that TimeoutError on web_open is caught and returns False."""
|
||||||
|
mixin = WebScrapingMixin()
|
||||||
|
|
||||||
|
callback = AsyncMock()
|
||||||
|
|
||||||
|
with patch.object(mixin, "web_open", new_callable = AsyncMock, side_effect = TimeoutError("Page load timeout")):
|
||||||
|
result = await mixin._navigate_paginated_ad_overview(callback)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
callback.assert_not_awaited() # Callback should not be called
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ad_list_container_not_found(self) -> None:
|
||||||
|
"""Test that missing ad list container returns False."""
|
||||||
|
mixin = WebScrapingMixin()
|
||||||
|
|
||||||
|
callback = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(mixin, "web_open", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "web_find", new_callable = AsyncMock, side_effect = TimeoutError("Container not found")),
|
||||||
|
):
|
||||||
|
result = await mixin._navigate_paginated_ad_overview(callback)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
callback.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_web_scroll_timeout_continues(self) -> None:
|
||||||
|
"""Test that TimeoutError on web_scroll_page_down is non-fatal and pagination continues."""
|
||||||
|
mixin = WebScrapingMixin()
|
||||||
|
|
||||||
|
callback = AsyncMock(return_value = True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(mixin, "web_open", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
|
||||||
|
patch.object(mixin, "web_find_all", new_callable = AsyncMock, return_value = []),
|
||||||
|
patch.object(mixin, "web_scroll_page_down", new_callable = AsyncMock, side_effect = TimeoutError("Scroll timeout")),
|
||||||
|
patch.object(mixin, "_timeout", return_value = 10),
|
||||||
|
):
|
||||||
|
result = await mixin._navigate_paginated_ad_overview(callback)
|
||||||
|
|
||||||
|
# Should continue and call callback despite scroll timeout
|
||||||
|
assert result is True
|
||||||
|
callback.assert_awaited_once_with(1)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_page_action_raises_timeout(self) -> None:
|
||||||
|
"""Test that TimeoutError from page_action is caught and returns False."""
|
||||||
|
mixin = WebScrapingMixin()
|
||||||
|
|
||||||
|
callback = AsyncMock(side_effect = TimeoutError("Action timeout"))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(mixin, "web_open", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "web_sleep", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "web_find", new_callable = AsyncMock, return_value = MagicMock()),
|
||||||
|
patch.object(mixin, "web_find_all", new_callable = AsyncMock, return_value = []),
|
||||||
|
patch.object(mixin, "web_scroll_page_down", new_callable = AsyncMock),
|
||||||
|
patch.object(mixin, "_timeout", return_value = 10),
|
||||||
|
):
|
||||||
|
result = await mixin._navigate_paginated_ad_overview(callback)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
callback.assert_awaited_once_with(1)
|
||||||
Reference in New Issue
Block a user