mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 02:31:45 +01:00
feat: speed up and stabilise test suite (#676)
## ℹ️ Description *Provide a concise summary of the changes introduced in this pull request.* - Link to the related issue(s): Issue # - Describe the motivation and context for this change. Refactors the test harness for faster and more reliable feedback: adds deterministic time freezing for update checks, accelerates and refactors smoke tests to run in-process, defaults pytest to xdist with durations tracking, and adjusts CI triggers so PRs run the test matrix only once. ## 📋 Changes Summary - add pytest-xdist + durations reporting defaults, force deterministic locale and slow markers, and document the workflow adjustments - run smoke tests in-process (no subprocess churn), mock update checks/logging, and mark slow specs appropriately - deflake update check interval tests by freezing datetime and simplify FixedDateTime helper - limit GitHub Actions `push` trigger to `main` so feature branches rely on the single pull_request run ### ⚙️ Type of Change Select the type(s) of change(s) included in this pull request: - [ ] 🐞 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 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. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Ensure tests run in a consistent English locale and restore prior locale after each run * Mark integration scraping tests as slow for clearer categorization * Replace subprocess-based CLI tests with an in-process runner that returns structured results and captures combined stdout/stderr/logs; disable update checks during smoke tests * Freeze current time in update-check tests for deterministic assertions * Add mock for process enumeration in web‑scraping unit tests to stabilize macOS-specific warnings <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.github/workflows/build.yml
vendored
7
.github/workflows/build.yml
vendored
@@ -10,9 +10,8 @@ on: # https://docs.github.com/en/actions/reference/workflows-and-actions/events
|
|||||||
# https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule
|
# https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule
|
||||||
- cron: '0 15 1 * *'
|
- cron: '0 15 1 * *'
|
||||||
push:
|
push:
|
||||||
branches-ignore: # build all branches except:
|
branches:
|
||||||
- 'dependabot/**' # prevent GHA triggered twice (once for commit to the branch and once for opening/syncing the PR)
|
- main
|
||||||
- 'dependencies/pdm' # prevent GHA triggered twice (once for commit to the branch and once for opening/syncing the PR)
|
|
||||||
tags-ignore: # don't build tags
|
tags-ignore: # don't build tags
|
||||||
- '**'
|
- '**'
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
@@ -55,6 +54,8 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
###########################################################
|
###########################################################
|
||||||
|
|
||||||
|
if: github.event_name != 'push' || github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ This project uses a layered testing approach, with a focus on reliability and fa
|
|||||||
- **All tests in order:**
|
- **All tests in order:**
|
||||||
- Run with: `pdm run test` (runs unit, then integration, then smoke)
|
- Run with: `pdm run test` (runs unit, then integration, then smoke)
|
||||||
|
|
||||||
|
### Parallel Execution and Slow-Test Tracking
|
||||||
|
|
||||||
|
- `pytest-xdist` runs every invocation with `-n auto`, so the suite is split across CPU cores automatically.
|
||||||
|
- Pytest now reports the slowest 25 tests (`--durations=25 --durations-min=0.5`), making regressions easy to spot in CI logs.
|
||||||
|
- Long-running scenarios are tagged with `@pytest.mark.slow` (smoke CLI checks and browser integrations). Keep them in CI, but skip locally via `pytest -m "not slow"` when you only need a quick signal.
|
||||||
|
- Coverage commands (`pdm run test:cov`, etc.) remain compatible—`pytest-cov` merges the per-worker data transparently.
|
||||||
|
|
||||||
### CI Test Order
|
### CI Test Order
|
||||||
|
|
||||||
- CI runs unit tests first, then integration tests, then smoke tests.
|
- CI runs unit tests first, then integration tests, then smoke tests.
|
||||||
|
|||||||
28
pdm.lock
generated
28
pdm.lock
generated
@@ -5,7 +5,7 @@
|
|||||||
groups = ["default", "dev"]
|
groups = ["default", "dev"]
|
||||||
strategy = ["inherit_metadata"]
|
strategy = ["inherit_metadata"]
|
||||||
lock_version = "4.5.0"
|
lock_version = "4.5.0"
|
||||||
content_hash = "sha256:db686d496c2229b3bd3d33e5cddb92024bb15b0641c8cad58af92e97bab507b8"
|
content_hash = "sha256:2786f3461151a94a8f76ed4ca2b2f3d8d7242d0004e17f1e1ae326b0d6529acd"
|
||||||
|
|
||||||
[[metadata.targets]]
|
[[metadata.targets]]
|
||||||
requires_python = ">=3.10,<3.15"
|
requires_python = ">=3.10,<3.15"
|
||||||
@@ -553,6 +553,17 @@ files = [
|
|||||||
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
|
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "execnet"
|
||||||
|
version = "2.1.1"
|
||||||
|
requires_python = ">=3.8"
|
||||||
|
summary = "execnet: rapid multi-Python deployment"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"},
|
||||||
|
{file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.20.0"
|
version = "3.20.0"
|
||||||
@@ -1360,6 +1371,21 @@ files = [
|
|||||||
{file = "pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e"},
|
{file = "pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-xdist"
|
||||||
|
version = "3.8.0"
|
||||||
|
requires_python = ">=3.9"
|
||||||
|
summary = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs"
|
||||||
|
groups = ["dev"]
|
||||||
|
dependencies = [
|
||||||
|
"execnet>=2.1",
|
||||||
|
"pytest>=7.0.0",
|
||||||
|
]
|
||||||
|
files = [
|
||||||
|
{file = "pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88"},
|
||||||
|
{file = "pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pywin32-ctypes"
|
name = "pywin32-ctypes"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ dev = [
|
|||||||
"pip-audit",
|
"pip-audit",
|
||||||
"pytest>=8.3.4",
|
"pytest>=8.3.4",
|
||||||
"pytest-asyncio>=0.25.3",
|
"pytest-asyncio>=0.25.3",
|
||||||
|
"pytest-xdist>=3.6.1",
|
||||||
"pytest-rerunfailures",
|
"pytest-rerunfailures",
|
||||||
"pytest-cov>=6.0.0",
|
"pytest-cov>=6.0.0",
|
||||||
"ruff",
|
"ruff",
|
||||||
@@ -347,8 +348,12 @@ addopts = """
|
|||||||
--strict-markers
|
--strict-markers
|
||||||
--doctest-modules
|
--doctest-modules
|
||||||
--cov-report=term-missing
|
--cov-report=term-missing
|
||||||
|
-n auto
|
||||||
|
--durations=25
|
||||||
|
--durations-min=0.5
|
||||||
"""
|
"""
|
||||||
markers = [
|
markers = [
|
||||||
|
"slow: marks a test as long running",
|
||||||
"smoke: marks a test as a high-level smoke test (critical path, no mocks)",
|
"smoke: marks a test as a high-level smoke test (critical path, no mocks)",
|
||||||
"itest: marks a test as an integration test (i.e. a test with external dependencies)",
|
"itest: marks a test as an integration test (i.e. a test with external dependencies)",
|
||||||
"asyncio: mark test as async"
|
"asyncio: mark test as async"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Fixture Organization:
|
|||||||
- Test data fixtures: Shared test data (description_test_cases)
|
- Test data fixtures: Shared test data (description_test_cases)
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import Iterator
|
||||||
from typing import Any, Final, cast
|
from typing import Any, Final, cast
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ import pytest
|
|||||||
from kleinanzeigen_bot import KleinanzeigenBot
|
from kleinanzeigen_bot import KleinanzeigenBot
|
||||||
from kleinanzeigen_bot.model.ad_model import Ad
|
from kleinanzeigen_bot.model.ad_model import Ad
|
||||||
from kleinanzeigen_bot.model.config_model import Config
|
from kleinanzeigen_bot.model.config_model import Config
|
||||||
from kleinanzeigen_bot.utils import loggers
|
from kleinanzeigen_bot.utils import i18n, loggers
|
||||||
from kleinanzeigen_bot.utils.web_scraping_mixin import Browser
|
from kleinanzeigen_bot.utils.web_scraping_mixin import Browser
|
||||||
|
|
||||||
loggers.configure_console_logging()
|
loggers.configure_console_logging()
|
||||||
@@ -198,6 +199,15 @@ def silence_nodriver_logs() -> None:
|
|||||||
loggers.get_logger("nodriver").setLevel(loggers.WARNING)
|
loggers.get_logger("nodriver").setLevel(loggers.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse = True)
|
||||||
|
def force_english_locale() -> Iterator[None]:
|
||||||
|
"""Ensure tests run with a deterministic English locale."""
|
||||||
|
previous_locale = i18n.get_current_locale()
|
||||||
|
i18n.set_current_locale(i18n.Locale("en", "US", "UTF-8"))
|
||||||
|
yield
|
||||||
|
i18n.set_current_locale(previous_locale)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Smoke Test Fixtures - Special fixtures for smoke tests
|
# Smoke Test Fixtures - Special fixtures for smoke tests
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import pytest
|
|||||||
from kleinanzeigen_bot.utils.misc import ensure
|
from kleinanzeigen_bot.utils.misc import ensure
|
||||||
from kleinanzeigen_bot.utils.web_scraping_mixin import WebScrapingMixin
|
from kleinanzeigen_bot.utils.web_scraping_mixin import WebScrapingMixin
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.slow
|
||||||
|
|
||||||
# Configure logging for integration tests
|
# Configure logging for integration tests
|
||||||
# The main bot already handles nodriver logging via silence_nodriver_logs fixture
|
# The main bot already handles nodriver logging via silence_nodriver_logs fixture
|
||||||
# and pytest handles verbosity with -v flag automatically
|
# and pytest handles verbosity with -v flag automatically
|
||||||
|
|||||||
@@ -6,28 +6,91 @@ 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 contextlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess # noqa: S404
|
from dataclasses import dataclass
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Any, Callable
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from ruyaml import YAML
|
from ruyaml import YAML
|
||||||
|
|
||||||
|
import kleinanzeigen_bot
|
||||||
from kleinanzeigen_bot.model.config_model import Config
|
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
|
from tests.conftest import SmokeKleinanzeigenBot
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.slow
|
||||||
|
|
||||||
def run_cli_subcommand(args:list[str], cwd:str | None = None) -> subprocess.CompletedProcess[str]:
|
|
||||||
|
@dataclass(slots = True)
|
||||||
|
class CLIResult:
|
||||||
|
returncode:int
|
||||||
|
stdout:str
|
||||||
|
stderr:str
|
||||||
|
|
||||||
|
|
||||||
|
def invoke_cli(args:list[str], cwd:Path | None = None) -> CLIResult:
|
||||||
"""
|
"""
|
||||||
Run the kleinanzeigen-bot CLI as a subprocess with the given arguments.
|
Run the kleinanzeigen-bot CLI in-process and capture stdout/stderr.
|
||||||
Returns the CompletedProcess object.
|
|
||||||
"""
|
"""
|
||||||
cli_module = "kleinanzeigen_bot.__main__"
|
stdout = io.StringIO()
|
||||||
cmd = [sys.executable, "-m", cli_module] + args
|
stderr = io.StringIO()
|
||||||
return subprocess.run(cmd, check = False, capture_output = True, text = True, cwd = cwd) # noqa: S603
|
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))
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
@pytest.mark.smoke
|
||||||
@@ -50,7 +113,7 @@ 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).
|
Smoke: CLI subcommands that do not require a config file (--help, help, version, diagnose).
|
||||||
"""
|
"""
|
||||||
args = [subcommand]
|
args = [subcommand]
|
||||||
result = run_cli_subcommand(args, cwd = str(tmp_path))
|
result = invoke_cli(args, cwd = tmp_path)
|
||||||
assert result.returncode == 0
|
assert result.returncode == 0
|
||||||
out = (result.stdout + "\n" + result.stderr).lower()
|
out = (result.stdout + "\n" + result.stderr).lower()
|
||||||
if subcommand in {"--help", "help"}:
|
if subcommand in {"--help", "help"}:
|
||||||
@@ -66,7 +129,7 @@ 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.
|
Smoke: CLI 'create-config' creates a config.yaml file in the current directory.
|
||||||
"""
|
"""
|
||||||
result = run_cli_subcommand(["create-config"], cwd = str(tmp_path))
|
result = invoke_cli(["create-config"], cwd = tmp_path)
|
||||||
config_file = tmp_path / "config.yaml"
|
config_file = tmp_path / "config.yaml"
|
||||||
assert result.returncode == 0
|
assert result.returncode == 0
|
||||||
assert config_file.exists(), "config.yaml was not created by create-config command"
|
assert config_file.exists(), "config.yaml was not created by create-config command"
|
||||||
@@ -82,7 +145,7 @@ def test_cli_subcommands_create_config_fails_if_exists(tmp_path:Path) -> None:
|
|||||||
"""
|
"""
|
||||||
config_file = tmp_path / "config.yaml"
|
config_file = tmp_path / "config.yaml"
|
||||||
config_file.write_text("# dummy config\n", encoding = "utf-8")
|
config_file.write_text("# dummy config\n", encoding = "utf-8")
|
||||||
result = run_cli_subcommand(["create-config"], cwd = str(tmp_path))
|
result = invoke_cli(["create-config"], cwd = tmp_path)
|
||||||
assert result.returncode == 0
|
assert result.returncode == 0
|
||||||
assert config_file.exists(), "config.yaml was deleted or not present after second create-config run"
|
assert config_file.exists(), "config.yaml was deleted or not present after second create-config run"
|
||||||
out = (result.stdout + "\n" + result.stderr).lower()
|
out = (result.stdout + "\n" + result.stderr).lower()
|
||||||
@@ -126,7 +189,7 @@ def test_cli_subcommands_with_config_formats(
|
|||||||
elif serializer is not None:
|
elif serializer is not None:
|
||||||
config_path.write_text(serializer(config_dict), encoding = "utf-8")
|
config_path.write_text(serializer(config_dict), encoding = "utf-8")
|
||||||
args = [subcommand, "--config", str(config_path)]
|
args = [subcommand, "--config", str(config_path)]
|
||||||
result = run_cli_subcommand(args, cwd = str(tmp_path))
|
result = invoke_cli(args, cwd = tmp_path)
|
||||||
assert result.returncode == 0
|
assert result.returncode == 0
|
||||||
out = (result.stdout + "\n" + result.stderr).lower()
|
out = (result.stdout + "\n" + result.stderr).lower()
|
||||||
if subcommand == "verify":
|
if subcommand == "verify":
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone, tzinfo
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@@ -18,11 +18,47 @@ import requests
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
from kleinanzeigen_bot.model import update_check_state as update_check_state_module
|
||||||
from kleinanzeigen_bot.model.config_model import Config
|
from kleinanzeigen_bot.model.config_model import Config
|
||||||
from kleinanzeigen_bot.model.update_check_state import UpdateCheckState
|
from kleinanzeigen_bot.model.update_check_state import UpdateCheckState
|
||||||
from kleinanzeigen_bot.update_checker import UpdateChecker
|
from kleinanzeigen_bot.update_checker import UpdateChecker
|
||||||
|
|
||||||
|
|
||||||
|
def _freeze_update_state_datetime(monkeypatch:pytest.MonkeyPatch, fixed_now:datetime) -> None:
|
||||||
|
"""Patch UpdateCheckState to return a deterministic datetime.now/utcnow."""
|
||||||
|
|
||||||
|
class FixedDateTime(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz:tzinfo | None = None) -> "FixedDateTime":
|
||||||
|
base = fixed_now.replace(tzinfo = None) if tz is None else fixed_now.astimezone(tz)
|
||||||
|
return cls(
|
||||||
|
base.year,
|
||||||
|
base.month,
|
||||||
|
base.day,
|
||||||
|
base.hour,
|
||||||
|
base.minute,
|
||||||
|
base.second,
|
||||||
|
base.microsecond,
|
||||||
|
tzinfo = base.tzinfo
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def utcnow(cls) -> "FixedDateTime":
|
||||||
|
base = fixed_now.astimezone(timezone.utc).replace(tzinfo = None)
|
||||||
|
return cls(
|
||||||
|
base.year,
|
||||||
|
base.month,
|
||||||
|
base.day,
|
||||||
|
base.hour,
|
||||||
|
base.minute,
|
||||||
|
base.second,
|
||||||
|
base.microsecond
|
||||||
|
)
|
||||||
|
|
||||||
|
datetime_module = getattr(update_check_state_module, "datetime")
|
||||||
|
monkeypatch.setattr(datetime_module, "datetime", FixedDateTime)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def config() -> Config:
|
def config() -> Config:
|
||||||
return Config.model_validate({
|
return Config.model_validate({
|
||||||
@@ -256,10 +292,12 @@ class TestUpdateChecker:
|
|||||||
# Should not raise an exception
|
# Should not raise an exception
|
||||||
state.save(state_file)
|
state.save(state_file)
|
||||||
|
|
||||||
def test_update_check_state_interval_units(self) -> None:
|
def test_update_check_state_interval_units(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||||
"""Test that different interval units are handled correctly."""
|
"""Test that different interval units are handled correctly."""
|
||||||
state = UpdateCheckState()
|
state = UpdateCheckState()
|
||||||
now = datetime.now(timezone.utc)
|
fixed_now = datetime(2025, 1, 15, 8, 0, tzinfo = timezone.utc)
|
||||||
|
_freeze_update_state_datetime(monkeypatch, fixed_now)
|
||||||
|
now = fixed_now
|
||||||
|
|
||||||
# Test seconds (should always be too short, fallback to 7d, only 2 days elapsed, so should_check is False)
|
# Test seconds (should always be too short, fallback to 7d, only 2 days elapsed, so should_check is False)
|
||||||
state.last_check = now - timedelta(seconds = 30)
|
state.last_check = now - timedelta(seconds = 30)
|
||||||
@@ -303,10 +341,14 @@ class TestUpdateChecker:
|
|||||||
state.last_check = now - timedelta(days = 6)
|
state.last_check = now - timedelta(days = 6)
|
||||||
assert state.should_check("1z") is False
|
assert state.should_check("1z") is False
|
||||||
|
|
||||||
def test_update_check_state_interval_validation(self) -> None:
|
def test_update_check_state_interval_validation(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||||
"""Test that interval validation works correctly."""
|
"""Test that interval validation works correctly."""
|
||||||
state = UpdateCheckState()
|
state = UpdateCheckState()
|
||||||
now = datetime.now(timezone.utc)
|
fixed_now = datetime(2025, 1, 1, 12, 0, tzinfo = timezone.utc)
|
||||||
|
|
||||||
|
_freeze_update_state_datetime(monkeypatch, fixed_now)
|
||||||
|
|
||||||
|
now = fixed_now
|
||||||
state.last_check = now - timedelta(days = 1)
|
state.last_check = now - timedelta(days = 1)
|
||||||
|
|
||||||
# Test minimum value (1d)
|
# Test minimum value (1d)
|
||||||
|
|||||||
@@ -1393,6 +1393,7 @@ class TestWebScrapingDiagnostics:
|
|||||||
|
|
||||||
with patch("os.path.exists", return_value = True), \
|
with patch("os.path.exists", return_value = True), \
|
||||||
patch("os.access", return_value = True), \
|
patch("os.access", return_value = True), \
|
||||||
|
patch("psutil.process_iter", return_value = []), \
|
||||||
patch("kleinanzeigen_bot.utils.web_scraping_mixin.net.is_port_open", return_value = False), \
|
patch("kleinanzeigen_bot.utils.web_scraping_mixin.net.is_port_open", return_value = False), \
|
||||||
patch("platform.system", return_value = "Darwin"), \
|
patch("platform.system", return_value = "Darwin"), \
|
||||||
patch("kleinanzeigen_bot.utils.web_scraping_mixin._is_admin", return_value = False), \
|
patch("kleinanzeigen_bot.utils.web_scraping_mixin._is_admin", return_value = False), \
|
||||||
|
|||||||
Reference in New Issue
Block a user