Files
kleinanzeigen-bot/tests/unit/test_init.py
Jens 36ca178574 feat: upgrade nodriver from 0.39 to 0.47 (#635)
## ℹ️ Description
Upgrade nodriver dependency from pinned version 0.39.0 to latest 0.47.0
to resolve browser startup issues and JavaScript evaluation problems
that affected versions 0.40-0.44.

- Link to the related issue(s): Resolves nodriver compatibility issues
- This upgrade addresses browser startup problems and window.BelenConf
evaluation failures that were blocking the use of newer nodriver
versions.

## 📋 Changes Summary

- Updated nodriver dependency from pinned 0.39.0 to >=0.47.0 in
pyproject.toml
- Fixed RemoteObject handling in web_execute method for nodriver 0.47
compatibility
- Added comprehensive BelenConf test fixture with real production data
structure
- Added integration test to validate window.BelenConf evaluation works
correctly
- Added German translation for new error message
- Replaced real user data with privacy-safe dummy data in test fixtures

### 🔧 Type Safety Improvements

**Added explicit `str()` conversions to resolve type inference issues:**

The comprehensive BelenConf test fixture contains deeply nested data
structures that caused pyright's type checker to infer complex
dictionary types throughout the codebase. To ensure type safety and
prevent runtime errors, I added explicit `str()` conversions in key
locations:

- **CSRF tokens**: `str(csrf_token)` - Ensures CSRF tokens are treated
as strings
- **Special attributes**: `str(special_attribute_value)` - Converts
special attribute values to strings
- **DOM attributes**: `str(special_attr_elem.attrs.id)` - Ensures
element IDs are strings
- **URL handling**: `str(current_img_url)` and `str(href_attributes)` -
Converts URLs and href attributes to strings
- **Price values**: `str(ad_cfg.price)` - Ensures price values are
strings

These conversions are defensive programming measures that ensure
backward compatibility and prevent type-related runtime errors, even if
the underlying data structures change in the future.

### ⚙️ Type of Change
- [x]  New feature (adds new functionality without breaking existing
usage)
- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)
- [ ] 💥 Breaking change (changes that might break existing user setups,
scripts, or configurations)

##  Checklist
Before requesting a review, confirm the following:
- [x] I have reviewed my changes to ensure they meet the project's
standards.
- [x] I have tested my changes and ensured that all tests pass (`pdm run
test`).
- [x] I have formatted the code (`pdm run format`).
- [x] I have verified that linting passes (`pdm run lint`).
- [x] I have updated documentation where necessary.

By submitting this pull request, I confirm that you can use, modify,
copy, and redistribute this contribution, under the terms of your
choice.
2025-10-12 21:22:46 +02:00

1492 lines
64 KiB
Python

