Files
kleinanzeigen-bot/tests/unit/test_app.py
Jens 25e2970ed0 feat: improve status APR report (#1201)
## ℹ️ Description

- Moves APR preview information out of verify output and into status
reporting.
- Improves status output into readable per-ad blocks with full relative
file paths and structured APR update/publish details.

## 📋 Changes Summary

- Keep verify focused on validation while preserving its full ad-loading
validation path.
- Show APR preview details in status without requiring verbose mode.
- Render status as path-first per-ad report lines with title, id,
status, APR update/publish details, and unchanged summary.
- Use structured APR detail data and translated human-readable reasons
instead of raw reason tokens.
- Remove the now-dead APR preview logger and stale translations.
- Update CLI help, README/docs, smoke tests, and focused unit tests.

### ⚙️ Type of Change

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

##  Checklist

- [x] I have reviewed my changes to ensure they meet the project
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.

Validation run:
- pdm run format
- pdm run lint
- pdm run test — 1467 passed, 4 skipped

By submitting this pull request, I confirm that you can use, modify,
copy, and redistribute this contribution, under the terms of your
choice.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* `status` now shows detailed APR preview information for ads in a
clearer, per-ad layout.
* Help text and documentation now point users to `status` for APR
preview details.

* **Bug Fixes**
* `verify` no longer displays APR preview output, making its results
focused on configuration and integrity checks.
* Improved status output readability and APR detail handling for publish
and update scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-04 01:43:20 +02:00

328 lines
16 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 os
from collections.abc import Generator
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from ruamel.yaml import YAML as _YAML
from kleinanzeigen_bot._version import __version__
from kleinanzeigen_bot.app import KleinanzeigenBot
from kleinanzeigen_bot.model.config_model import Config
from kleinanzeigen_bot.runtime_config import RuntimeState
from kleinanzeigen_bot.utils import xdg_paths
@pytest.fixture
def mock_config_setup(test_bot:KleinanzeigenBot, tmp_path:Path) -> Generator[None, None, None]:
"""Provide a centralized mock configuration setup for tests.
This fixture mocks load_config and other essential configuration-related methods."""
test_bot.config_file_path = str(tmp_path / "config.yaml")
workspace = xdg_paths.Workspace.for_config(tmp_path / "config.yaml", "kleinanzeigen-bot")
with (
patch("kleinanzeigen_bot.runtime_config.resolve_workspace", return_value = workspace),
patch(
"kleinanzeigen_bot.runtime_config.load_config",
return_value = RuntimeState(config = test_bot.config, categories = {}, timing_collector = None),
),
patch("kleinanzeigen_bot.runtime_config.apply_browser_config"),
patch("kleinanzeigen_bot.runtime_config.configure_file_logging", return_value = None),
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 run-path initialization and download plumbing."""
@pytest.mark.asyncio
@pytest.mark.parametrize("command", ["verify", "update-check", "update-content-hash", "publish", "delete", "extend", "download"])
async def test_run_uses_workspace_state_file_for_update_checker(self, test_bot:KleinanzeigenBot, command:str, tmp_path:Path) -> None:
"""Ensure UpdateChecker is initialized with the workspace state file."""
update_checker_calls:list[tuple[Config, Path]] = []
workspace = xdg_paths.Workspace.for_config(tmp_path / "config.yaml", "kleinanzeigen-bot")
class DummyUpdateChecker:
def __init__(self, config:Config, state_file:Path) -> None:
update_checker_calls.append((config, state_file))
def check_for_updates(self, *_args:Any, **_kwargs:Any) -> None:
return None
with (
patch("kleinanzeigen_bot.runtime_config.resolve_workspace", return_value = workspace),
patch(
"kleinanzeigen_bot.runtime_config.load_config",
return_value = RuntimeState(config = test_bot.config, categories = {}, timing_collector = None),
),
patch("kleinanzeigen_bot.runtime_config.configure_file_logging", return_value = None),
patch("kleinanzeigen_bot.runtime_config.apply_browser_config"),
patch.object(test_bot, "load_ads", return_value = []),
patch.object(test_bot, "create_browser_session", new_callable = AsyncMock),
patch.object(test_bot, "login", new_callable = AsyncMock),
patch("kleinanzeigen_bot.download_flow.download_ads", new_callable = AsyncMock),
patch.object(test_bot, "close_browser_session"),
patch("kleinanzeigen_bot.update_checker.UpdateChecker", DummyUpdateChecker),
):
await test_bot.run(["app", command])
expected_state_path = (tmp_path / "config.yaml").resolve().parent / ".temp" / "update_check_state.json"
assert update_checker_calls == [(test_bot.config, expected_state_path)]
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_get_config_file_path(self, test_bot:KleinanzeigenBot, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test config file path handling."""
monkeypatch.chdir(tmp_path)
test_bot.config_file_path = os.path.abspath("config.yaml")
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, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
"""Test log file path handling."""
monkeypatch.chdir(tmp_path)
test_bot.log_file_path = os.path.abspath("kleinanzeigen_bot.log")
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 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"
config_path.write_text(
"""
login:
username: test
password: test
""",
encoding = "utf-8",
)
test_bot.config_file_path = str(config_path)
with (
patch("kleinanzeigen_bot.update_checker.UpdateChecker.check_for_updates"),
patch.object(test_bot, "load_ads", return_value = []) as mock_load_ads,
patch("kleinanzeigen_bot.price_reduction.evaluate_auto_price_reduction") as mock_evaluate,
):
await test_bot.run(["script.py", "verify", "--config", str(config_path), "--workspace-mode", "portable"])
mock_load_ads.assert_called_once_with(exclude_ads_with_id = False)
mock_evaluate.assert_not_called()
assert test_bot.config.login.username == "test"
@pytest.mark.asyncio
async def test_run_update_does_not_instantiate_update_checker( # pylint: disable=unused-argument
self, test_bot:KleinanzeigenBot, mock_config_setup:None,
) -> None:
"""Verify update command does NOT instantiate or run UpdateChecker."""
update_checker_calls:list[tuple[object, object]] = []
class _DummyUpdateChecker:
def __init__(self, config:object, state_file:object) -> None:
update_checker_calls.append((config, state_file))
def check_for_updates(self, *args:object, **kwargs:object) -> None:
pass
with (
patch.object(test_bot, "load_ads", return_value = []),
patch("kleinanzeigen_bot.update_checker.UpdateChecker", _DummyUpdateChecker),
):
await test_bot.run(["script.py", "update"])
assert update_checker_calls == [], "UpdateChecker should not be instantiated for the update command"
@pytest.mark.asyncio
async def test_run_invalid_download_selector_skips_bootstrap(self, test_bot:KleinanzeigenBot, tmp_path:Path) -> None:
"""Verify download with invalid explicit --ads exits before bootstrap/config loading."""
config_path = tmp_path / "config.yaml"
config_path.write_text(
"login:\n username: test\n password: test"
"\nad_defaults:\n contact:\n name: T\n zipcode: '12345'"
"\npublishing:\n delete_old_ads: BEFORE_PUBLISH\n delete_old_ads_by_title: false\n",
encoding = "utf-8",
)
workspace = xdg_paths.Workspace.for_config(config_path, "kleinanzeigen-bot")
with (
patch("kleinanzeigen_bot.runtime_config.resolve_workspace", return_value = workspace),
patch("kleinanzeigen_bot.runtime_config.load_config") as mock_load_config,
):
with pytest.raises(SystemExit) as exc_info:
await test_bot.run(["script.py", "download", "--ads=invalid"])
assert exc_info.value.code == 2
mock_load_config.assert_not_called()
# ---------------------------------------------------------------------------
# Translation integrity
# ---------------------------------------------------------------------------
class TestAppTranslations:
"""Verify German translation keys exist for extracted app.py handler methods."""
def _load_translations(self) -> dict[str, object]:
trans_path = Path(__file__).resolve().parent.parent.parent / "src" / "kleinanzeigen_bot" / "resources" / "translations.de.yaml"
with open(trans_path, encoding = "utf-8") as f:
trans:dict[str, object] = _YAML(typ = "safe").load(f)
return trans
def test_handler_translation_sections_present(self) -> None:
"""All handler methods with LOG calls have corresponding translation sections."""
trans = self._load_translations()
app_section = trans.get("kleinanzeigen_bot/app.py", {})
assert isinstance(app_section, dict)
# Every handler that emits LOG.* calls must have a translation section
expected_sections:set[str] = {
"_handle_verify",
"_handle_update_content_hash",
"_handle_publish",
"_handle_update",
"_handle_delete",
"_handle_extend",
"_handle_download",
}
actual_sections = {k for k in app_section if isinstance(app_section[k], dict)}
for section in expected_sections:
assert section in actual_sections, f"Missing translation section for {section}"
def test_key_handler_messages_are_translated(self) -> None:
"""Spot-check that critical messages moved to handlers are still translated."""
trans = self._load_translations()
app_section = trans.get("kleinanzeigen_bot/app.py", {})
assert isinstance(app_section, dict)
# Verify messages in their new handler homes
verify_section = app_section.get("_handle_verify", {})
assert isinstance(verify_section, dict)
assert verify_section["DONE: No configuration errors found."] == "FERTIG: Keine Konfigurationsfehler gefunden."
update_section = app_section.get("_handle_update", {})
assert isinstance(update_section, dict)
assert update_section["DONE: No changed ads found."] == "FERTIG: Keine geänderten Anzeigen gefunden."
extend_section = app_section.get("_handle_extend", {})
assert isinstance(extend_section, dict)
assert extend_section["DONE: No ads found to extend."] == "FERTIG: Keine Anzeigen zum Verlängern gefunden."
# Verify original run section keeps only its own messages
run_section = app_section.get("run", {})
assert isinstance(run_section, dict)
assert "Unknown command: %s" in run_section
assert "DONE: No configuration errors found." not in run_section
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("kleinanzeigen_bot.download_flow.download_ads", new_callable = AsyncMock) as mock_download:
await test_bot.run(["script.py", "download"])
assert test_bot.ads_selector == "new"
mock_download.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_update_default_selector(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running update command with default selector falls back to changed."""
with patch.object(test_bot, "load_ads", return_value = []):
await test_bot.run(["script.py", "update"])
assert test_bot.ads_selector == "changed"
@pytest.mark.asyncio
async def test_run_extend_default_selector(self, test_bot:KleinanzeigenBot, mock_config_setup:None) -> None: # pylint: disable=unused-argument
"""Test running extend command with default selector falls back to all."""
with patch.object(test_bot, "load_ads", return_value = []):
await test_bot.run(["script.py", "extend"])
assert test_bot.ads_selector == "all"
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("kleinanzeigen_bot.download_flow.download_ads", new_callable = AsyncMock) as mock_download:
await test_bot.run(["script.py", "download", "--ads=123,456"])
assert test_bot.ads_selector == "123,456"
mock_download.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("command", ["publish", "download", "update", "extend"])
async def test_run_invalid_selector_exits_with_error( # pylint: disable=unused-argument
self, test_bot:KleinanzeigenBot, mock_config_setup:None, command:str,
) -> None:
"""Test running command with invalid selector exits with error."""
with pytest.raises(SystemExit) as exc_info:
await test_bot.run(["script.py", command, "--ads=invalid"])
assert exc_info.value.code == 2