from __future__ import annotations import hashlib from pathlib import Path import pytest from boltclam.models import UpdateAsset, UpdateFeed, UpdateRelease from boltclam.updates import ( UpdateError, UpdateService, choose_asset, is_newer, parse_checksums, parse_version, ) def test_version_parsing_and_comparison(): assert parse_version("v1.2.3") == ((1, 2, 3), ()) assert parse_version("1.2.3-beta.1") assert is_newer("v1.2.0", "1.1.9") is True assert is_newer("v1.0.0", "1.0.0") is False assert is_newer("v1.0.0", "1.0.0-beta.2") is True assert is_newer("v1.0.0-beta.2", "1.0.0") is False assert is_newer("v1.0.0-beta.2", "1.0.0-beta.1") is True assert is_newer("not-a-version", "1.0.0") is False def _release(tag="v1.2.0", prerelease=False, assets=None) -> UpdateRelease: return UpdateRelease(id="2", tag=tag, name="Test", notes="notes", prerelease=prerelease, published_at="2026-10-20T00:00:00Z", html_url="https://git.example", assets=assets or []) def test_choose_asset_prefers_a_setup_executable(): release = _release(assets=[ UpdateAsset("1", "BoltClam-1.2.0.zip", 5, "application/zip", "u1"), UpdateAsset("2", "BoltClam-1.2.0-Setup.exe", 5, "application/octet-stream", "u2"), ]) assert choose_asset(release).name == "BoltClam-1.2.0-Setup.exe" def test_choose_asset_falls_back_to_zip(): release = _release(assets=[ UpdateAsset("1", "notes.txt", 5, "text/plain", "u1"), UpdateAsset("2", "BoltClam-windows.zip", 5, "application/zip", "u2"), ]) assert choose_asset(release).name == "BoltClam-windows.zip" assert choose_asset(_release(assets=[])) is None def test_feed_from_api_preserves_release_fields(): feed = UpdateFeed.from_api({ "channel": "beta", "latest": {"id": "3", "tag": "v2.0.0-beta", "name": "Beta", "notes": "notes", "prerelease": True, "published_at": "2026-10-22T00:00:00Z", "assets": [{"id": "9", "name": "a.exe", "size": 12, "content_type": "x", "download_url": "u"}]}, "releases": [], "checked_at": "2026-10-25T00:00:00Z", }) assert feed.channel == "beta" assert feed.latest.tag == "v2.0.0-beta" assert feed.latest.assets[0].name == "a.exe" def test_parse_checksums(): digest = "a" * 64 assert parse_checksums(f"{digest} BoltClam-Setup.exe\n") == { "BoltClam-Setup.exe": digest} class FakeApi: def __init__(self, checksum_text: str | None = None, payload: bytes = b"payload"): self.checksum_text = checksum_text self.payload = payload def download_asset(self, url, destination, progress=None): destination = Path(destination) destination.parent.mkdir(parents=True, exist_ok=True) data = self.checksum_text.encode() if self.checksum_text and "SUM" in url else self.payload destination.write_bytes(data) return destination, hashlib.sha256(data).hexdigest() def _download_release(checksum: str) -> UpdateRelease: asset = UpdateAsset("1", "BoltClam-Setup.exe", 7, "application/octet-stream", "https://api.example/asset") checksum_asset = UpdateAsset("2", "SHA256SUMS.txt", 100, "text/plain", "https://api.example/SUM") return _release(assets=[asset, checksum_asset]) def test_download_verifies_a_matching_checksum(isolated_env): payload = b"payload" digest = hashlib.sha256(payload).hexdigest() release = _download_release(digest) service = UpdateService(FakeApi(f"{digest} BoltClam-Setup.exe\n", payload)) result = service.download(release, release.assets[0]) assert result.path.read_bytes() == payload assert result.verified is True assert result.expected_sha256 == digest def test_download_rejects_a_bad_checksum(isolated_env): release = _download_release("b" * 64) service = UpdateService(FakeApi(f"{'b' * 64} BoltClam-Setup.exe\n", b"payload")) with pytest.raises(UpdateError): service.download(release, release.assets[0]) def test_check_returns_the_feed(isolated_env): class Api: def get_updates(self, channel, force=False): return {"channel": channel, "latest": None, "releases": []} feed = UpdateService(Api(), "beta").check() assert feed.channel == "beta" assert feed.latest is None