# SPDX-FileCopyrightText: © Jens Bergmann and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
import copy, io, logging, os, tempfile # isort: skip
from collections.abc import Generator
from contextlib import redirect_stdout
from datetime import timedelta
from pathlib import Path
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import ValidationError
from kleinanzeigen_bot import LOG, KleinanzeigenBot, misc
from kleinanzeigen_bot._version import __version__
from kleinanzeigen_bot.model.ad_model import Ad
from kleinanzeigen_bot.model.config_model import AdDefaults, Config, PublishingConfig
from kleinanzeigen_bot.utils import dicts, loggers
from kleinanzeigen_bot.utils.web_scraping_mixin import By, Element
@pytest.fixture
def mock_page() -> MagicMock:
"""Provide a mock page object for testing."""
mock = MagicMock()
# Mock async methods
mock.sleep = AsyncMock()
mock.evaluate = AsyncMock()
mock.click = AsyncMock()
mock.type = AsyncMock()
mock.select = AsyncMock()
mock.wait_for_selector = AsyncMock()
mock.wait_for_navigation = AsyncMock()
mock.wait_for_load_state = AsyncMock()
mock.content = AsyncMock(return_value = "<html></html>")
mock.goto = AsyncMock()
mock.close = AsyncMock()
return mock
@pytest.mark.asyncio
async def test_mock_page_fixture(mock_page:MagicMock) -> None:
"""Test that the mock_page fixture is properly configured."""
# Test that all required async methods are present
assert hasattr(mock_page, "sleep")
assert hasattr(mock_page, "evaluate")
assert hasattr(mock_page, "click")
assert hasattr(mock_page, "type")
assert hasattr(mock_page, "select")
assert hasattr(mock_page, "wait_for_selector")
assert hasattr(mock_page, "wait_for_navigation")
assert hasattr(mock_page, "wait_for_load_state")
assert hasattr(mock_page, "content")
assert hasattr(mock_page, "goto")
assert hasattr(mock_page, "close")
# Test that content returns expected value
assert await mock_page.content() == "<html></html>"
@pytest.fixture
def base_ad_config() -> dict[str, Any]:
"""Provide a base ad configuration that can be used across tests."""
return {
"id": None,
"title": "Test Title",
"description": "Test Description",
"type": "OFFER",
"price_type": "FIXED",
"price": 100,
"shipping_type": "SHIPPING",
"shipping_options": [],
"category": "160",
"special_attributes": {},
"sell_directly": False,
"images": [],
"active": True,
"republication_interval": 7,
"created_on": None,
"contact": {
"name": "Test User",
"zipcode": "12345",
"location": "Test City",
"street": "",
"phone": ""
}
}
def remove_fields(config:dict[str, Any], *fields:str) -> dict[str, Any]:
"""Create a new ad configuration with specified fields removed.
Args:
config: The configuration to remove fields from
*fields: Field names to remove
Returns:
A new ad configuration dictionary with specified fields removed
"""
result = copy.deepcopy(config)
for field in fields:
if "." in field:
# Handle nested fields (e.g., "contact.phone")
parts = field.split(".", maxsplit = 1)
current = result
for part in parts[:-1]:
if part in current:
current = current[part]
if parts[-1] in current:
del current[parts[-1]]
elif field in result:
del result[field]
return result
def test_remove_fields() -> None:
"""Test the remove_fields helper function."""
test_config = {
"field1": "value1",
"field2": "value2",
"nested": {
"field3": "value3"
}
}
# Test removing top-level field
result = remove_fields(test_config, "field1")
assert "field1" not in result
assert "field2" in result
# Test removing nested field
result = remove_fields(test_config, "nested.field3")
assert "field3" not in result["nested"]
# Test removing non-existent field
result = remove_fields(test_config, "nonexistent")
assert result == test_config
@pytest.fixture
def minimal_ad_config(base_ad_config:dict[str, Any]) -> dict[str, Any]:
"""Provide a minimal ad configuration with only required fields."""
return remove_fields(
base_ad_config,
"id",
"created_on",
"shipping_options",
"special_attributes",
"contact.street",
"contact.phone"
)
@pytest.fixture
def mock_config_setup(test_bot:KleinanzeigenBot) -> Generator[None]:
"""Provide a centralized mock configuration setup for tests.
This fixture mocks load_config and other essential configuration-related methods."""
with patch.object(test_bot, "load_config"), \
patch.object(test_bot, "create_browser_session", new_callable = AsyncMock), \
patch.object(test_bot, "login", new_callable = AsyncMock), \
patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request:
# Mock the web request for published ads
mock_request.return_value = {"content": '{"ads": []}'}
yield
class TestKleinanzeigenBotInitialization:
"""Tests for KleinanzeigenBot initialization and basic functionality."""
def test_constructor_initializes_default_values(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that constructor sets all default values correctly."""
assert test_bot.root_url == "https://www.kleinanzeigen.de"
assert isinstance(test_bot.config, Config)
assert test_bot.command == "help"
assert test_bot.ads_selector == "due"
assert test_bot.keep_old_ads is False
assert test_bot.log_file_path is not None
assert test_bot.file_log is None
def test_get_version_returns_correct_version(self, test_bot:KleinanzeigenBot) -> None:
"""Verify version retrieval works correctly."""
with patch("kleinanzeigen_bot.__version__", "1.2.3"):
assert test_bot.get_version() == "1.2.3"
class TestKleinanzeigenBotLogging:
"""Tests for logging functionality."""
def test_configure_file_logging_creates_log_file(self, test_bot:KleinanzeigenBot, log_file_path:str) -> None:
"""Verify that file logging configuration creates the log file."""
test_bot.log_file_path = log_file_path
test_bot.configure_file_logging()
assert test_bot.file_log is not None
assert os.path.exists(log_file_path)
# Test that calling again doesn't recreate logger
original_file_log = test_bot.file_log
test_bot.configure_file_logging()
assert test_bot.file_log is original_file_log
def test_configure_file_logging_disabled_when_no_path(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that logging is disabled when no path is provided."""
test_bot.log_file_path = None
test_bot.configure_file_logging()
assert test_bot.file_log is None
class TestKleinanzeigenBotCommandLine:
"""Tests for command line argument parsing."""
@pytest.mark.parametrize(("args", "expected_command", "expected_selector", "expected_keep_old"), [
(["publish", "--ads=all"], "publish", "all", False),
(["verify"], "verify", "due", False),
(["download", "--ads=12345"], "download", "12345", False),
(["publish", "--force"], "publish", "all", False),
(["publish", "--keep-old"], "publish", "due", True),
(["publish", "--ads=all", "--keep-old"], "publish", "all", True),
(["download", "--ads=new"], "download", "new", False),
(["publish", "--ads=changed"], "publish", "changed", False),
(["publish", "--ads=changed,due"], "publish", "changed,due", False),
(["publish", "--ads=changed,new"], "publish", "changed,new", False),
(["version"], "version", "due", False),
])
def test_parse_args_handles_valid_arguments(
self,
test_bot:KleinanzeigenBot,
args:list[str],
expected_command:str,
expected_selector:str,
expected_keep_old:bool
) -> None:
"""Verify that valid command line arguments are parsed correctly."""
test_bot.parse_args(["dummy"] + args) # Add dummy arg to simulate sys.argv[0]
assert test_bot.command == expected_command
assert test_bot.ads_selector == expected_selector
assert test_bot.keep_old_ads == expected_keep_old
def test_parse_args_handles_help_command(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that help command is handled correctly."""
buf = io.StringIO()
with pytest.raises(SystemExit) as exc_info, redirect_stdout(buf):
test_bot.parse_args(["dummy", "--help"])
assert exc_info.value.code == 0
stdout = buf.getvalue()
assert "publish" in stdout
assert "verify" in stdout
assert "help" in stdout
assert "version" in stdout
assert "--verbose" in stdout
def test_parse_args_handles_invalid_arguments(self, test_bot:KleinanzeigenBot, caplog:pytest.LogCaptureFixture) -> None:
"""Verify that invalid arguments are handled correctly."""
caplog.set_level(logging.ERROR)
with pytest.raises(SystemExit) as exc_info:
test_bot.parse_args(["dummy", "--invalid-option"])
assert exc_info.value.code == 2
assert any(
record.levelno == logging.ERROR
and (
"--invalid-option not recognized" in record.getMessage()
or "Option --invalid-option unbekannt" in record.getMessage()
)
for record in caplog.records
)
assert any(
("--invalid-option not recognized" in m)
or ("Option --invalid-option unbekannt" in m)
for m in caplog.messages
)
def test_parse_args_handles_verbose_flag(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that verbose flag sets correct log level."""
test_bot.parse_args(["dummy", "--verbose"])
assert loggers.is_debug(LOG)
def test_parse_args_handles_config_path(self, test_bot:KleinanzeigenBot, test_data_dir:str) -> None:
"""Verify that config path is set correctly."""
config_path = Path(test_data_dir) / "custom_config.yaml"
test_bot.parse_args(["dummy", "--config", str(config_path)])
assert test_bot.config_file_path == str(config_path.absolute())
class TestKleinanzeigenBotConfiguration:
"""Tests for configuration loading and validation."""
def test_load_config_handles_missing_file(
self,
test_bot:KleinanzeigenBot,
test_data_dir:str
) -> None:
"""Verify that loading a missing config file creates default config. No info log is expected anymore."""
config_path = Path(test_data_dir) / "missing_config.yaml"
config_path.unlink(missing_ok = True)
test_bot.config_file_path = str(config_path)
test_bot.load_config()
assert config_path.exists()
def test_load_config_validates_required_fields(self, test_bot:KleinanzeigenBot, test_data_dir:str) -> None:
"""Verify that config validation checks required fields."""
config_path = Path(test_data_dir) / "config.yaml"
config_content = """
login:
username: dummy_user
# Missing password
"""
with open(config_path, "w", encoding = "utf-8") as f:
f.write(config_content)
test_bot.config_file_path = str(config_path)
with pytest.raises(ValidationError) as exc_info:
test_bot.load_config()
assert "login.username" not in str(exc_info.value)
assert "login.password" in str(exc_info.value)
class TestKleinanzeigenBotAuthentication:
"""Tests for login and authentication functionality."""
@pytest.mark.asyncio
async def test_is_logged_in_returns_true_when_logged_in(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that login check returns true when logged in."""
with patch.object(test_bot, "web_text", return_value = "Welcome dummy_user"):
assert await test_bot.is_logged_in() is True
@pytest.mark.asyncio
async def test_is_logged_in_returns_true_with_alternative_element(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that login check returns true when logged in with alternative element."""
with patch.object(test_bot, "web_text", side_effect = [
TimeoutError(), # First try with mr-medium fails
"angemeldet als: dummy_user" # Second try with user-email succeeds
]):
assert await test_bot.is_logged_in() is True
@pytest.mark.asyncio
async def test_is_logged_in_returns_false_when_not_logged_in(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that login check returns false when not logged in."""
with patch.object(test_bot, "web_text", side_effect = TimeoutError):
assert await test_bot.is_logged_in() is False
@pytest.mark.asyncio
async def test_login_flow_completes_successfully(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that normal login flow completes successfully."""
with patch.object(test_bot, "web_open") as mock_open, \
patch.object(test_bot, "is_logged_in", side_effect = [False, True]) as mock_logged_in, \
patch.object(test_bot, "web_find", side_effect = TimeoutError), \
patch.object(test_bot, "web_input") as mock_input, \
patch.object(test_bot, "web_click") as mock_click:
await test_bot.login()
mock_open.assert_called()
mock_logged_in.assert_called()
mock_input.assert_called()
mock_click.assert_called()
@pytest.mark.asyncio
async def test_login_flow_handles_captcha(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that login flow handles captcha correctly."""
with patch.object(test_bot, "web_open"), \
patch.object(test_bot, "is_logged_in", side_effect = [False, False, True]), \
patch.object(test_bot, "web_find") as mock_find, \
patch.object(test_bot, "web_input") as mock_input, \
patch.object(test_bot, "web_click") as mock_click, \
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput:
# Mock the sequence of web_find calls:
# First login attempt:
# 1. Captcha iframe found (in check_and_wait_for_captcha)
# 2. Phone verification not found (in handle_after_login_logic)
# 3. GDPR banner not found (in handle_after_login_logic)
# Second login attempt:
# 4. Captcha iframe found (in check_and_wait_for_captcha)
# 5. Phone verification not found (in handle_after_login_logic)
# 6. GDPR banner not found (in handle_after_login_logic)
mock_find.side_effect = [
AsyncMock(), # Captcha iframe (first login)
TimeoutError(), # Phone verification (first login)
TimeoutError(), # GDPR banner (first login)
AsyncMock(), # Captcha iframe (second login)
TimeoutError(), # Phone verification (second login)
TimeoutError(), # GDPR banner (second login)
]
mock_ainput.return_value = ""
mock_input.return_value = AsyncMock()
mock_click.return_value = AsyncMock()
await test_bot.login()
# Verify the complete flow
assert mock_find.call_count == 6 # Exactly 6 web_find calls
assert mock_ainput.call_count == 2 # Two captcha prompts
assert mock_input.call_count == 6 # Two login attempts with username, clear password, and set password
assert mock_click.call_count == 2 # Two submit button clicks
@pytest.mark.asyncio
async def test_check_and_wait_for_captcha(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that captcha detection works correctly."""
with patch.object(test_bot, "web_find") as mock_find, \
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput:
# Test case 1: Captcha found
mock_find.return_value = AsyncMock()
mock_ainput.return_value = ""
await test_bot.check_and_wait_for_captcha(is_login_page = True)
assert mock_find.call_count == 1
assert mock_ainput.call_count == 1
# Test case 2: No captcha
mock_find.side_effect = TimeoutError()
mock_ainput.reset_mock()
await test_bot.check_and_wait_for_captcha(is_login_page = True)
assert mock_find.call_count == 2
assert mock_ainput.call_count == 0
@pytest.mark.asyncio
async def test_fill_login_data_and_send(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that login form filling works correctly."""
with patch.object(test_bot, "web_input") as mock_input, \
patch.object(test_bot, "web_click") as mock_click, \
patch.object(test_bot, "check_and_wait_for_captcha", new_callable = AsyncMock) as mock_captcha:
# Mock successful login form interaction
mock_input.return_value = AsyncMock()
mock_click.return_value = AsyncMock()
await test_bot.fill_login_data_and_send()
assert mock_captcha.call_count == 1
assert mock_input.call_count == 3 # Username, clear password, set password
assert mock_click.call_count == 1 # Submit button
@pytest.mark.asyncio
async def test_handle_after_login_logic(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that post-login handling works correctly."""
with patch.object(test_bot, "web_find") as mock_find, \
patch.object(test_bot, "web_click") as mock_click, \
patch("kleinanzeigen_bot.ainput", new_callable = AsyncMock) as mock_ainput:
# Test case 1: No special handling needed
mock_find.side_effect = [TimeoutError(), TimeoutError()] # No phone verification, no GDPR
mock_click.return_value = AsyncMock()
mock_ainput.return_value = ""
await test_bot.handle_after_login_logic()
assert mock_find.call_count == 2
assert mock_click.call_count == 0
assert mock_ainput.call_count == 0
# Test case 2: Phone verification needed
mock_find.reset_mock()
mock_click.reset_mock()
mock_ainput.reset_mock()
mock_find.side_effect = [AsyncMock(), TimeoutError()] # Phone verification found, no GDPR
await test_bot.handle_after_login_logic()
assert mock_find.call_count == 2
assert mock_click.call_count == 0 # No click needed, just wait for user
assert mock_ainput.call_count == 1 # Wait for user to complete verification
# Test case 3: GDPR banner present
mock_find.reset_mock()
mock_click.reset_mock()
mock_ainput.reset_mock()
mock_find.side_effect = [TimeoutError(), AsyncMock()] # No phone verification, GDPR found
await test_bot.handle_after_login_logic()
assert mock_find.call_count == 2
assert mock_click.call_count == 2 # Click to accept GDPR and continue
assert mock_ainput.call_count == 0
class TestKleinanzeigenBotLocalization:
"""Tests for localization and help text."""
def test_show_help_displays_german_text(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that help text is displayed in German when language is German."""
with patch("kleinanzeigen_bot.get_current_locale") as mock_locale, \
patch("builtins.print") as mock_print:
mock_locale.return_value.language = "de"
test_bot.show_help()
printed_text = "".join(str(call.args[0]) for call in mock_print.call_args_list)
assert "Verwendung:" in printed_text
assert "Befehle:" in printed_text
def test_show_help_displays_english_text(self, test_bot:KleinanzeigenBot) -> None:
"""Verify that help text is displayed in English when language is English."""
with patch("kleinanzeigen_bot.get_current_locale") as mock_locale, \
patch("builtins.print") as mock_print:
mock_locale.return_value.language = "en"
test_bot.show_help()
printed_text = "".join(str(call.args[0]) for call in mock_print.call_args_list)
assert "Usage:" in printed_text
assert "Commands:" in printed_text
class TestKleinanzeigenBotBasics:
"""Basic tests for KleinanzeigenBot."""
def test_get_version(self, test_bot:KleinanzeigenBot) -> None:
"""Test version retrieval."""
assert test_bot.get_version() == __version__
def test_configure_file_logging(self, test_bot:KleinanzeigenBot, log_file_path:str) -> None:
"""Test file logging configuration."""
test_bot.log_file_path = log_file_path
test_bot.configure_file_logging()
assert test_bot.file_log is not None
assert os.path.exists(log_file_path)
def test_configure_file_logging_no_path(self, test_bot:KleinanzeigenBot) -> None:
"""Test file logging configuration with no path."""
test_bot.log_file_path = None
test_bot.configure_file_logging()
assert test_bot.file_log is None
def test_close_browser_session(self, test_bot:KleinanzeigenBot) -> None:
"""Test closing browser session."""
mock_close = MagicMock()
test_bot.page = MagicMock() # Ensure page exists to trigger cleanup
with patch.object(test_bot, "close_browser_session", new = mock_close):
test_bot.close_browser_session() # Call directly instead of relying on __del__
mock_close.assert_called_once()
def test_get_root_url(self, test_bot:KleinanzeigenBot) -> None:
"""Test root URL retrieval."""
assert test_bot.root_url == "https://www.kleinanzeigen.de"
def test_get_config_defaults(self, test_bot:KleinanzeigenBot) -> None:
"""Test default configuration values."""
assert isinstance(test_bot.config, Config)
assert test_bot.command == "help"
assert test_bot.ads_selector == "due"
assert test_bot.keep_old_ads is False
def test_get_log_level(self, test_bot:KleinanzeigenBot) -> None:
"""Test log level configuration."""
# Reset log level to default
LOG.setLevel(loggers.INFO)
assert not loggers.is_debug(LOG)
test_bot.parse_args(["script.py", "-v"])
assert loggers.is_debug(LOG)
def test_get_config_file_path(self, test_bot:KleinanzeigenBot) -> None:
"""Test config file path handling."""
default_path = os.path.abspath("config.yaml")
assert test_bot.config_file_path == default_path
test_path = os.path.abspath("custom_config.yaml")
test_bot.config_file_path = test_path
assert test_bot.config_file_path == test_path
def test_get_log_file_path(self, test_bot:KleinanzeigenBot) -> None:
"""Test log file path handling."""
default_path = os.path.abspath("kleinanzeigen_bot.log")
assert test_bot.log_file_path == default_path
test_path = os.path.abspath("custom.log")
test_bot.log_file_path = test_path
assert test_bot.log_file_path == test_path
def test_get_categories(self, test_bot:KleinanzeigenBot) -> None:
"""Test categories handling."""
test_categories = {"test_cat": "test_id"}
test_bot.categories = test_categories
assert test_bot.categories == test_categories
class TestKleinanzeigenBotArgParsing:
"""Tests for command line argument parsing."""
def test_parse_args_help(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing help command."""
test_bot.parse_args(["script.py", "help"])
assert test_bot.command == "help"
def test_parse_args_version(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing version command."""
test_bot.parse_args(["script.py", "version"])
assert test_bot.command == "version"
def test_parse_args_verbose(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing verbose flag."""
test_bot.parse_args(["script.py", "-v", "help"])
assert loggers.is_debug(loggers.get_logger("kleinanzeigen_bot"))
def test_parse_args_config_path(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing config path."""
test_bot.parse_args(["script.py", "--config=test.yaml", "help"])
assert test_bot.config_file_path.endswith("test.yaml")
def test_parse_args_logfile(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing log file path."""
test_bot.parse_args(["script.py", "--logfile=test.log", "help"])
assert test_bot.log_file_path is not None
assert "test.log" in test_bot.log_file_path
def test_parse_args_ads_selector(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing ads selector."""
test_bot.parse_args(["script.py", "--ads=all", "publish"])
assert test_bot.ads_selector == "all"
def test_parse_args_force(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing force flag."""
test_bot.parse_args(["script.py", "--force", "publish"])
assert test_bot.ads_selector == "all"
def test_parse_args_keep_old(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing keep-old flag."""
test_bot.parse_args(["script.py", "--keep-old", "publish"])
assert test_bot.keep_old_ads is True
def test_parse_args_logfile_empty(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing empty log file path."""
test_bot.parse_args(["script.py", "--logfile=", "help"])
assert test_bot.log_file_path is None
def test_parse_args_lang_option(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing language option."""
test_bot.parse_args(["script.py", "--lang=en", "help"])
assert test_bot.command == "help"
def test_parse_args_no_arguments(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing no arguments defaults to help."""
test_bot.parse_args(["script.py"])
assert test_bot.command == "help"
def test_parse_args_multiple_commands(self, test_bot:KleinanzeigenBot) -> None:
"""Test parsing multiple commands raises error."""
with pytest.raises(SystemExit) as exc_info:
test_bot.parse_args(["script.py", "help", "version"])
assert exc_info.value.code == 2
class TestKleinanzeigenBotCommands:
"""Tests for command execution."""
@pytest.mark.asyncio
async def test_run_version_command(self, test_bot:KleinanzeigenBot, capsys:Any) -> None:
"""Test running version command."""
await test_bot.run(["script.py", "version"])
captured = capsys.readouterr()
assert __version__ in captured.out
@pytest.mark.asyncio
async def test_run_help_command(self, test_bot:KleinanzeigenBot, capsys:Any) -> None:
"""Test running help command."""
await test_bot.run(["script.py", "help"])
captured = capsys.readouterr()
assert "Usage:" in captured.out
@pytest.mark.asyncio
async def test_run_unknown_command(self, test_bot:KleinanzeigenBot) -> None:
"""Test running unknown command."""
with pytest.raises(SystemExit) as exc_info:
await test_bot.run(["script.py", "unknown"])
assert exc_info.value.code == 2
@pytest.mark.asyncio
async def test_verify_command(self, test_bot:KleinanzeigenBot, tmp_path:Any) -> None:
"""Test verify command with minimal config."""
config_path = Path(tmp_path) / "config.yaml"
with open(config_path, "w", encoding = "utf-8") as f:
f.write("""
login:
username: test
password: test
""")
test_bot.config_file_path = str(config_path)
await test_bot.run(["script.py", "verify"])
assert test_bot.config.login.username == "test"
class TestKleinanzeigenBotAdOperations:
"""Tests for ad-related operations."""
@pytest.mark.asyncio
async def test_run_delete_command_no_ads(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running delete command with no ads."""
with patch.object(test_bot, "load_ads", return_value = []):
await test_bot.run(["script.py", "delete"])
assert test_bot.command == "delete"
@pytest.mark.asyncio
async def test_run_publish_command_no_ads(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running publish command with no ads."""
with patch.object(test_bot, "load_ads", return_value = []):
await test_bot.run(["script.py", "publish"])
assert test_bot.command == "publish"
@pytest.mark.asyncio
async def test_run_download_command_default_selector(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running download command with default selector."""
with patch.object(test_bot, "download_ads", new_callable = AsyncMock):
await test_bot.run(["script.py", "download"])
assert test_bot.ads_selector == "new"
def test_load_ads_no_files(self, test_bot:KleinanzeigenBot) -> None:
"""Test loading ads with no files."""
test_bot.config.ad_files = ["nonexistent/*.yaml"]
ads = test_bot.load_ads()
assert len(ads) == 0
class TestKleinanzeigenBotAdManagement:
"""Tests for ad management functionality."""
@pytest.mark.asyncio
async def test_download_ads_with_specific_ids(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test downloading ads with specific IDs."""
test_bot.ads_selector = "123,456"
with patch.object(test_bot, "download_ads", new_callable = AsyncMock):
await test_bot.run(["script.py", "download", "--ads=123,456"])
assert test_bot.ads_selector == "123,456"
@pytest.mark.asyncio
async def test_run_publish_invalid_selector(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running publish with invalid selector."""
with patch.object(test_bot, "load_ads", return_value = []):
await test_bot.run(["script.py", "publish", "--ads=invalid"])
assert test_bot.ads_selector == "due"
@pytest.mark.asyncio
async def test_run_download_invalid_selector(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running download with invalid selector."""
with patch.object(test_bot, "download_ads", new_callable = AsyncMock):
await test_bot.run(["script.py", "download", "--ads=invalid"])
assert test_bot.ads_selector == "new"
class TestKleinanzeigenBotAdConfiguration:
"""Tests for ad configuration functionality."""
def test_load_config_with_categories(self, test_bot:KleinanzeigenBot, tmp_path:Any) -> None:
"""Test loading config with custom categories."""
config_path = Path(tmp_path) / "config.yaml"
with open(config_path, "w", encoding = "utf-8") as f:
f.write("""
login:
username: test
password: test
categories:
custom_cat: custom_id
""")
test_bot.config_file_path = str(config_path)
test_bot.load_config()
assert "custom_cat" in test_bot.categories
assert test_bot.categories["custom_cat"] == "custom_id"
def test_load_ads_with_missing_title(self, test_bot:KleinanzeigenBot, tmp_path:Any, minimal_ad_config:dict[str, Any]) -> None:
"""Test loading ads with missing title."""
temp_path = Path(tmp_path)
ad_dir = temp_path / "ads"
ad_dir.mkdir()
ad_file = ad_dir / "test_ad.yaml"
# Create a minimal config with empty title to trigger validation
ad_cfg = minimal_ad_config | {
"title": ""
}
dicts.save_dict(ad_file, ad_cfg)
# Set config file path to tmp_path and use relative path for ad_files
test_bot.config_file_path = str(temp_path / "config.yaml")
test_bot.config.ad_files = ["ads/*.yaml"]
with pytest.raises(ValidationError) as exc_info:
test_bot.load_ads()
assert "title" in str(exc_info.value)
def test_load_ads_with_invalid_price_type(self, test_bot:KleinanzeigenBot, tmp_path:Any, minimal_ad_config:dict[str, Any]) -> None:
"""Test loading ads with invalid price type."""
temp_path = Path(tmp_path)
ad_dir = temp_path / "ads"
ad_dir.mkdir()
ad_file = ad_dir / "test_ad.yaml"
# Create config with invalid price type
ad_cfg = minimal_ad_config | {
"price_type": "INVALID_TYPE"
}
dicts.save_dict(ad_file, ad_cfg)
# Set config file path to tmp_path and use relative path for ad_files
test_bot.config_file_path = str(temp_path / "config.yaml")
test_bot.config.ad_files = ["ads/*.yaml"]
with pytest.raises(ValidationError) as exc_info:
test_bot.load_ads()
assert "price_type" in str(exc_info.value)
def test_load_ads_with_invalid_shipping_type(self, test_bot:KleinanzeigenBot, tmp_path:Any, minimal_ad_config:dict[str, Any]) -> None:
"""Test loading ads with invalid shipping type."""
temp_path = Path(tmp_path)
ad_dir = temp_path / "ads"
ad_dir.mkdir()
ad_file = ad_dir / "test_ad.yaml"
# Create config with invalid shipping type
ad_cfg = minimal_ad_config | {
"shipping_type": "INVALID_TYPE"
}
dicts.save_dict(ad_file, ad_cfg)
# Set config file path to tmp_path and use relative path for ad_files
test_bot.config_file_path = str(temp_path / "config.yaml")
test_bot.config.ad_files = ["ads/*.yaml"]
with pytest.raises(ValidationError) as exc_info:
test_bot.load_ads()
assert "shipping_type" in str(exc_info.value)
def test_load_ads_with_invalid_price_config(self, test_bot:KleinanzeigenBot, tmp_path:Any, minimal_ad_config:dict[str, Any]) -> None:
"""Test loading ads with invalid price configuration."""
temp_path = Path(tmp_path)
ad_dir = temp_path / "ads"
ad_dir.mkdir()
ad_file = ad_dir / "test_ad.yaml"
# Create config with price for GIVE_AWAY type
ad_cfg = minimal_ad_config | {
"price_type": "GIVE_AWAY",
"price": 100 # Price should not be set for GIVE_AWAY
}
dicts.save_dict(ad_file, ad_cfg)
# Set config file path to tmp_path and use relative path for ad_files
test_bot.config_file_path = str(temp_path / "config.yaml")
test_bot.config.ad_files = ["ads/*.yaml"]
with pytest.raises(ValidationError) as exc_info:
test_bot.load_ads()
assert "price" in str(exc_info.value)
def test_load_ads_with_missing_price(self, test_bot:KleinanzeigenBot, tmp_path:Any, minimal_ad_config:dict[str, Any]) -> None:
"""Test loading ads with missing price for FIXED price type."""
temp_path = Path(tmp_path)
ad_dir = temp_path / "ads"
ad_dir.mkdir()
ad_file = ad_dir / "test_ad.yaml"
# Create config with FIXED price type but no price
ad_cfg = minimal_ad_config | {
"price_type": "FIXED",
"price": None # Missing required price for FIXED type
}
dicts.save_dict(ad_file, ad_cfg)
# Set config file path to tmp_path and use relative path for ad_files
test_bot.config_file_path = str(temp_path / "config.yaml")
test_bot.config.ad_files = ["ads/*.yaml"]
with pytest.raises(ValidationError) as exc_info:
test_bot.load_ads()
assert "price is required when price_type is FIXED" in str(exc_info.value)
class TestKleinanzeigenBotAdDeletion:
"""Tests for ad deletion functionality."""
@pytest.mark.asyncio
async def test_delete_ad_by_title(self, test_bot:KleinanzeigenBot, minimal_ad_config:dict[str, Any]) -> None:
"""Test deleting an ad by title."""
test_bot.page = MagicMock()
test_bot.page.evaluate = AsyncMock(return_value = {"statusCode": 200, "content": "{}"})
test_bot.page.sleep = AsyncMock()
# Use minimal config since we only need title for deletion by title
ad_cfg = Ad.model_validate(minimal_ad_config | {
"title": "Test Title",
"id": None # Explicitly set id to None for title-based deletion
})
published_ads = [
{"title": "Test Title", "id": "67890"},
{"title": "Other Title", "id": "11111"}
]
with patch.object(test_bot, "web_open", new_callable = AsyncMock), \
patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \
patch.object(test_bot, "web_click", new_callable = AsyncMock), \
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True):
mock_find.return_value.attrs = {"content": "some-token"}
result = await test_bot.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = True)
assert result is True
@pytest.mark.asyncio
async def test_delete_ad_by_id(self, test_bot:KleinanzeigenBot, minimal_ad_config:dict[str, Any]) -> None:
"""Test deleting an ad by ID."""
test_bot.page = MagicMock()
test_bot.page.evaluate = AsyncMock(return_value = {"statusCode": 200, "content": "{}"})
test_bot.page.sleep = AsyncMock()
# Create config with ID for deletion by ID
ad_cfg = Ad.model_validate(minimal_ad_config | {
"id": "12345" # Fixed: use proper dict key syntax
})
published_ads = [
{"title": "Different Title", "id": "12345"},
{"title": "Other Title", "id": "11111"}
]
with patch.object(test_bot, "web_open", new_callable = AsyncMock), \
patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \
patch.object(test_bot, "web_click", new_callable = AsyncMock), \
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True):
mock_find.return_value.attrs = {"content": "some-token"}
result = await test_bot.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = False)
assert result is True
@pytest.mark.asyncio
async def test_delete_ad_by_id_with_non_string_csrf_token(self, test_bot:KleinanzeigenBot, minimal_ad_config:dict[str, Any]) -> None:
"""Test deleting an ad by ID with non-string CSRF token to cover str() conversion."""
test_bot.page = MagicMock()
test_bot.page.evaluate = AsyncMock(return_value = {"statusCode": 200, "content": "{}"})
test_bot.page.sleep = AsyncMock()
# Create config with ID for deletion by ID
ad_cfg = Ad.model_validate(minimal_ad_config | {
"id": "12345"
})
published_ads = [
{"title": "Different Title", "id": "12345"},
{"title": "Other Title", "id": "11111"}
]
with patch.object(test_bot, "web_open", new_callable = AsyncMock), \
patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \
patch.object(test_bot, "web_click", new_callable = AsyncMock), \
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), \
patch.object(test_bot, "web_request", new_callable = AsyncMock) as mock_request:
# Mock non-string CSRF token to test str() conversion
mock_find.return_value.attrs = {"content": 12345} # Non-string token
result = await test_bot.delete_ad(ad_cfg, published_ads, delete_old_ads_by_title = False)
assert result is True
# Verify that str() was called on the CSRF token
mock_request.assert_called_once()
call_args = mock_request.call_args
assert call_args[1]["headers"]["x-csrf-token"] == "12345" # Should be converted to string
class TestKleinanzeigenBotAdRepublication:
"""Tests for ad republication functionality."""
def test_check_ad_republication_with_changes(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Test that ads with changes are marked for republication."""
# Mock the description config to prevent modification of the description
test_bot.config.ad_defaults = AdDefaults.model_validate({
"description": {
"prefix": "",
"suffix": ""
}
})
# Create ad config with all necessary fields for republication
ad_cfg = Ad.model_validate(base_ad_config | {
"id": "12345",
"updated_on": "2024-01-01T00:00:01",
"created_on": "2024-01-01T00:00:01",
"description": "Changed description"
})
# Create a temporary directory and file
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
ad_dir = temp_path / "ads"
ad_dir.mkdir()
ad_file = ad_dir / "test_ad.yaml"
dicts.save_dict(ad_file, ad_cfg.model_dump())
# Set config file path and use relative path for ad_files
test_bot.config_file_path = str(temp_path / "config.yaml")
test_bot.config.ad_files = ["ads/*.yaml"]
ads_to_publish = test_bot.load_ads()
assert len(ads_to_publish) == 1
def test_check_ad_republication_no_changes(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Test that unchanged ads within interval are not marked for republication."""
current_time = misc.now()
three_days_ago = (current_time - timedelta(days = 3)).isoformat()
# Create ad config with timestamps for republication check
ad_cfg = Ad.model_validate(base_ad_config | {
"id": "12345",
"updated_on": three_days_ago,
"created_on": three_days_ago
})
# Calculate hash before making the copy to ensure they match
ad_cfg_orig = ad_cfg.model_dump()
current_hash = ad_cfg.update_content_hash().content_hash
ad_cfg_orig["content_hash"] = current_hash
# Mock the config to prevent actual file operations
test_bot.config.ad_files = ["test.yaml"]
with patch("kleinanzeigen_bot.utils.dicts.load_dict_if_exists", return_value = ad_cfg_orig), \
patch("kleinanzeigen_bot.utils.dicts.load_dict", return_value = {}): # Mock ad_fields.yaml
ads_to_publish = test_bot.load_ads()
assert len(ads_to_publish) == 0 # No ads should be marked for republication
class TestKleinanzeigenBotShippingOptions:
"""Tests for shipping options functionality."""
@pytest.mark.asyncio
async def test_shipping_options_mapping(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any], tmp_path:Any) -> None:
"""Test that shipping options are mapped correctly."""
# Create a mock page to simulate browser context
test_bot.page = MagicMock()
test_bot.page.url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-bestaetigung.html?adId=12345"
test_bot.page.evaluate = AsyncMock()
# Create ad config with specific shipping options
ad_cfg = Ad.model_validate(base_ad_config | {
"shipping_options": ["DHL_2", "Hermes_Päckchen"],
"updated_on": "2024-01-01T00:00:00", # Add created_on to prevent KeyError
"created_on": "2024-01-01T00:00:00" # Add updated_on for consistency
})
# Create the original ad config and published ads list
ad_cfg.update_content_hash() # Add content hash to prevent republication
ad_cfg_orig = ad_cfg.model_dump()
published_ads:list[dict[str, Any]] = []
# Set up default config values needed for the test
test_bot.config.publishing = PublishingConfig.model_validate({
"delete_old_ads": "BEFORE_PUBLISH",
"delete_old_ads_by_title": False
})
# Create temporary file path
ad_file = Path(tmp_path) / "test_ad.yaml"
# Mock web_execute to handle all JavaScript calls
async def mock_web_execute(script:str) -> Any:
if script == "document.body.scrollHeight":
return 0 # Return integer to prevent scrolling loop
return None
# Create mock elements
csrf_token_elem = MagicMock()
csrf_token_elem.attrs = {"content": "csrf-token-123"}
shipping_form_elem = MagicMock()
shipping_form_elem.attrs = {}
shipping_size_radio = MagicMock()
shipping_size_radio.attrs = {"checked": False}
category_path_elem = MagicMock()
category_path_elem.apply = AsyncMock(return_value = "Test Category")
# Mock the necessary web interaction methods
with patch.object(test_bot, "web_execute", side_effect = mock_web_execute), \
patch.object(test_bot, "web_click", new_callable = AsyncMock), \
patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \
patch.object(test_bot, "web_select", new_callable = AsyncMock), \
patch.object(test_bot, "web_input", new_callable = AsyncMock), \
patch.object(test_bot, "web_open", new_callable = AsyncMock), \
patch.object(test_bot, "web_sleep", new_callable = AsyncMock), \
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), \
patch.object(test_bot, "web_request", new_callable = AsyncMock), \
patch.object(test_bot, "web_find_all", new_callable = AsyncMock), \
patch.object(test_bot, "web_await", new_callable = AsyncMock), \
patch("builtins.input", return_value = ""), \
patch.object(test_bot, "web_scroll_page_down", new_callable = AsyncMock):
# Mock web_find to simulate element detection
async def mock_find_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
if selector_value == "meta[name=_csrf]":
return csrf_token_elem
if selector_value == "myftr-shppngcrt-frm":
return shipping_form_elem
if selector_type == By.ID and selector_value.startswith("radio-button-"):
return shipping_size_radio
if selector_value == "postad-category-path":
return category_path_elem
return None
mock_find.side_effect = mock_find_side_effect
# Mock web_check to return True for radio button checked state
with patch.object(test_bot, "web_check", new_callable = AsyncMock) as mock_check:
mock_check.return_value = True
# Test through the public interface by publishing an ad
await test_bot.publish_ad(str(ad_file), ad_cfg, ad_cfg_orig, published_ads)
# Verify that web_find was called the expected number of times
assert mock_find.await_count >= 3
# Verify the file was created in the temporary directory
assert ad_file.exists()
@pytest.mark.asyncio
async def test_special_attributes_with_non_string_values(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Test that special attributes with non-string values are converted to strings."""
# Create ad config with string special attributes first (to pass validation)
ad_cfg = Ad.model_validate(base_ad_config | {
"special_attributes": {
"art_s": "12345", # String value initially
"condition_s": "67890", # String value initially
"color_s": "red" # String value
},
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00"
})
# Now modify the special attributes to non-string values to test str() conversion
# This simulates the scenario where the values come from external sources as non-strings
# We need to cast to Any to bypass type checking for this test
special_attrs = cast(Any, ad_cfg.special_attributes)
special_attrs["art_s"] = 12345 # Non-string value
special_attrs["condition_s"] = 67890 # Non-string value
# Mock special attribute elements
art_s_elem = MagicMock()
art_s_attrs = MagicMock()
art_s_attrs.id = "art_s"
art_s_attrs.name = "art_s"
art_s_elem.attrs = art_s_attrs
art_s_elem.local_name = "select"
condition_s_elem = MagicMock()
condition_s_attrs = MagicMock()
condition_s_attrs.id = "condition_s"
condition_s_attrs.name = "condition_s"
condition_s_elem.attrs = condition_s_attrs
condition_s_elem.local_name = "select"
color_s_elem = MagicMock()
color_s_attrs = MagicMock()
color_s_attrs.id = "color_s"
color_s_attrs.name = "color_s"
color_s_elem.attrs = color_s_attrs
color_s_elem.local_name = "select"
# Mock the necessary web interaction methods
with patch.object(test_bot, "web_find", new_callable = AsyncMock) as mock_find, \
patch.object(test_bot, "web_select", new_callable = AsyncMock) as mock_select, \
patch.object(test_bot, "web_check", new_callable = AsyncMock, return_value = True), \
patch.object(test_bot, "_KleinanzeigenBot__set_condition", new_callable = AsyncMock) as mock_set_condition:
# Mock web_find to simulate element detection
async def mock_find_side_effect(selector_type:By, selector_value:str, **_:Any) -> Element | None:
# Handle XPath queries for special attributes
if selector_type == By.XPATH and "contains(@name" in selector_value:
if "art_s" in selector_value:
return art_s_elem
if "condition_s" in selector_value:
return condition_s_elem
if "color_s" in selector_value:
return color_s_elem
return None
mock_find.side_effect = mock_find_side_effect
# Test the __set_special_attributes method directly
await getattr(test_bot, "_KleinanzeigenBot__set_special_attributes")(ad_cfg)
# Verify that web_select was called with string values (str() conversion)
mock_select.assert_any_call(By.ID, "art_s", "12345") # Converted to string
mock_select.assert_any_call(By.ID, "color_s", "red") # Already string
# Verify that __set_condition was called with string value
mock_set_condition.assert_called_once_with("67890") # Converted to string
class TestKleinanzeigenBotUrlConstruction:
"""Tests for URL construction functionality."""
def test_url_construction(self, test_bot:KleinanzeigenBot) -> None:
"""Test that URLs are constructed correctly."""
# Test login URL
expected_login_url = "https://www.kleinanzeigen.de/m-einloggen.html?targetUrl=/"
assert f"{test_bot.root_url}/m-einloggen.html?targetUrl=/" == expected_login_url
# Test ad management URL
expected_manage_url = "https://www.kleinanzeigen.de/m-meine-anzeigen.html"
assert f"{test_bot.root_url}/m-meine-anzeigen.html" == expected_manage_url
# Test ad publishing URL
expected_publish_url = "https://www.kleinanzeigen.de/p-anzeige-aufgeben-schritt2.html"
assert f"{test_bot.root_url}/p-anzeige-aufgeben-schritt2.html" == expected_publish_url
class TestKleinanzeigenBotPrefixSuffix:
"""Tests for description prefix and suffix functionality."""
# pylint: disable=protected-access
def test_description_prefix_suffix_handling(
self,
test_bot_config:Config,
description_test_cases:list[tuple[dict[str, Any], str, str]]
) -> None:
"""Test handling of description prefix/suffix in various configurations."""
for config, raw_description, expected_description in description_test_cases:
test_bot = KleinanzeigenBot()
test_bot.config = test_bot_config.with_values(config)
ad_cfg = test_bot.load_ad({
"description": raw_description,
"active": True,
"title": "0123456789",
"category": "whatever",
})
# Access private method using the correct name mangling
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert description == expected_description
def test_description_length_validation(self, test_bot_config:Config) -> None:
"""Test that long descriptions with affixes raise appropriate error."""
test_bot = KleinanzeigenBot()
test_bot.config = test_bot_config.with_values({
"ad_defaults": {
"description_prefix": "P" * 1000,
"description_suffix": "S" * 1000
}
})
ad_cfg = test_bot.load_ad({
"description": "D" * 2001, # This plus affixes will exceed 4000 chars
"active": True,
"title": "0123456789",
"category": "whatever",
})
with pytest.raises(AssertionError) as exc_info:
getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert "Length of ad description including prefix and suffix exceeds 4000 chars" in str(exc_info.value)
assert "Description length: 4001" in str(exc_info.value)
class TestKleinanzeigenBotDescriptionHandling:
"""Tests for description handling functionality."""
def test_description_without_main_config_description(self, test_bot_config:Config) -> None:
"""Test that description works correctly when description is missing from main config."""
test_bot = KleinanzeigenBot()
test_bot.config = test_bot_config
# Test with a simple ad config
ad_cfg = test_bot.load_ad({
"description": "Test Description",
"active": True,
"title": "0123456789",
"category": "whatever",
})
# The description should be returned as-is without any prefix/suffix
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert description == "Test Description"
def test_description_with_only_new_format_affixes(self, test_bot_config:Config) -> None:
"""Test that description works with only new format affixes in config."""
test_bot = KleinanzeigenBot()
test_bot.config = test_bot_config.with_values({
"ad_defaults": {
"description_prefix": "Prefix: ",
"description_suffix": " :Suffix"
}
})
ad_cfg = test_bot.load_ad({
"description": "Test Description",
"active": True,
"title": "0123456789",
"category": "whatever",
})
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert description == "Prefix: Test Description :Suffix"
def test_description_with_mixed_config_formats(self, test_bot_config:Config) -> None:
"""Test that description works with both old and new format affixes in config."""
test_bot = KleinanzeigenBot()
test_bot.config = test_bot_config.with_values({
"ad_defaults": {
"description_prefix": "New Prefix: ",
"description_suffix": " :New Suffix",
"description": {
"prefix": "Old Prefix: ",
"suffix": " :Old Suffix"
}
}
})
ad_cfg = test_bot.load_ad({
"description": "Test Description",
"active": True,
"title": "0123456789",
"category": "whatever",
})
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert description == "New Prefix: Test Description :New Suffix"
def test_description_with_ad_level_affixes(self, test_bot_config:Config) -> None:
"""Test that ad-level affixes take precedence over config affixes."""
test_bot = KleinanzeigenBot()
test_bot.config = test_bot_config.with_values({
"ad_defaults": {
"description_prefix": "Config Prefix: ",
"description_suffix": " :Config Suffix"
}
})
ad_cfg = test_bot.load_ad({
"description": "Test Description",
"description_prefix": "Ad Prefix: ",
"description_suffix": " :Ad Suffix",
"active": True,
"title": "0123456789",
"category": "whatever",
})
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert description == "Ad Prefix: Test Description :Ad Suffix"
def test_description_with_none_values(self, test_bot_config:Config) -> None:
"""Test that None values in affixes are handled correctly."""
test_bot = KleinanzeigenBot()
test_bot.config = test_bot_config.with_values({
"ad_defaults": {
"description_prefix": None,
"description_suffix": None,
"description": {
"prefix": None,
"suffix": None
}
}
})
ad_cfg = test_bot.load_ad({
"description": "Test Description",
"active": True,
"title": "0123456789",
"category": "whatever",
})
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert description == "Test Description"
def test_description_with_email_replacement(self, test_bot_config:Config) -> None:
"""Test that @ symbols in description are replaced with (at)."""
test_bot = KleinanzeigenBot()
test_bot.config = test_bot_config
ad_cfg = test_bot.load_ad({
"description": "Contact: test@example.com",
"active": True,
"title": "0123456789",
"category": "whatever",
})
description = getattr(test_bot, "_KleinanzeigenBot__get_description")(ad_cfg, with_affixes = True)
assert description == "Contact: test(at)example.com"
class TestKleinanzeigenBotChangedAds:
"""Tests for the 'changed' ads selector functionality."""
def test_load_ads_with_changed_selector(self, test_bot_config:Config, base_ad_config:dict[str, Any]) -> None:
"""Test that only changed ads are loaded when using the 'changed' selector."""
# Set up the bot with the 'changed' selector
test_bot = KleinanzeigenBot()
test_bot.ads_selector = "changed"
test_bot.config = test_bot_config.with_values({
"ad_defaults": {
"description": {
"prefix": "",
"suffix": ""
}
}
})
# Create a changed ad
ad_cfg = Ad.model_validate(base_ad_config | {
"id": "12345",
"title": "Changed Ad",
"updated_on": "2024-01-01T00:00:00",
"created_on": "2024-01-01T00:00:00",
"active": True
})
# Calculate hash for changed_ad and add it to the config
# Then modify the ad to simulate a change
changed_ad = ad_cfg.model_dump()
changed_hash = ad_cfg.update_content_hash().content_hash
changed_ad["content_hash"] = changed_hash
# Now modify the ad to make it "changed"
changed_ad["title"] = "Changed Ad - Modified"
# Create temporary directory and file
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
ad_dir = temp_path / "ads"
ad_dir.mkdir()
# Write the ad file
dicts.save_dict(ad_dir / "changed_ad.yaml", changed_ad)
# Set config file path and use relative path for ad_files
test_bot.config_file_path = str(temp_path / "config.yaml")
test_bot.config.ad_files = ["ads/*.yaml"]
# Mock the loading of the ad configuration
with patch("kleinanzeigen_bot.utils.dicts.load_dict", side_effect = [
changed_ad, # First call returns the changed ad
{} # Second call for ad_fields.yaml
]):
ads_to_publish = test_bot.load_ads()
# The changed ad should be loaded
assert len(ads_to_publish) == 1
assert ads_to_publish[0][1].title == "Changed Ad - Modified"
def test_load_ads_with_due_selector_includes_all_due_ads(self, test_bot:KleinanzeigenBot, base_ad_config:dict[str, Any]) -> None:
"""Test that 'due' selector includes all ads that are due for republication, regardless of changes."""
# Set up the bot with the 'due' selector
test_bot.ads_selector = "due"
# Create a changed ad that is also due for republication
current_time = misc.now()
old_date = (current_time - timedelta(days = 10)).isoformat() # Past republication interval
ad_cfg = Ad.model_validate(base_ad_config | {
"id": "12345",
"title": "Changed Ad",
"updated_on": old_date,
"created_on": old_date,
"republication_interval": 7, # Due for republication after 7 days
"active": True
})
changed_ad = ad_cfg.model_dump()
# Create temporary directory and file
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
ad_dir = temp_path / "ads"
ad_dir.mkdir()
# Write the ad file
dicts.save_dict(ad_dir / "changed_ad.yaml", changed_ad)
# Set config file path and use relative path for ad_files
test_bot.config_file_path = str(temp_path / "config.yaml")
test_bot.config.ad_files = ["ads/*.yaml"]
# Mock the loading of the ad configuration
with patch("kleinanzeigen_bot.utils.dicts.load_dict", side_effect = [
changed_ad, # First call returns the changed ad
{} # Second call for ad_fields.yaml
]):
ads_to_publish = test_bot.load_ads()
# The changed ad should be loaded with 'due' selector because it's due for republication
assert len(ads_to_publish) == 1
def test_file_logger_writes_message(tmp_path:Path, caplog:pytest.LogCaptureFixture) -> None:
"""
Unit: Logger can be initialized and used, robust to pytest log capture.
"""
log_path = tmp_path / "logger_test.log"
logger_name = "logger_test_logger_unique"
logger = logging.getLogger(logger_name)
logger.setLevel(logging.DEBUG)
logger.propagate = False
for h in list(logger.handlers):
logger.removeHandler(h)
handle = logging.FileHandler(str(log_path), encoding = "utf-8")
handle.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(levelname)s:%(name)s:%(message)s")
handle.setFormatter(formatter)
logger.addHandler(handle)
logger.info("Logger test log message")
handle.flush()
handle.close()
logger.removeHandler(handle)
assert log_path.exists()
with open(log_path, "r", encoding = "utf-8") as f:
contents = f.read()
assert "Logger test log message" in contents