fix: compare updates via release tag (#745)

## ℹ️ Description
- Link to the related issue(s): Issue #N/A
- Describe the motivation and context for this change.
Ensure update-check compares against release tags instead of moving
branch tips and keep tests/translations in sync.

## 📋 Changes Summary
- compare release commit via tag name first and fall back only when
missing
- update update-checker tests for commit-ish resolution and tag-based
release data
- refresh German translations for update-checker log strings

### ⚙️ Type of Change
Select the type(s) of change(s) included in this pull request:
- [x] 🐞 Bug fix (non-breaking change which fixes an issue)


##  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

* **Bug Fixes**
* More reliable update checks by resolving commits from tags, branches
or hashes and robustly comparing short vs full hashes.
* Improved prerelease handling to avoid inappropriate preview updates
and better handling of missing release data.

* **Localization & UX**
* Error and prerelease messages now use localized strings; commit dates
shown consistently in UTC and short-hash form.

* **Tests**
* Updated tests to cover the new resolution flow, error cases, and
logging behavior.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jens
2025-12-21 16:45:20 +01:00
committed by GitHub
parent 01753d0cba
commit 8ea41d3230
3 changed files with 274 additions and 150 deletions

View File

@@ -520,10 +520,8 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py:
################################################# #################################################
kleinanzeigen_bot/update_checker.py: kleinanzeigen_bot/update_checker.py:
################################################# #################################################
_get_commit_date: _resolve_commitish:
"Could not get commit date: %s": "Konnte Commit-Datum nicht ermitteln: %s" "Could not resolve commit '%s': %s": "Konnte Commit '%s' nicht aufloesen: %s"
_get_release_commit:
"Could not get release commit: %s": "Konnte Release-Commit nicht ermitteln: %s"
check_for_updates: check_for_updates:
"A new version is available: %s from %s UTC (current: %s from %s UTC, channel: %s)": "Eine neue Version ist verfügbar: %s vom %s UTC (aktuell: %s vom %s UTC, Kanal: %s)" "A new version is available: %s from %s UTC (current: %s from %s UTC, channel: %s)": "Eine neue Version ist verfügbar: %s vom %s UTC (aktuell: %s vom %s UTC, Kanal: %s)"
"Could not determine commit dates for comparison.": "Konnte Commit-Daten für den Vergleich nicht ermitteln." "Could not determine commit dates for comparison.": "Konnte Commit-Daten für den Vergleich nicht ermitteln."
@@ -531,8 +529,6 @@ kleinanzeigen_bot/update_checker.py:
"Could not determine local version.": "Konnte lokale Version nicht ermitteln." "Could not determine local version.": "Konnte lokale Version nicht ermitteln."
"Could not determine release commit hash.": "Konnte Release-Commit-Hash nicht ermitteln." "Could not determine release commit hash.": "Konnte Release-Commit-Hash nicht ermitteln."
"Could not get releases: %s": "Konnte Releases nicht abrufen: %s" "Could not get releases: %s": "Konnte Releases nicht abrufen: %s"
"Failed to get commit dates: %s": "Fehler beim Abrufen der Commit-Daten: %s"
"Failed to get release commit: %s": "Fehler beim Abrufen des Release-Commits: %s"
? "Release notes:\n%s" ? "Release notes:\n%s"
: "Release-Notizen:\n%s" : "Release-Notizen:\n%s"
"You are on the latest version: %s (compared to %s in channel %s)": "Sie verwenden die neueste Version: %s (verglichen mit %s im Kanal %s)" "You are on the latest version: %s (compared to %s in channel %s)": "Sie verwenden die neueste Version: %s (verglichen mit %s im Kanal %s)"

View File

@@ -6,6 +6,7 @@ from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime
from gettext import gettext as _
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -66,51 +67,33 @@ class UpdateChecker:
return version.split("+")[1] return version.split("+")[1]
return None return None
def _get_release_commit(self, tag_name:str) -> str | None: def _resolve_commitish(self, commitish:str) -> tuple[str | None, datetime | None]:
"""Get the commit hash for a release tag. """Resolve a commit-ish to a full commit hash and date.
Args: Args:
tag_name: The release tag name (e.g. 'latest'). commitish: The commit hash, tag, or branch.
Returns: Returns:
The commit hash, or None if it cannot be determined. Tuple of (full commit hash, commit date), or (None, None) if it cannot be determined.
""" """
try: try:
response = requests.get( response = requests.get(
f"https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/releases/tags/{tag_name}", f"https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/commits/{commitish}",
timeout = self._request_timeout() timeout = self._request_timeout()
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if isinstance(data, dict) and "target_commitish" in data: if not isinstance(data, dict):
return str(data["target_commitish"]) return None, None
return None commit_date = None
if "commit" in data and "author" in data["commit"] and "date" in data["commit"]["author"]:
commit_date = datetime.fromisoformat(data["commit"]["author"]["date"].replace("Z", "+00:00"))
sha = data.get("sha")
commit_hash = str(sha) if sha else None
return commit_hash, commit_date
except Exception as e: except Exception as e:
logger.warning("Could not get release commit: %s", e) logger.warning(_("Could not resolve commit '%s': %s"), commitish, e)
return None return None, None
def _get_commit_date(self, commit:str) -> datetime | None:
"""Get the commit date for a commit hash.
Args:
commit: The commit hash.
Returns:
The commit date, or None if it cannot be determined.
"""
try:
response = requests.get(
f"https://api.github.com/repos/Second-Hand-Friends/kleinanzeigen-bot/commits/{commit}",
timeout = self._request_timeout()
)
response.raise_for_status()
data = response.json()
if isinstance(data, dict) and "commit" in data and "author" in data["commit"] and "date" in data["commit"]["author"]:
return datetime.fromisoformat(data["commit"]["author"]["date"].replace("Z", "+00:00"))
return None
except Exception as e:
logger.warning("Could not get commit date: %s", e)
return None
def _get_short_commit_hash(self, commit:str) -> str: def _get_short_commit_hash(self, commit:str) -> str:
"""Get the short version of a commit hash. """Get the short version of a commit hash.
@@ -123,6 +106,19 @@ class UpdateChecker:
""" """
return commit[:7] return commit[:7]
def _commits_match(self, local_commit:str, release_commit:str) -> bool:
"""Determine whether two commits refer to the same hash.
This accounts for short vs. full hashes (e.g. 7 chars vs. 40 chars).
"""
local_commit = local_commit.strip()
release_commit = release_commit.strip()
if local_commit == release_commit:
return True
if len(local_commit) < len(release_commit) and release_commit.startswith(local_commit):
return True
return len(release_commit) < len(local_commit) and local_commit.startswith(release_commit)
def check_for_updates(self, *, skip_interval_check:bool = False) -> None: def check_for_updates(self, *, skip_interval_check:bool = False) -> None:
"""Check for updates to the bot. """Check for updates to the bot.
@@ -138,12 +134,12 @@ class UpdateChecker:
local_version = self.get_local_version() local_version = self.get_local_version()
if not local_version: if not local_version:
logger.warning("Could not determine local version.") logger.warning(_("Could not determine local version."))
return return
local_commit = self._get_commit_hash(local_version) local_commitish = self._get_commit_hash(local_version)
if not local_commit: if not local_commitish:
logger.warning("Could not determine local commit hash.") logger.warning(_("Could not determine local commit hash."))
return return
# --- Fetch release info from GitHub using correct endpoint per channel --- # --- Fetch release info from GitHub using correct endpoint per channel ---
@@ -158,7 +154,9 @@ class UpdateChecker:
release = response.json() release = response.json()
# Defensive: ensure it's not a prerelease # Defensive: ensure it's not a prerelease
if release.get("prerelease", False): if release.get("prerelease", False):
logger.warning("Latest release from GitHub is a prerelease, but 'latest' channel expects a stable release.") logger.warning(
_("Latest release from GitHub is a prerelease, but 'latest' channel expects a stable release.")
)
return return
elif self.config.update_check.channel == "preview": elif self.config.update_check.channel == "preview":
# Use /releases endpoint and select the most recent prerelease # Use /releases endpoint and select the most recent prerelease
@@ -169,51 +167,47 @@ class UpdateChecker:
response.raise_for_status() response.raise_for_status()
releases = response.json() releases = response.json()
# Find the most recent prerelease # Find the most recent prerelease
release = next((r for r in releases if r.get("prerelease", False)), None) release = next((r for r in releases if r.get("prerelease", False) and not r.get("draft", False)), None)
if not release: if not release:
logger.warning("No prerelease found for 'preview' channel.") logger.warning(_("No prerelease found for 'preview' channel."))
return return
else: else:
logger.warning("Unknown update channel: %s", self.config.update_check.channel) logger.warning(_("Unknown update channel: %s"), self.config.update_check.channel)
return return
except Exception as e: except Exception as e:
logger.warning("Could not get releases: %s", e) logger.warning(_("Could not get releases: %s"), e)
return return
# Get release commit # Get release commit-ish (use tag name to avoid branch tip drift)
try: release_commitish = release.get("tag_name")
release_commit = self._get_release_commit(release["tag_name"]) if not release_commitish:
except Exception as e: release_commitish = release.get("target_commitish")
logger.warning("Failed to get release commit: %s", e) if not release_commitish:
return logger.warning(_("Could not determine release commit hash."))
if not release_commit:
logger.warning("Could not determine release commit hash.")
return return
# Get commit dates # Resolve commit hashes and dates for comparison
try: local_commit, local_commit_date = self._resolve_commitish(local_commitish)
local_commit_date = self._get_commit_date(local_commit) release_commit, release_commit_date = self._resolve_commitish(str(release_commitish))
release_commit_date = self._get_commit_date(release_commit) if not local_commit or not release_commit or not local_commit_date or not release_commit_date:
except Exception as e: logger.warning(_("Could not determine commit dates for comparison."))
logger.warning("Failed to get commit dates: %s", e)
return
if not local_commit_date or not release_commit_date:
logger.warning("Could not determine commit dates for comparison.")
return return
if local_commit == release_commit: if self._commits_match(local_commit, release_commit):
# If the commit hashes are identical, we are on the latest version. Do not proceed to other checks. # If the commit hashes are identical, we are on the latest version. Do not proceed to other checks.
logger.info( logger.info(
"You are on the latest version: %s (compared to %s in channel %s)", _("You are on the latest version: %s (compared to %s in channel %s)"),
local_version, local_version,
self._get_short_commit_hash(release_commit), self._get_short_commit_hash(release_commit),
self.config.update_check.channel self.config.update_check.channel
) )
self.state.update_last_check()
self.state.save(self.state_file)
return return
# All commit dates are in UTC; append ' UTC' to timestamps in logs for clarity. # All commit dates are in UTC; append ' UTC' to timestamps in logs for clarity.
if local_commit_date < release_commit_date: if local_commit_date < release_commit_date:
logger.warning( logger.warning(
"A new version is available: %s from %s UTC (current: %s from %s UTC, channel: %s)", _("A new version is available: %s from %s UTC (current: %s from %s UTC, channel: %s)"),
self._get_short_commit_hash(release_commit), self._get_short_commit_hash(release_commit),
release_commit_date.strftime("%Y-%m-%d %H:%M:%S"), release_commit_date.strftime("%Y-%m-%d %H:%M:%S"),
local_version, local_version,
@@ -221,11 +215,13 @@ class UpdateChecker:
self.config.update_check.channel self.config.update_check.channel
) )
if release.get("body"): if release.get("body"):
logger.info("Release notes:\n%s", release["body"]) logger.info(_("Release notes:\n%s"), release["body"])
else: else:
logger.info( logger.info(
"You are on a different commit than the release for channel '%s' (tag: %s). This may mean you are ahead, behind, or on a different branch. " _(
"Local commit: %s (%s UTC), Release commit: %s (%s UTC)", "You are on a different commit than the release for channel '%s' (tag: %s). This may mean you are ahead, behind, or on a different branch. "
"Local commit: %s (%s UTC), Release commit: %s (%s UTC)"
),
self.config.update_check.channel, self.config.update_check.channel,
release.get("tag_name", "unknown"), release.get("tag_name", "unknown"),
self._get_short_commit_hash(local_commit), self._get_short_commit_hash(local_commit),

View File

@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: © jens Bergmann and contributors # SPDX-FileCopyrightText: © Jens Bergmann 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/
@@ -7,7 +7,7 @@ from __future__ import annotations
import json import json
import logging import logging
from datetime import datetime, timedelta, timezone, tzinfo from datetime import datetime, timedelta, timezone, tzinfo
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any, cast
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -90,29 +90,56 @@ class TestUpdateChecker:
assert checker._get_commit_hash("2025+fb00f11") == "fb00f11" assert checker._get_commit_hash("2025+fb00f11") == "fb00f11"
assert checker._get_commit_hash("2025") is None assert checker._get_commit_hash("2025") is None
def test_get_release_commit(self, config:Config) -> None: def test_resolve_commitish(self, config:Config) -> None:
"""Test that the release commit hash is correctly retrieved from the GitHub API.""" """Test that a commit-ish is resolved to a full hash and date."""
checker = UpdateChecker(config) checker = UpdateChecker(config)
with patch("requests.get", return_value = MagicMock(json = lambda: {"target_commitish": "e7a3d46"})): with patch(
assert checker._get_release_commit("latest") == "e7a3d46" "requests.get",
return_value = MagicMock(json = lambda: {"sha": "e7a3d46", "commit": {"author": {"date": "2025-05-18T00:00:00Z"}}})
):
commit_hash, commit_date = checker._resolve_commitish("latest")
assert commit_hash == "e7a3d46"
assert commit_date == datetime(2025, 5, 18, tzinfo = timezone.utc)
def test_request_timeout_uses_config(self, config:Config, mocker:"MockerFixture") -> None: def test_request_timeout_uses_config(self, config:Config, mocker:"MockerFixture") -> None:
"""Ensure HTTP calls honor the timeout configuration.""" """Ensure HTTP calls honor the timeout configuration."""
config.timeouts.multiplier = 1.5 config.timeouts.multiplier = 1.5
checker = UpdateChecker(config) checker = UpdateChecker(config)
mock_response = MagicMock(json = lambda: {"target_commitish": "abc"}) mock_response = MagicMock(json = lambda: {"sha": "abc", "commit": {"author": {"date": "2025-05-18T00:00:00Z"}}})
mock_get = mocker.patch("requests.get", return_value = mock_response) mock_get = mocker.patch("requests.get", return_value = mock_response)
checker._get_release_commit("latest") checker._resolve_commitish("latest")
expected_timeout = config.timeouts.effective("update_check") expected_timeout = config.timeouts.effective("update_check")
assert mock_get.call_args.kwargs["timeout"] == expected_timeout assert mock_get.call_args.kwargs["timeout"] == expected_timeout
def test_get_commit_date(self, config:Config) -> None: def test_resolve_commitish_no_commit(self, config:Config, mocker:"MockerFixture") -> None:
"""Test that the commit date is correctly retrieved from the GitHub API.""" """Test resolving a commit-ish when the API returns no commit data."""
checker = UpdateChecker(config) checker = UpdateChecker(config)
with patch("requests.get", return_value = MagicMock(json = lambda: {"commit": {"author": {"date": "2025-05-18T00:00:00Z"}}})): mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"sha": "abc"}))
assert checker._get_commit_date("e7a3d46") == datetime(2025, 5, 18, tzinfo = timezone.utc) commit_hash, commit_date = checker._resolve_commitish("sha")
assert commit_hash == "abc"
assert commit_date is None
def test_resolve_commitish_logs_warning_on_exception(
self,
config:Config,
caplog:pytest.LogCaptureFixture
) -> None:
"""Test resolving a commit-ish logs a warning when the request fails."""
caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker")
checker = UpdateChecker(config)
with patch("requests.get", side_effect = Exception("boom")):
commit_hash, commit_date = checker._resolve_commitish("sha")
assert commit_hash is None
assert commit_date is None
assert any("Could not resolve commit 'sha': boom" in r.getMessage() for r in caplog.records)
def test_commits_match_short_hash(self, config:Config) -> None:
"""Test that short commit hashes are treated as matching prefixes."""
checker = UpdateChecker(config)
assert checker._commits_match("abc1234", "abc1234def5678") is True
def test_check_for_updates_disabled(self, config:Config) -> None: def test_check_for_updates_disabled(self, config:Config) -> None:
"""Test that the update checker does not check for updates if disabled.""" """Test that the update checker does not check for updates if disabled."""
@@ -125,9 +152,24 @@ class TestUpdateChecker:
def test_check_for_updates_no_local_version(self, config:Config) -> None: def test_check_for_updates_no_local_version(self, config:Config) -> None:
"""Test that the update checker handles the case where the local version cannot be determined.""" """Test that the update checker handles the case where the local version cannot be determined."""
checker = UpdateChecker(config) checker = UpdateChecker(config)
with patch.object(UpdateChecker, "get_local_version", return_value = None): with patch.object(UpdateCheckState, "should_check", return_value = True), \
patch.object(UpdateChecker, "get_local_version", return_value = None):
checker.check_for_updates() # Should not raise exception checker.check_for_updates() # Should not raise exception
def test_check_for_updates_logs_missing_local_version(
self,
config:Config,
caplog:pytest.LogCaptureFixture
) -> None:
"""Test that the update checker logs a warning when the local version is missing."""
caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker")
checker = UpdateChecker(config)
with patch.object(UpdateCheckState, "should_check", return_value = True), \
patch.object(UpdateChecker, "get_local_version", return_value = None):
checker.check_for_updates()
assert any("Could not determine local version." in r.getMessage() for r in caplog.records)
def test_check_for_updates_no_commit_hash(self, config:Config) -> None: def test_check_for_updates_no_commit_hash(self, config:Config) -> None:
"""Test that the update checker handles the case where the commit hash cannot be extracted.""" """Test that the update checker handles the case where the commit hash cannot be extracted."""
checker = UpdateChecker(config) checker = UpdateChecker(config)
@@ -146,24 +188,48 @@ class TestUpdateChecker:
with patch("requests.get", side_effect = Exception("API Error")): with patch("requests.get", side_effect = Exception("API Error")):
checker.check_for_updates() # Should not raise exception checker.check_for_updates() # Should not raise exception
def test_check_for_updates_latest_prerelease_warning(
self,
config:Config,
mocker:"MockerFixture",
caplog:pytest.LogCaptureFixture
) -> None:
"""Test that the update checker warns when latest points to a prerelease."""
caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker")
mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mocker.patch.object(
requests,
"get",
return_value = mocker.Mock(json = lambda: {"tag_name": "latest", "prerelease": True})
)
checker = UpdateChecker(config)
checker.check_for_updates()
expected = "Latest release from GitHub is a prerelease, but 'latest' channel expects a stable release."
assert any(expected in r.getMessage() for r in caplog.records)
def test_check_for_updates_ahead(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: def test_check_for_updates_ahead(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
"""Test that the update checker correctly identifies when the local version is ahead of the latest release.""" """Test that the update checker correctly identifies when the local version is ahead of the latest release."""
caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker") caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker")
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11") mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mocker.patch.object(UpdateChecker, "_get_release_commit", return_value = "e7a3d46")
mocker.patch.object( mocker.patch.object(
UpdateChecker, UpdateChecker,
"_get_commit_date", "_resolve_commitish",
side_effect = [ side_effect = [
datetime(2025, 5, 18, tzinfo = timezone.utc), ("fb00f11", datetime(2025, 5, 18, tzinfo = timezone.utc)),
datetime(2025, 5, 16, tzinfo = timezone.utc) ("e7a3d46", datetime(2025, 5, 16, tzinfo = timezone.utc))
] ]
) )
mocker.patch.object( mocker.patch.object(
requests, requests,
"get", "get",
return_value = mocker.Mock(json = lambda: {"tag_name": "latest", "prerelease": False}) return_value = mocker.Mock(
json = lambda: {"tag_name": "latest", "prerelease": False}
)
) )
mocker.patch.object(UpdateCheckState, "should_check", return_value = True) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
@@ -186,19 +252,20 @@ class TestUpdateChecker:
config.update_check.channel = "preview" config.update_check.channel = "preview"
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11") mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mocker.patch.object(UpdateChecker, "_get_release_commit", return_value = "e7a3d46")
mocker.patch.object( mocker.patch.object(
UpdateChecker, UpdateChecker,
"_get_commit_date", "_resolve_commitish",
side_effect = [ side_effect = [
datetime(2025, 5, 18, tzinfo = timezone.utc), ("fb00f11", datetime(2025, 5, 18, tzinfo = timezone.utc)),
datetime(2025, 5, 16, tzinfo = timezone.utc) ("e7a3d46", datetime(2025, 5, 16, tzinfo = timezone.utc))
] ]
) )
mocker.patch.object( mocker.patch.object(
requests, requests,
"get", "get",
return_value = mocker.Mock(json = lambda: [{"tag_name": "preview", "prerelease": True}]) return_value = mocker.Mock(
json = lambda: [{"tag_name": "preview", "prerelease": True, "draft": False}]
)
) )
mocker.patch.object(UpdateCheckState, "should_check", return_value = True) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
@@ -216,24 +283,48 @@ class TestUpdateChecker:
) )
assert any(expected in r.getMessage() for r in caplog.records) assert any(expected in r.getMessage() for r in caplog.records)
def test_check_for_updates_preview_missing_prerelease(
self,
config:Config,
mocker:"MockerFixture",
caplog:pytest.LogCaptureFixture
) -> None:
"""Test that the update checker warns when no preview prerelease is available."""
caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker")
config.update_check.channel = "preview"
mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mocker.patch.object(
requests,
"get",
return_value = mocker.Mock(json = lambda: [{"tag_name": "v1", "prerelease": False, "draft": False}])
)
checker = UpdateChecker(config)
checker.check_for_updates()
assert any("No prerelease found for 'preview' channel." in r.getMessage() for r in caplog.records)
def test_check_for_updates_behind(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: def test_check_for_updates_behind(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
"""Test that the update checker correctly identifies when the local version is behind the latest release.""" """Test that the update checker correctly identifies when the local version is behind the latest release."""
caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker") caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker")
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11") mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mocker.patch.object(UpdateChecker, "_get_release_commit", return_value = "e7a3d46")
mocker.patch.object( mocker.patch.object(
UpdateChecker, UpdateChecker,
"_get_commit_date", "_resolve_commitish",
side_effect = [ side_effect = [
datetime(2025, 5, 16, tzinfo = timezone.utc), ("fb00f11", datetime(2025, 5, 16, tzinfo = timezone.utc)),
datetime(2025, 5, 18, tzinfo = timezone.utc) ("e7a3d46", datetime(2025, 5, 18, tzinfo = timezone.utc))
] ]
) )
mocker.patch.object( mocker.patch.object(
requests, requests,
"get", "get",
return_value = mocker.Mock(json = lambda: {"tag_name": "latest", "prerelease": False}) return_value = mocker.Mock(
json = lambda: {"tag_name": "latest", "prerelease": False}
)
) )
mocker.patch.object(UpdateCheckState, "should_check", return_value = True) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
@@ -247,21 +338,57 @@ class TestUpdateChecker:
expected = "A new version is available: e7a3d46 from 2025-05-18 00:00:00 UTC (current: 2025+fb00f11 from 2025-05-16 00:00:00 UTC, channel: latest)" expected = "A new version is available: e7a3d46 from 2025-05-18 00:00:00 UTC (current: 2025+fb00f11 from 2025-05-16 00:00:00 UTC, channel: latest)"
assert any(expected in r.getMessage() for r in caplog.records) assert any(expected in r.getMessage() for r in caplog.records)
def test_check_for_updates_logs_release_notes(
self,
config:Config,
mocker:"MockerFixture",
caplog:pytest.LogCaptureFixture
) -> None:
"""Test that release notes are logged when present."""
caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker")
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mocker.patch.object(
UpdateChecker,
"_resolve_commitish",
side_effect = [
("fb00f11", datetime(2025, 5, 16, tzinfo = timezone.utc)),
("e7a3d46", datetime(2025, 5, 18, tzinfo = timezone.utc))
]
)
mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
mocker.patch.object(
requests,
"get",
return_value = mocker.Mock(
json = lambda: {"tag_name": "latest", "prerelease": False, "body": "Release notes here"}
)
)
checker = UpdateChecker(config)
checker.check_for_updates()
assert any("Release notes:\nRelease notes here" in r.getMessage() for r in caplog.records)
def test_check_for_updates_same(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None: def test_check_for_updates_same(self, config:Config, mocker:"MockerFixture", caplog:pytest.LogCaptureFixture) -> None:
"""Test that the update checker correctly identifies when the local version is the same as the latest release.""" """Test that the update checker correctly identifies when the local version is the same as the latest release."""
caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker") caplog.set_level("INFO", logger = "kleinanzeigen_bot.update_checker")
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11") mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mocker.patch.object(UpdateChecker, "_get_release_commit", return_value = "fb00f11")
mocker.patch.object( mocker.patch.object(
UpdateChecker, UpdateChecker,
"_get_commit_date", "_resolve_commitish",
return_value = datetime(2025, 5, 18, tzinfo = timezone.utc) side_effect = [
("fb00f11", datetime(2025, 5, 18, tzinfo = timezone.utc)),
("fb00f11", datetime(2025, 5, 18, tzinfo = timezone.utc))
]
) )
mocker.patch.object( mocker.patch.object(
requests, requests,
"get", "get",
return_value = mocker.Mock(json = lambda: {"tag_name": "latest", "prerelease": False}) return_value = mocker.Mock(
json = lambda: {"tag_name": "latest", "prerelease": False}
)
) )
mocker.patch.object(UpdateCheckState, "should_check", return_value = True) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
@@ -275,6 +402,26 @@ class TestUpdateChecker:
expected = "You are on the latest version: 2025+fb00f11 (compared to fb00f11 in channel latest)" expected = "You are on the latest version: 2025+fb00f11 (compared to fb00f11 in channel latest)"
assert any(expected in r.getMessage() for r in caplog.records) assert any(expected in r.getMessage() for r in caplog.records)
def test_check_for_updates_unknown_channel(
self,
config:Config,
mocker:"MockerFixture",
caplog:pytest.LogCaptureFixture
) -> None:
"""Test that the update checker warns on unknown update channels."""
caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker")
cast(Any, config.update_check).channel = "unknown"
mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mock_get = mocker.patch("requests.get")
checker = UpdateChecker(config)
checker.check_for_updates()
mock_get.assert_not_called()
assert any("Unknown update channel: unknown" in r.getMessage() for r in caplog.records)
def test_check_for_updates_respects_interval_gate( def test_check_for_updates_respects_interval_gate(
self, self,
config:Config, config:Config,
@@ -457,59 +604,40 @@ class TestUpdateChecker:
# Should not raise # Should not raise
state.save(state_file) state.save(state_file)
def test_get_release_commit_no_sha(self, config:Config, mocker:"MockerFixture") -> None: def test_resolve_commitish_no_author(self, config:Config, mocker:"MockerFixture") -> None:
"""Test _get_release_commit with API returning no sha key.""" """Test resolving a commit-ish when the API returns no author key."""
checker = UpdateChecker(config) checker = UpdateChecker(config)
mocker.patch("requests.get", return_value = mocker.Mock(json = dict)) mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"sha": "abc", "commit": {}}))
assert checker._get_release_commit("latest") is None commit_hash, commit_date = checker._resolve_commitish("sha")
assert commit_hash == "abc"
assert commit_date is None
def test_get_release_commit_list_instead_of_dict(self, config:Config, mocker:"MockerFixture") -> None: def test_resolve_commitish_no_date(self, config:Config, mocker:"MockerFixture") -> None:
"""Test _get_release_commit with API returning a list instead of dict.""" """Test resolving a commit-ish when the API returns no date key."""
checker = UpdateChecker(config)
mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"sha": "abc", "commit": {"author": {}}}))
commit_hash, commit_date = checker._resolve_commitish("sha")
assert commit_hash == "abc"
assert commit_date is None
def test_resolve_commitish_list_instead_of_dict(self, config:Config, mocker:"MockerFixture") -> None:
"""Test resolving a commit-ish when the API returns a list instead of dict."""
checker = UpdateChecker(config) checker = UpdateChecker(config)
mocker.patch("requests.get", return_value = mocker.Mock(json = list)) mocker.patch("requests.get", return_value = mocker.Mock(json = list))
assert checker._get_release_commit("latest") is None commit_hash, commit_date = checker._resolve_commitish("sha")
assert commit_hash is None
assert commit_date is None
def test_get_commit_date_no_commit(self, config:Config, mocker:"MockerFixture") -> None: def test_check_for_updates_missing_release_commitish(self, config:Config, mocker:"MockerFixture") -> None:
"""Test _get_commit_date with API returning no commit key.""" """Test check_for_updates handles missing release commit-ish."""
checker = UpdateChecker(config)
mocker.patch("requests.get", return_value = mocker.Mock(json = dict))
assert checker._get_commit_date("sha") is None
def test_get_commit_date_no_author(self, config:Config, mocker:"MockerFixture") -> None:
"""Test _get_commit_date with API returning no author key."""
checker = UpdateChecker(config)
mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"commit": {}}))
assert checker._get_commit_date("sha") is None
def test_get_commit_date_no_date(self, config:Config, mocker:"MockerFixture") -> None:
"""Test _get_commit_date with API returning no date key."""
checker = UpdateChecker(config)
mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"commit": {"author": {}}}))
assert checker._get_commit_date("sha") is None
def test_get_commit_date_list_instead_of_dict(self, config:Config, mocker:"MockerFixture") -> None:
"""Test _get_commit_date with API returning a list instead of dict."""
checker = UpdateChecker(config)
mocker.patch("requests.get", return_value = mocker.Mock(json = list))
assert checker._get_commit_date("sha") is None
def test_check_for_updates_release_commit_exception(self, config:Config, mocker:"MockerFixture") -> None:
"""Test check_for_updates handles exception in _get_release_commit."""
checker = UpdateChecker(config) checker = UpdateChecker(config)
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11") mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mocker.patch.object(UpdateChecker, "_get_release_commit", side_effect = Exception("fail"))
mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
checker.check_for_updates() # Should not raise
def test_check_for_updates_commit_date_exception(self, config:Config, mocker:"MockerFixture") -> None:
"""Test check_for_updates handles exception in _get_commit_date."""
checker = UpdateChecker(config)
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mocker.patch.object(UpdateChecker, "_get_release_commit", return_value = "e7a3d46")
mocker.patch.object(UpdateChecker, "_get_commit_date", side_effect = Exception("fail"))
mocker.patch.object(UpdateCheckState, "should_check", return_value = True) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
mocker.patch(
"requests.get",
return_value = mocker.Mock(json = lambda: {"prerelease": False})
)
checker.check_for_updates() # Should not raise checker.check_for_updates() # Should not raise
def test_check_for_updates_no_releases_empty(self, config:Config, mocker:"MockerFixture") -> None: def test_check_for_updates_no_releases_empty(self, config:Config, mocker:"MockerFixture") -> None:
@@ -531,11 +659,15 @@ class TestUpdateChecker:
caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker") caplog.set_level("WARNING", logger = "kleinanzeigen_bot.update_checker")
mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11") mocker.patch.object(UpdateChecker, "get_local_version", return_value = "2025+fb00f11")
mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11") mocker.patch.object(UpdateChecker, "_get_commit_hash", return_value = "fb00f11")
mocker.patch.object(UpdateChecker, "_get_release_commit", return_value = "e7a3d46") mocker.patch.object(UpdateChecker, "_resolve_commitish", return_value = (None, None))
mocker.patch.object(UpdateChecker, "_get_commit_date", return_value = None)
mocker.patch.object(UpdateCheckState, "should_check", return_value = True) mocker.patch.object(UpdateCheckState, "should_check", return_value = True)
# Patch requests.get to avoid any real HTTP requests # Patch requests.get to avoid any real HTTP requests
mocker.patch("requests.get", return_value = mocker.Mock(json = lambda: {"tag_name": "latest", "prerelease": False})) mocker.patch(
"requests.get",
return_value = mocker.Mock(
json = lambda: {"tag_name": "latest", "prerelease": False}
)
)
checker = UpdateChecker(config) checker = UpdateChecker(config)
checker.check_for_updates() checker.check_for_updates()
assert any("Could not determine commit dates for comparison." in r.getMessage() for r in caplog.records) assert any("Could not determine commit dates for comparison." in r.getMessage() for r in caplog.records)