Files
voculinux/tests/test_update_checker.py
T
xavierk 474b522281
CI / security (pull_request) Successful in 1m14s
CI / lint (pull_request) Successful in 21s
CI / ai-review (pull_request) Successful in 9s
test(update): isolate the update checker tests from a packaged marker
The update checker tests now patch the packaged-install check, so they do
not depend on /usr/share/voculinux/packaged being absent on the host. The
model ladder test for an unrequested cuda-without-toolkit order is dropped.

Refs #16, #20
2026-11-06 20:02:44 +05:30

407 lines
16 KiB
Python

"""Tests for the Voculinux release checker (Gitea, never upstream VocaHQ)."""
import logging
from unittest.mock import MagicMock, patch
import pytest
from vocalinux.utils.update_checker import (
ReleaseInfo,
build_channel,
fetch_latest_release,
format_release_notes,
is_newer_version,
is_trusted_release_url,
is_update_available,
normalize_channel,
normalize_version,
)
@pytest.fixture(autouse=True)
def _unpackaged_install(monkeypatch):
"""Keep these tests independent of a packaged marker on the host (ticket #27)."""
monkeypatch.setattr("vocalinux.utils.update_checker.is_packaged_install", lambda: False)
class TestNormalizeAndCompare:
def test_normalize_version_strips_v_prefix(self):
assert normalize_version("v0.15.0") == "0.15.0"
assert normalize_version("V0.15.0") == "0.15.0"
def test_equal_versions_are_not_newer(self):
assert not is_newer_version("0.15.0", "0.15.0")
assert not is_newer_version("v0.15.0", "0.15.0")
def test_numeric_ordering(self):
assert is_newer_version("0.9.0", "0.10.0")
assert is_newer_version("0.15.0", "0.15.1")
assert not is_newer_version("1.0.0", "0.99.0")
def test_stable_outranks_prerelease(self):
assert is_newer_version("0.15.0-beta", "0.15.0")
assert not is_newer_version("0.15.0", "0.15.0-beta")
def test_dot_prerelease_suffix(self):
assert is_newer_version("0.15.0.rc1", "0.15.0")
assert not is_newer_version("0.15.0", "0.15.0.rc1")
def test_build_metadata_ignored(self):
assert not is_newer_version("0.15.0+flatpak", "0.15.0")
class TestChannels:
def test_build_channel_from_version(self) -> None:
cases = (
("0.18.3", "stable"),
("v0.18.3", "stable"),
("0.18.3+flatpak", "stable"),
("0.14.0-beta", "stable"),
("0.18.3.dev20261020+4799f06", "nightly"),
("0.18.3.dev20261020", "nightly"),
)
for version, expected in cases:
assert build_channel(version) == expected
def test_build_channel_reads_version_module_at_call_time(self) -> None:
with patch(
"vocalinux.version.__version__",
"0.18.3.dev20261020+4799f06",
):
assert build_channel() == "nightly"
def test_normalize_channel(self):
assert normalize_channel("stable") == "stable"
assert normalize_channel("NIGHTLY") == "nightly"
assert normalize_channel("nope") == "stable"
def test_stable_update_uses_semver(self):
release = ReleaseInfo(
tag_name="v0.15.0",
version="0.15.0",
name="v0.15.0",
html_url="https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
body="",
published_at="2026-08-02T00:00:00Z",
prerelease=False,
channel="stable",
)
assert is_update_available("0.14.0", release, "stable")
assert not is_update_available("0.15.0", release, "stable")
def test_nightly_update_compares_dates(self):
release = ReleaseInfo(
tag_name="nightly-2026-08-02",
version="nightly-2026-08-02",
name="Nightly Build - 0.14.2.dev20260802+27ecf88",
html_url="https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-02",
body="",
published_at="2026-08-02T00:00:00Z",
prerelease=True,
channel="nightly",
)
assert is_update_available("nightly-2026-08-01", release, "nightly")
assert not is_update_available("nightly-2026-08-02", release, "nightly")
# Installed nightlies use .devYYYYMMDD[+sha], not the GitHub tag name.
assert not is_update_available("0.14.2.dev20260802+27ecf88", release, "nightly")
assert is_update_available("0.14.2.dev20260801+aaaaaaa", release, "nightly")
# Stable installs following nightly should be offered the latest nightly.
assert is_update_available("0.15.0", release, "nightly")
class TestTrustedUrls:
def test_accepts_project_urls(self):
assert is_trusted_release_url("https://git.bongbetic.com/xavierk/voculinux")
assert is_trusted_release_url(
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0"
)
def test_rejects_upstream_and_other_urls(self):
assert not is_trusted_release_url("https://github.com/VocaHQ/vocalinux")
assert not is_trusted_release_url("https://github.com/VocaHQ/vocalinux/releases/tag/v1")
assert not is_trusted_release_url("https://github.com/jatinkrmalik/vocalinux")
assert not is_trusted_release_url("https://git.bongbetic.com/xavierk/other")
assert not is_trusted_release_url("https://evil.example/xavierk/voculinux")
assert not is_trusted_release_url("")
def test_rejects_non_https_urls(self):
assert not is_trusted_release_url("http://git.bongbetic.com/xavierk/voculinux")
class TestFormatReleaseNotes:
def test_empty_body(self):
assert "No release notes" in format_release_notes("")
def test_keeps_code_block_contents(self):
body = "Install with:\n```bash\ncurl -fsSL https://example.com | bash\n```\nDone"
notes = format_release_notes(body)
assert "curl -fsSL https://example.com | bash" in notes
assert "```" not in notes
def test_strips_images_and_keeps_links(self):
body = 'See [docs](https://example.com) <img src="x.png" alt="x" />'
notes = format_release_notes(body)
assert "docs (https://example.com)" in notes
assert "<img" not in notes
class TestFetchLatestRelease:
def test_returns_none_on_network_error(self):
mock_requests = MagicMock()
mock_requests.get.side_effect = Exception("offline")
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
assert fetch_latest_release() is None
def test_parses_release_payload(self):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"tag_name": "v0.15.0",
"name": "v0.15.0",
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
"body": "Notes",
"published_at": "2026-08-02T22:29:58Z",
"prerelease": False,
}
mock_requests = MagicMock()
mock_requests.get.return_value = mock_response
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release()
assert isinstance(release, ReleaseInfo)
assert release.version == "0.15.0"
assert release.html_url.endswith("/v0.15.0")
mock_requests.get.assert_called_once()
api_url = mock_requests.get.call_args[0][0]
assert api_url == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases/latest"
def test_drops_untrusted_html_url(self):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"tag_name": "v0.15.0",
"name": "v0.15.0",
"html_url": "https://evil.example/download",
"body": "Notes",
"published_at": "2026-08-02T22:29:58Z",
"prerelease": False,
}
mock_requests = MagicMock()
mock_requests.get.return_value = mock_response
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release()
assert release is not None
assert release.html_url == ""
def test_nightly_channel_picks_nightly_tag(self):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{
"tag_name": "v0.15.0",
"name": "v0.15.0",
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
"body": "Stable",
"published_at": "2026-08-02T22:29:58Z",
"prerelease": False,
"draft": False,
},
{
"tag_name": "nightly-2026-08-02",
"name": "nightly-2026-08-02",
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-02",
"body": "Nightly",
"published_at": "2026-08-02T05:00:00Z",
"prerelease": True,
"draft": False,
},
]
mock_requests = MagicMock()
mock_requests.get.return_value = mock_response
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release(channel="nightly")
assert release is not None
assert release.tag_name == "nightly-2026-08-02"
assert release.channel == "nightly"
mock_requests.get.assert_called_once()
assert mock_requests.get.call_args[0][0].endswith("/releases")
def test_nightly_channel_pages_past_non_nightly_releases(self):
"""Newest nightly can sit beyond the first /releases page."""
page1 = [
{
"tag_name": f"v0.15.{i}",
"name": f"v0.15.{i}",
"html_url": f"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.{i}",
"body": "",
"published_at": "2026-08-03T00:00:00Z",
"prerelease": False,
"draft": False,
}
for i in range(30, 0, -1)
]
page2 = [
{
"tag_name": "nightly-2026-08-01",
"name": "nightly-2026-08-01",
"html_url": (
"https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-01"
),
"body": "Nightly",
"published_at": "2026-08-01T05:00:00Z",
"prerelease": True,
"draft": False,
}
]
responses = []
for payload in (page1, page2):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = payload
responses.append(mock_response)
mock_requests = MagicMock()
mock_requests.get.side_effect = responses
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release(channel="nightly")
assert release is not None
assert release.tag_name == "nightly-2026-08-01"
assert mock_requests.get.call_count == 2
assert mock_requests.get.call_args_list[0].kwargs["params"]["page"] == 1
assert mock_requests.get.call_args_list[1].kwargs["params"]["page"] == 2
def test_stable_404_means_no_release_and_warns(self, caplog):
"""A missing or private repo answers 404 anonymously: no release, one warning."""
mock_requests = _requests_mock([_mock_response(404)])
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
with patch.dict("sys.modules", {"requests": mock_requests}):
assert fetch_latest_release() is None
mock_requests.get.assert_called_once()
assert len(_checker_warnings(caplog)) == 1
@pytest.mark.parametrize("status_code", [403, 429])
def test_stable_403_and_429_return_none_without_a_second_request(self, caplog, status_code):
mock_requests = _requests_mock([_mock_response(status_code)])
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
with patch.dict("sys.modules", {"requests": mock_requests}):
assert fetch_latest_release() is None
mock_requests.get.assert_called_once()
assert _checker_warnings(caplog) == []
def test_prerelease_tag_is_compared_not_filtered(self):
"""Existing comparison rules apply to a pre-release tag: no new filter."""
payload = {
"tag_name": "v0.18.0-beta.1",
"name": "v0.18.0-beta.1",
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.18.0-beta.1",
"body": "Beta",
"published_at": "2026-08-02T22:29:58Z",
"prerelease": True,
}
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release()
assert release is not None
assert release.prerelease is True
assert release.version == "0.18.0-beta.1"
assert is_update_available("0.17.0", release) is True
assert is_update_available("0.18.0", release) is False
def test_nightly_api_403_returns_none(self, caplog):
api_response = _mock_response(403)
mock_requests = _requests_mock([api_response])
caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
with patch.dict("sys.modules", {"requests": mock_requests}):
assert fetch_latest_release(channel="nightly") is None
mock_requests.get.assert_called_once()
requested = mock_requests.get.call_args[0][0]
assert requested == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases"
assert _checker_warnings(caplog) == []
def test_stable_lookup_parses_gitea_payload_from_gitea_only(self, caplog):
"""The stable lookup reads Gitea's JSON and never contacts GitHub."""
payload = {
"tag_name": "v0.18.6",
"name": "v0.18.6",
"html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.18.6",
"body": "Notes",
"published_at": "2026-11-06T10:00:00Z",
"prerelease": False,
}
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
with patch.dict("sys.modules", {"requests": mock_requests}):
release = fetch_latest_release()
assert release is not None
assert release.tag_name == "v0.18.6"
assert release.html_url == payload["html_url"]
assert release.body == "Notes"
mock_requests.get.assert_called_once()
first = mock_requests.get.call_args
assert (
first[0][0]
== "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases/latest"
)
assert first.kwargs["headers"]["Accept"] == "application/json"
assert first.kwargs["headers"]["User-Agent"] == "Voculinux-UpdateChecker"
assert "Authorization" not in first.kwargs["headers"]
@pytest.mark.parametrize("channel", ["stable", "nightly"])
def test_source_install_never_queries_upstream(self, channel):
payload = [] if channel == "nightly" else {"tag_name": "v0.18.6"}
mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
with patch.dict("sys.modules", {"requests": mock_requests}):
fetch_latest_release(channel=channel)
for call in mock_requests.get.call_args_list:
assert call[0][0].startswith(
"https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/"
)
def test_packaged_install_makes_no_network_request(self):
"""Packaged installs get updates from xbps, so the checker stays off the network."""
mock_requests = _requests_mock([_mock_response(200, json_value={"tag_name": "v9.9.9"})])
with (
patch("vocalinux.utils.update_checker.is_packaged_install", return_value=True),
patch.dict("sys.modules", {"requests": mock_requests}),
):
assert fetch_latest_release() is None
assert fetch_latest_release(channel="nightly") is None
mock_requests.get.assert_not_called()
def _mock_response(status_code, json_value=None, url=""):
mock_response = MagicMock()
mock_response.status_code = status_code
mock_response.url = url
mock_response.json.return_value = json_value
return mock_response
def _requests_mock(side_effect):
mock_requests = MagicMock()
mock_requests.get.side_effect = side_effect
mock_requests.exceptions.RequestException = Exception
return mock_requests
def _checker_warnings(caplog):
return [
record
for record in caplog.records
if record.name == "vocalinux.utils.update_checker" and record.levelno >= logging.WARNING
]