# SPDX-FileCopyrightText: © Jens Bergmann and contributors # SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ """ Minimal smoke tests: post-deployment health checks for kleinanzeigen-bot. These tests verify that the most essential components are operational. """ import contextlib import io import json import logging import os import re from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Mapping from unittest.mock import patch import pytest from ruyaml import YAML import kleinanzeigen_bot from kleinanzeigen_bot.model.config_model import Config from kleinanzeigen_bot.utils.i18n import get_current_locale, set_current_locale from tests.conftest import SmokeKleinanzeigenBot pytestmark = pytest.mark.slow @dataclass(slots = True) class CLIResult: returncode:int stdout:str stderr:str def invoke_cli( args:list[str], cwd:Path | None = None, env_overrides:Mapping[str, str] | None = None, ) -> CLIResult: """ Run the kleinanzeigen-bot CLI in-process and capture stdout/stderr. Args: args: CLI arguments passed to ``kleinanzeigen_bot.main``. cwd: Optional working directory for this in-process CLI run. env_overrides: Optional environment variable overrides merged into the current environment for the run (useful to isolate HOME/XDG paths). """ stdout = io.StringIO() stderr = io.StringIO() previous_cwd:Path | None = None previous_locale = get_current_locale() def capture_register(func:Callable[..., object], *_cb_args:Any, **_cb_kwargs:Any) -> Callable[..., object]: return func log_capture = io.StringIO() log_handler = logging.StreamHandler(log_capture) log_handler.setLevel(logging.DEBUG) def build_result(exit_code:object) -> CLIResult: if exit_code is None: normalized = 0 elif isinstance(exit_code, int): normalized = exit_code else: normalized = 1 combined_stderr = stderr.getvalue() + log_capture.getvalue() return CLIResult(normalized, stdout.getvalue(), combined_stderr) try: if cwd is not None: previous_cwd = Path.cwd() os.chdir(os.fspath(cwd)) logging.getLogger().addHandler(log_handler) with contextlib.ExitStack() as stack: stack.enter_context(patch("kleinanzeigen_bot.atexit.register", capture_register)) stack.enter_context(contextlib.redirect_stdout(stdout)) stack.enter_context(contextlib.redirect_stderr(stderr)) effective_env_overrides = env_overrides if env_overrides is not None else _default_smoke_env(cwd) if effective_env_overrides is not None: stack.enter_context(patch.dict(os.environ, effective_env_overrides)) try: kleinanzeigen_bot.main(["kleinanzeigen-bot", *args]) except SystemExit as exc: return build_result(exc.code) return build_result(0) finally: logging.getLogger().removeHandler(log_handler) log_handler.close() if previous_cwd is not None: os.chdir(previous_cwd) set_current_locale(previous_locale) def _xdg_env_overrides(tmp_path:Path) -> dict[str, str]: """Create temporary HOME/XDG environment overrides for isolated smoke test runs.""" home = tmp_path / "home" xdg_config = tmp_path / "xdg" / "config" xdg_state = tmp_path / "xdg" / "state" xdg_cache = tmp_path / "xdg" / "cache" for path in (home, xdg_config, xdg_state, xdg_cache): path.mkdir(parents = True, exist_ok = True) return { "HOME": os.fspath(home), "XDG_CONFIG_HOME": os.fspath(xdg_config), "XDG_STATE_HOME": os.fspath(xdg_state), "XDG_CACHE_HOME": os.fspath(xdg_cache), } def _default_smoke_env(cwd:Path | None) -> dict[str, str] | None: """Isolate HOME/XDG paths to temporary directories during smoke CLI calls.""" if cwd is None: return None return _xdg_env_overrides(cwd) @pytest.fixture(autouse = True) def disable_update_checker(monkeypatch:pytest.MonkeyPatch) -> None: """Prevent smoke tests from hitting GitHub for update checks.""" def _no_update(*_args:object, **_kwargs:object) -> None: return None monkeypatch.setattr("kleinanzeigen_bot.update_checker.UpdateChecker.check_for_updates", _no_update) @pytest.mark.smoke def test_app_starts(smoke_bot:SmokeKleinanzeigenBot) -> None: """Smoke: Bot can be instantiated and started without error.""" assert smoke_bot is not None # Optionally call a minimal method if available assert hasattr(smoke_bot, "run") or hasattr(smoke_bot, "login") @pytest.mark.smoke @pytest.mark.parametrize("subcommand", [ "--help", "help", "version", "diagnose", ]) def test_cli_subcommands_no_config(subcommand:str, tmp_path:Path) -> None: """ Smoke: CLI subcommands that do not require a config file (--help, help, version, diagnose). """ args = [subcommand] result = invoke_cli(args, cwd = tmp_path) assert result.returncode == 0 out = (result.stdout + "\n" + result.stderr).lower() if subcommand in {"--help", "help"}: assert "usage" in out or "help" in out, f"Expected help text in CLI output.\n{out}" elif subcommand == "version": assert re.match(r"^\s*\d{4}\+\w+", result.stdout.strip()), f"Output does not look like a version string: {result.stdout}" elif subcommand == "diagnose": assert "browser connection diagnostics" in out or "browser-verbindungsdiagnose" in out, f"Expected diagnostic output.\n{out}" @pytest.mark.smoke def test_cli_subcommands_create_config_creates_file(tmp_path:Path) -> None: """ Smoke: CLI 'create-config' creates a config.yaml file in the current directory. """ result = invoke_cli(["create-config"], cwd = tmp_path) config_file = tmp_path / "config.yaml" assert result.returncode == 0 assert config_file.exists(), "config.yaml was not created by create-config command" out = (result.stdout + "\n" + result.stderr).lower() assert "saving" in out, f"Expected saving message in CLI output.\n{out}" assert "config.yaml" in out, f"Expected config.yaml in CLI output.\n{out}" @pytest.mark.smoke def test_cli_subcommands_create_config_fails_if_exists(tmp_path:Path) -> None: """ Smoke: CLI 'create-config' does not overwrite config.yaml if it already exists. """ config_file = tmp_path / "config.yaml" config_file.write_text("# dummy config\n", encoding = "utf-8") result = invoke_cli(["create-config"], cwd = tmp_path) assert result.returncode == 0 assert config_file.exists(), "config.yaml was deleted or not present after second create-config run" out = (result.stdout + "\n" + result.stderr).lower() assert ( "already exists" in out or "not overwritten" in out or "saving" in out ), f"Expected message about existing config in CLI output.\n{out}" @pytest.mark.smoke @pytest.mark.parametrize(("subcommand", "output_check"), [ ("verify", "verify"), ("update-check", "update"), ("update-content-hash", "update-content-hash"), ("diagnose", "diagnose"), ]) @pytest.mark.parametrize(("config_ext", "serializer"), [ ("yaml", None), ("yml", None), ("json", json.dumps), ]) def test_cli_subcommands_with_config_formats( subcommand:str, output_check:str, config_ext:str, serializer:Callable[[dict[str, object]], str] | None, tmp_path:Path, test_bot_config:Config, ) -> None: """ Smoke: CLI subcommands that require a config file, tested with all supported formats. """ config_path = tmp_path / f"config.{config_ext}" try: config_dict = test_bot_config.model_dump() except AttributeError: config_dict = test_bot_config.dict() if config_ext in {"yaml", "yml"}: yaml = YAML(typ = "unsafe", pure = True) with open(config_path, "w", encoding = "utf-8") as f: yaml.dump(config_dict, f) elif serializer is not None: config_path.write_text(serializer(config_dict), encoding = "utf-8") args = [subcommand, "--config", str(config_path), "--workspace-mode", "portable"] result = invoke_cli(args, cwd = tmp_path) assert result.returncode == 0 out = (result.stdout + "\n" + result.stderr).lower() if subcommand == "verify": assert "no configuration errors found" in out, f"Expected 'no configuration errors found' in output for 'verify'.\n{out}" elif subcommand == "update-content-hash": assert "no active ads found" in out, f"Expected 'no active ads found' in output for 'update-content-hash'.\n{out}" elif subcommand == "update-check": assert result.returncode == 0 elif subcommand == "diagnose": assert "browser connection diagnostics" in out or "browser-verbindungsdiagnose" in out, f"Expected diagnostic output for 'diagnose'.\n{out}"