feat: Refactor and expand CLI smoke tests for subcommand/config coverage (#581)

This commit is contained in:
Jens Bergmann
2025-07-14 12:38:23 +02:00
committed by GitHub
parent 47c68add76
commit 280a72cba0
3 changed files with 134 additions and 82 deletions

View File

@@ -6,16 +6,18 @@ Minimal smoke tests: post-deployment health checks for kleinanzeigen-bot.
These tests verify that the most essential components are operational. These tests verify that the most essential components are operational.
""" """
import logging import json
import re
import subprocess # noqa: S404 import subprocess # noqa: S404
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Callable
import pytest import pytest
from ruyaml import YAML
from kleinanzeigen_bot.model.config_model import Config from kleinanzeigen_bot.model.config_model import Config
from kleinanzeigen_bot.utils import i18n from tests.conftest import SmokeKleinanzeigenBot
from tests.conftest import DummyBrowser, DummyPage, SmokeKleinanzeigenBot
def run_cli_subcommand(args:list[str], cwd:str | None = None) -> subprocess.CompletedProcess[str]: def run_cli_subcommand(args:list[str], cwd:str | None = None) -> subprocess.CompletedProcess[str]:
@@ -37,85 +39,95 @@ def test_app_starts(smoke_bot:SmokeKleinanzeigenBot) -> None:
@pytest.mark.smoke @pytest.mark.smoke
def test_config_loads() -> None: @pytest.mark.parametrize("subcommand", [
"""Smoke: Minimal config loads successfully.""" "--help",
minimal_cfg = { "help",
"ad_defaults": {"contact": {"name": "dummy", "zipcode": "12345"}}, "version",
"login": {"username": "dummy", "password": "dummy"}, ])
"publishing": {"delete_old_ads": "BEFORE_PUBLISH", "delete_old_ads_by_title": False}, def test_cli_subcommands_no_config(subcommand:str, tmp_path:Path) -> None:
} """
config = Config.model_validate(minimal_cfg) Smoke: CLI subcommands that do not require a config file (--help, help, version).
assert config.login.username == "dummy" """
assert config.login.password == "dummy" # noqa: S105 args = [subcommand]
result = run_cli_subcommand(args, cwd = str(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}"
@pytest.mark.smoke @pytest.mark.smoke
def test_logger_initializes(tmp_path:Path, caplog:pytest.LogCaptureFixture) -> None: def test_cli_subcommands_create_config_creates_file(tmp_path:Path) -> None:
"""Smoke: Logger can be initialized and used, robust to pytest log capture.""" """
log_path = tmp_path / "smoke_test.log" Smoke: CLI 'create-config' creates a config.yaml file in the current directory.
logger_name = "smoke_test_logger_unique" """
logger = logging.getLogger(logger_name)
logger.setLevel(logging.DEBUG)
logger.propagate = False
# Remove all handlers to start clean
for h in list(logger.handlers):
logger.removeHandler(h)
# Create and attach a file handler
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)
# Log a message
logger.info("Smoke test log message")
# Flush and close the handler
handle.flush()
handle.close()
# Remove the handler from the logger
logger.removeHandler(handle)
assert log_path.exists()
with open(log_path, "r", encoding = "utf-8") as f:
contents = f.read()
assert "Smoke test log message" in contents
@pytest.mark.smoke
def test_translation_system_healthy() -> None:
"""Smoke: Translation system loads and retrieves a known key."""
# Use a known string that should exist in translations (fallback to identity)
en = i18n.translate("Login", None)
assert isinstance(en, str)
assert len(en) > 0
# Switch to German and test
i18n.set_current_locale(i18n.Locale("de"))
de = i18n.translate("Login", None)
assert isinstance(de, str)
assert len(de) > 0
# Reset locale
i18n.set_current_locale(i18n.Locale("en"))
@pytest.mark.smoke
def test_dummy_browser_session() -> None:
"""Smoke: Dummy browser session can be created and closed."""
browser = DummyBrowser()
page = browser.page
assert isinstance(page, DummyPage)
browser.stop() # Should not raise
@pytest.mark.smoke
def test_cli_entrypoint_help_runs() -> None:
"""Smoke: CLI entry point runs with --help and exits cleanly (subprocess)."""
result = run_cli_subcommand(["--help"])
assert result.returncode in {0, 1}, f"CLI exited with unexpected code: {result.returncode}\nstdout: {result.stdout}\nstderr: {result.stderr}"
assert "Usage" in result.stdout or "usage" in result.stdout or "help" in result.stdout.lower(), f"No help text in CLI output: {result.stdout}"
@pytest.mark.smoke
def test_cli_create_config_creates_file(tmp_path:Path) -> None:
"""Smoke: CLI 'create-config' creates a config.yaml file in the current directory."""
result = run_cli_subcommand(["create-config"], cwd = str(tmp_path)) result = run_cli_subcommand(["create-config"], cwd = str(tmp_path))
config_path = tmp_path / "config.yaml" config_file = tmp_path / "config.yaml"
assert result.returncode == 0, f"CLI exited with code {result.returncode}\nstdout: {result.stdout}\nstderr: {result.stderr}" assert result.returncode == 0
assert config_path.exists(), "config.yaml was not created by create-config command" 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 = run_cli_subcommand(["create-config"], cwd = str(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"),
])
@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)]
result = run_cli_subcommand(args, cwd = str(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

View File

@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: © Sebastian Thomschke and contributors # SPDX-FileCopyrightText: © Sebastian Thomschke and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ # SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
from kleinanzeigen_bot.model.config_model import AdDefaults from kleinanzeigen_bot.model.config_model import AdDefaults, Config
def test_migrate_legacy_description_prefix() -> None: def test_migrate_legacy_description_prefix() -> None:
@@ -60,3 +60,17 @@ def test_migrate_legacy_description_suffix() -> None:
"suffix": "Legacy Suffix" "suffix": "Legacy Suffix"
} }
}).description_suffix == "Legacy Suffix" }).description_suffix == "Legacy Suffix"
def test_minimal_config_validation() -> None:
"""
Unit: Minimal config validation.
"""
minimal_cfg = {
"ad_defaults": {"contact": {"name": "dummy", "zipcode": "12345"}},
"login": {"username": "dummy", "password": "dummy"},
"publishing": {"delete_old_ads": "BEFORE_PUBLISH", "delete_old_ads_by_title": False},
}
config = Config.model_validate(minimal_cfg)
assert config.login.username == "dummy"
assert config.login.password == "dummy" # noqa: S105

View File

@@ -1358,3 +1358,29 @@ class TestKleinanzeigenBotChangedAds:
# The changed ad should be loaded with 'due' selector because it's due for republication # The changed ad should be loaded with 'due' selector because it's due for republication
assert len(ads_to_publish) == 1 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