test: Enhance test coverage for KleinanzeigenBot initialization and core functionality (#408)

This commit is contained in:
Jens Bergmann
2025-02-09 03:33:01 +01:00
committed by GitHub
parent dd5f2ba5e4
commit affde0debf
5 changed files with 1180 additions and 123 deletions

View File

@@ -3,11 +3,12 @@ SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
SPDX-License-Identifier: AGPL-3.0-or-later
SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
"""
import json
import os
import json, os
from typing import Any, TypedDict
from unittest.mock import MagicMock, AsyncMock, patch, call
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from kleinanzeigen_bot.extract import AdExtractor
from kleinanzeigen_bot.web_scraping_mixin import Browser, By, Element
@@ -37,33 +38,11 @@ class _TestCaseDict(TypedDict):
class TestAdExtractorBasics:
"""Basic synchronous tests for AdExtractor."""
@pytest.fixture
def extractor(self) -> AdExtractor:
browser_mock = MagicMock(spec=Browser)
config_mock = {
"ad_defaults": {
"description": {
"prefix": "Test Prefix",
"suffix": "Test Suffix"
}
}
}
return AdExtractor(browser_mock, config_mock)
def test_constructor(self) -> None:
def test_constructor(self, browser_mock: MagicMock, sample_config: dict[str, Any]) -> None:
"""Test the constructor of AdExtractor"""
browser_mock = MagicMock(spec=Browser)
config = {
"ad_defaults": {
"description": {
"prefix": "Test Prefix",
"suffix": "Test Suffix"
}
}
}
extractor = AdExtractor(browser_mock, config)
extractor = AdExtractor(browser_mock, sample_config)
assert extractor.browser == browser_mock
assert extractor.config == config
assert extractor.config == sample_config
@pytest.mark.parametrize(
"url,expected_id",
@@ -74,27 +53,14 @@ class TestAdExtractorBasics:
("https://www.kleinanzeigen.de/invalid-url", -1),
],
)
def test_extract_ad_id_from_ad_url(self, extractor: AdExtractor, url: str, expected_id: int) -> None:
def test_extract_ad_id_from_ad_url(self, test_extractor: AdExtractor, url: str, expected_id: int) -> None:
"""Test extraction of ad ID from different URL formats."""
assert extractor.extract_ad_id_from_ad_url(url) == expected_id
assert test_extractor.extract_ad_id_from_ad_url(url) == expected_id
class TestAdExtractorPricing:
"""Tests for pricing related functionality."""
@pytest.fixture
def extractor(self) -> AdExtractor:
browser_mock = MagicMock(spec=Browser)
config_mock = {
"ad_defaults": {
"description": {
"prefix": "Test Prefix",
"suffix": "Test Suffix"
}
}
}
return AdExtractor(browser_mock, config_mock)
@pytest.mark.parametrize(
"price_text,expected_price,expected_type",
[
@@ -108,20 +74,20 @@ class TestAdExtractorPricing:
@pytest.mark.asyncio
# pylint: disable=protected-access
async def test_extract_pricing_info(
self, extractor: AdExtractor, price_text: str, expected_price: int | None, expected_type: str
self, test_extractor: AdExtractor, price_text: str, expected_price: int | None, expected_type: str
) -> None:
"""Test price extraction with different formats"""
with patch.object(extractor, 'web_text', new_callable=AsyncMock, return_value=price_text):
price, price_type = await extractor._extract_pricing_info_from_ad_page()
with patch.object(test_extractor, 'web_text', new_callable=AsyncMock, return_value=price_text):
price, price_type = await test_extractor._extract_pricing_info_from_ad_page()
assert price == expected_price
assert price_type == expected_type
@pytest.mark.asyncio
# pylint: disable=protected-access
async def test_extract_pricing_info_timeout(self, extractor: AdExtractor) -> None:
async def test_extract_pricing_info_timeout(self, test_extractor: AdExtractor) -> None:
"""Test price extraction when element is not found"""
with patch.object(extractor, 'web_text', new_callable=AsyncMock, side_effect=TimeoutError):
price, price_type = await extractor._extract_pricing_info_from_ad_page()
with patch.object(test_extractor, 'web_text', new_callable=AsyncMock, side_effect=TimeoutError):
price, price_type = await test_extractor._extract_pricing_info_from_ad_page()
assert price is None
assert price_type == "NOT_APPLICABLE"
@@ -129,19 +95,6 @@ class TestAdExtractorPricing:
class TestAdExtractorShipping:
"""Tests for shipping related functionality."""
@pytest.fixture
def extractor(self) -> AdExtractor:
browser_mock = MagicMock(spec=Browser)
config_mock = {
"ad_defaults": {
"description": {
"prefix": "Test Prefix",
"suffix": "Test Suffix"
}
}
}
return AdExtractor(browser_mock, config_mock)
@pytest.mark.parametrize(
"shipping_text,expected_type,expected_cost",
[
@@ -153,12 +106,12 @@ class TestAdExtractorShipping:
@pytest.mark.asyncio
# pylint: disable=protected-access
async def test_extract_shipping_info(
self, extractor: AdExtractor, shipping_text: str, expected_type: str, expected_cost: float | None
self, test_extractor: AdExtractor, shipping_text: str, expected_type: str, expected_cost: float | None
) -> None:
"""Test shipping info extraction with different text formats."""
with patch.object(extractor, 'page', MagicMock()), \
patch.object(extractor, 'web_text', new_callable=AsyncMock, return_value=shipping_text), \
patch.object(extractor, 'web_request', new_callable=AsyncMock) as mock_web_request:
with patch.object(test_extractor, 'page', MagicMock()), \
patch.object(test_extractor, 'web_text', new_callable=AsyncMock, return_value=shipping_text), \
patch.object(test_extractor, 'web_request', new_callable=AsyncMock) as mock_web_request:
if expected_cost:
shipping_response: dict[str, Any] = {
@@ -172,7 +125,7 @@ class TestAdExtractorShipping:
}
mock_web_request.return_value = {"content": json.dumps(shipping_response)}
shipping_type, costs, options = await extractor._extract_shipping_info_from_ad_page()
shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page()
assert shipping_type == expected_type
assert costs == expected_cost
@@ -183,7 +136,7 @@ class TestAdExtractorShipping:
@pytest.mark.asyncio
# pylint: disable=protected-access
async def test_extract_shipping_info_with_options(self, extractor: AdExtractor) -> None:
async def test_extract_shipping_info_with_options(self, test_extractor: AdExtractor) -> None:
"""Test shipping info extraction with shipping options."""
shipping_response = {
"content": json.dumps({
@@ -197,11 +150,11 @@ class TestAdExtractorShipping:
})
}
with patch.object(extractor, 'page', MagicMock()), \
patch.object(extractor, 'web_text', new_callable=AsyncMock, return_value="+ Versand ab 5,49 €"), \
patch.object(extractor, 'web_request', new_callable=AsyncMock, return_value=shipping_response):
with patch.object(test_extractor, 'page', MagicMock()), \
patch.object(test_extractor, 'web_text', new_callable=AsyncMock, return_value="+ Versand ab 5,49 €"), \
patch.object(test_extractor, 'web_request', new_callable=AsyncMock, return_value=shipping_response):
shipping_type, costs, options = await extractor._extract_shipping_info_from_ad_page()
shipping_type, costs, options = await test_extractor._extract_shipping_info_from_ad_page()
assert shipping_type == "SHIPPING"
assert costs == 5.49
@@ -211,35 +164,22 @@ class TestAdExtractorShipping:
class TestAdExtractorNavigation:
"""Tests for navigation related functionality."""
@pytest.fixture
def extractor(self) -> AdExtractor:
browser_mock = MagicMock(spec=Browser)
config_mock = {
"ad_defaults": {
"description": {
"prefix": "Test Prefix",
"suffix": "Test Suffix"
}
}
}
return AdExtractor(browser_mock, config_mock)
@pytest.mark.asyncio
async def test_navigate_to_ad_page_with_url(self, extractor: AdExtractor) -> None:
async def test_navigate_to_ad_page_with_url(self, test_extractor: AdExtractor) -> None:
"""Test navigation to ad page using a URL."""
page_mock = AsyncMock()
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
with patch.object(extractor, 'page', page_mock), \
patch.object(extractor, 'web_open', new_callable=AsyncMock) as mock_web_open, \
patch.object(extractor, 'web_find', new_callable=AsyncMock, side_effect=TimeoutError):
with patch.object(test_extractor, 'page', page_mock), \
patch.object(test_extractor, 'web_open', new_callable=AsyncMock) as mock_web_open, \
patch.object(test_extractor, 'web_find', new_callable=AsyncMock, side_effect=TimeoutError):
result = await extractor.naviagte_to_ad_page("https://www.kleinanzeigen.de/s-anzeige/test/12345")
result = await test_extractor.naviagte_to_ad_page("https://www.kleinanzeigen.de/s-anzeige/test/12345")
assert result is True
mock_web_open.assert_called_with("https://www.kleinanzeigen.de/s-anzeige/test/12345")
@pytest.mark.asyncio
async def test_navigate_to_ad_page_with_id(self, extractor: AdExtractor) -> None:
async def test_navigate_to_ad_page_with_id(self, test_extractor: AdExtractor) -> None:
"""Test navigation to ad page using an ID."""
page_mock = AsyncMock()
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
@@ -266,20 +206,20 @@ class TestAdExtractorNavigation:
return popup_close_mock
return None
with patch.object(extractor, 'page', page_mock), \
patch.object(extractor, 'web_open', new_callable=AsyncMock) as mock_web_open, \
patch.object(extractor, 'web_input', new_callable=AsyncMock), \
patch.object(extractor, 'web_check', new_callable=AsyncMock, return_value=True), \
patch.object(extractor, 'web_find', new_callable=AsyncMock, side_effect=find_mock):
with patch.object(test_extractor, 'page', page_mock), \
patch.object(test_extractor, 'web_open', new_callable=AsyncMock) as mock_web_open, \
patch.object(test_extractor, 'web_input', new_callable=AsyncMock), \
patch.object(test_extractor, 'web_check', new_callable=AsyncMock, return_value=True), \
patch.object(test_extractor, 'web_find', new_callable=AsyncMock, side_effect=find_mock):
result = await extractor.naviagte_to_ad_page(12345)
result = await test_extractor.naviagte_to_ad_page(12345)
assert result is True
mock_web_open.assert_called_with('https://www.kleinanzeigen.de/')
submit_button_mock.click.assert_awaited_once()
popup_close_mock.click.assert_awaited_once()
@pytest.mark.asyncio
async def test_navigate_to_ad_page_with_popup(self, extractor: AdExtractor) -> None:
async def test_navigate_to_ad_page_with_popup(self, test_extractor: AdExtractor) -> None:
"""Test navigation to ad page with popup handling."""
page_mock = AsyncMock()
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
@@ -289,18 +229,18 @@ class TestAdExtractorNavigation:
input_mock.send_keys = AsyncMock()
input_mock.apply = AsyncMock(return_value=True)
with patch.object(extractor, 'page', page_mock), \
patch.object(extractor, 'web_open', new_callable=AsyncMock), \
patch.object(extractor, 'web_find', new_callable=AsyncMock, return_value=input_mock), \
patch.object(extractor, 'web_click', new_callable=AsyncMock) as mock_web_click, \
patch.object(extractor, 'web_check', new_callable=AsyncMock, return_value=True):
with patch.object(test_extractor, 'page', page_mock), \
patch.object(test_extractor, 'web_open', new_callable=AsyncMock), \
patch.object(test_extractor, 'web_find', new_callable=AsyncMock, return_value=input_mock), \
patch.object(test_extractor, 'web_click', new_callable=AsyncMock) as mock_web_click, \
patch.object(test_extractor, 'web_check', new_callable=AsyncMock, return_value=True):
result = await extractor.naviagte_to_ad_page(12345)
result = await test_extractor.naviagte_to_ad_page(12345)
assert result is True
mock_web_click.assert_called_with(By.CLASS_NAME, 'mfp-close')
@pytest.mark.asyncio
async def test_navigate_to_ad_page_invalid_id(self, extractor: AdExtractor) -> None:
async def test_navigate_to_ad_page_invalid_id(self, test_extractor: AdExtractor) -> None:
"""Test navigation to ad page with invalid ID."""
page_mock = AsyncMock()
page_mock.url = "https://www.kleinanzeigen.de/s-suchen.html?k0"
@@ -311,22 +251,22 @@ class TestAdExtractorNavigation:
input_mock.apply = AsyncMock(return_value=True)
input_mock.attrs = {}
with patch.object(extractor, 'page', page_mock), \
patch.object(extractor, 'web_open', new_callable=AsyncMock), \
patch.object(extractor, 'web_find', new_callable=AsyncMock, return_value=input_mock):
with patch.object(test_extractor, 'page', page_mock), \
patch.object(test_extractor, 'web_open', new_callable=AsyncMock), \
patch.object(test_extractor, 'web_find', new_callable=AsyncMock, return_value=input_mock):
result = await extractor.naviagte_to_ad_page(99999)
result = await test_extractor.naviagte_to_ad_page(99999)
assert result is False
@pytest.mark.asyncio
async def test_extract_own_ads_urls(self, extractor: AdExtractor) -> None:
async def test_extract_own_ads_urls(self, test_extractor: AdExtractor) -> None:
"""Test extraction of own ads URLs - basic test."""
with patch.object(extractor, 'web_open', new_callable=AsyncMock), \
patch.object(extractor, 'web_sleep', new_callable=AsyncMock), \
patch.object(extractor, 'web_find', new_callable=AsyncMock) as mock_web_find, \
patch.object(extractor, 'web_find_all', new_callable=AsyncMock) as mock_web_find_all, \
patch.object(extractor, 'web_scroll_page_down', new_callable=AsyncMock), \
patch.object(extractor, 'web_execute', new_callable=AsyncMock):
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) as mock_web_find_all, \
patch.object(test_extractor, 'web_scroll_page_down', new_callable=AsyncMock), \
patch.object(test_extractor, 'web_execute', new_callable=AsyncMock):
# Setup mock objects for DOM elements
splitpage = MagicMock()
@@ -355,7 +295,7 @@ class TestAdExtractorNavigation:
]
# Execute test and verify results
refs = await extractor.extract_own_ads_urls()
refs = await test_extractor.extract_own_ads_urls()
assert refs == ['/s-anzeige/test/12345']

1017
tests/unit/test_init.py Normal file

File diff suppressed because it is too large Load Diff