webdrop-bridge/tests/unit/test_updater.py
claudi d408c3a2de test: Add comprehensive unit tests for UpdateManager (20 tests, 77% coverage)
- Version parsing and comparison tests
- Cache management with TTL validation
- API fetching with network error handling
- Check for updates (with caching)
- Download update with checksum file handling
- Checksum verification (match/mismatch/missing)
- Platform-aware installer launching (Windows MSI, macOS DMG)
- All 20 tests passing with proper async/await support
- Installed pytest-asyncio for async test support
- Updated copilot instructions to document .venv environment
2026-01-29 08:15:35 +01:00

370 lines
12 KiB
Python

"""Tests for the UpdateManager auto-update system."""
import asyncio
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from webdrop_bridge.core.updater import Release, UpdateManager
@pytest.fixture
def update_manager(tmp_path):
"""Create UpdateManager instance with temp directory."""
return UpdateManager(current_version="0.0.1", config_dir=tmp_path)
@pytest.fixture
def sample_release():
"""Sample release data from API."""
return {
"tag_name": "v0.0.2",
"name": "WebDropBridge v0.0.2",
"version": "0.0.2",
"body": "## Changes\n- Bug fixes",
"assets": [
{
"name": "WebDropBridge.exe",
"browser_download_url": "https://example.com/WebDropBridge.exe",
},
{
"name": "WebDropBridge.exe.sha256",
"browser_download_url": "https://example.com/WebDropBridge.exe.sha256",
},
],
"published_at": "2026-01-29T10:00:00Z",
}
class TestVersionParsing:
"""Test semantic version parsing."""
def test_parse_valid_version(self, update_manager):
"""Test parsing valid version string."""
assert update_manager._parse_version("1.2.3") == (1, 2, 3)
assert update_manager._parse_version("v1.2.3") == (1, 2, 3)
assert update_manager._parse_version("0.0.1") == (0, 0, 1)
def test_parse_invalid_version(self, update_manager):
"""Test parsing invalid version raises error."""
with pytest.raises(ValueError):
update_manager._parse_version("1.2") # Too few parts
with pytest.raises(ValueError):
update_manager._parse_version("a.b.c") # Non-numeric
with pytest.raises(ValueError):
update_manager._parse_version("") # Empty string
def test_is_newer_version_true(self, update_manager):
"""Test version comparison when newer version exists."""
assert update_manager._is_newer_version("0.0.2")
assert update_manager._is_newer_version("0.1.0")
assert update_manager._is_newer_version("1.0.0")
def test_is_newer_version_false(self, update_manager):
"""Test version comparison when version is not newer."""
assert not update_manager._is_newer_version("0.0.1") # Same
assert not update_manager._is_newer_version("0.0.0") # Older
class TestCaching:
"""Test update cache management."""
def test_save_and_load_cache(self, update_manager, sample_release):
"""Test saving and loading cache."""
# Save cache
update_manager._save_cache(sample_release)
assert update_manager.cache_file.exists()
# Load cache
cached = update_manager._load_cache()
assert cached is not None
assert cached["release"]["tag_name"] == "v0.0.2"
def test_cache_expiration(self, update_manager, sample_release):
"""Test cache expiration after TTL."""
# Save cache
update_manager._save_cache(sample_release)
# Manually set old timestamp
with open(update_manager.cache_file) as f:
cache_data = json.load(f)
cache_data["timestamp"] = "2020-01-01T00:00:00"
with open(update_manager.cache_file, "w") as f:
json.dump(cache_data, f)
# Cache should be expired
cached = update_manager._load_cache()
assert cached is None
assert not update_manager.cache_file.exists()
def test_corrupted_cache_cleanup(self, update_manager):
"""Test corrupted cache is cleaned up."""
# Write invalid JSON
update_manager.cache_file.write_text("invalid json")
# Attempt to load
cached = update_manager._load_cache()
assert cached is None
assert not update_manager.cache_file.exists()
class TestFetching:
"""Test API fetching."""
@patch("webdrop_bridge.core.updater.urlopen")
def test_fetch_release_success(self, mock_urlopen, update_manager):
"""Test successful release fetch."""
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(
{
"tag_name": "v0.0.2",
"name": "WebDropBridge v0.0.2",
"body": "Release notes",
"assets": [],
"published_at": "2026-01-29T10:00:00Z",
}
).encode()
mock_response.__enter__.return_value = mock_response
mock_urlopen.return_value = mock_response
result = update_manager._fetch_release()
assert result is not None
assert result["tag_name"] == "v0.0.2"
assert result["version"] == "0.0.2"
@patch("webdrop_bridge.core.updater.urlopen")
def test_fetch_release_network_error(self, mock_urlopen, update_manager):
"""Test fetch handles network errors."""
from urllib.error import URLError
mock_urlopen.side_effect = URLError("Connection failed")
result = update_manager._fetch_release()
assert result is None
class TestCheckForUpdates:
"""Test checking for updates."""
@pytest.mark.asyncio
@patch.object(UpdateManager, "_fetch_release")
async def test_check_for_updates_newer_available(
self, mock_fetch, update_manager, sample_release
):
"""Test detecting available update."""
mock_fetch.return_value = sample_release
release = await update_manager.check_for_updates()
assert release is not None
assert release.version == "0.0.2"
@pytest.mark.asyncio
@patch.object(UpdateManager, "_fetch_release")
async def test_check_for_updates_no_update(
self, mock_fetch, update_manager
):
"""Test no update available."""
mock_fetch.return_value = {
"tag_name": "v0.0.1",
"name": "WebDropBridge v0.0.1",
"version": "0.0.1",
"body": "",
"assets": [],
"published_at": "2026-01-29T10:00:00Z",
}
release = await update_manager.check_for_updates()
assert release is None
@pytest.mark.asyncio
@patch.object(UpdateManager, "_fetch_release")
async def test_check_for_updates_uses_cache(
self, mock_fetch, update_manager, sample_release
):
"""Test cache is used on subsequent calls."""
mock_fetch.return_value = sample_release
# First call
release1 = await update_manager.check_for_updates()
assert release1 is not None
# Second call should use cache (reset mock)
mock_fetch.reset_mock()
release2 = await update_manager.check_for_updates()
# Fetch should not be called again
mock_fetch.assert_not_called()
assert release2 is not None # Cache returns same release
class TestDownloading:
"""Test update downloading."""
@pytest.mark.asyncio
async def test_download_update_success(
self, update_manager, tmp_path
):
"""Test successful update download."""
# Create release with .msi asset
release_data = {
"tag_name": "v0.0.2",
"name": "WebDropBridge v0.0.2",
"version": "0.0.2",
"body": "Release notes",
"assets": [
{
"name": "WebDropBridge-1.0.0-Setup.msi",
"browser_download_url": "https://example.com/WebDropBridge.msi",
}
],
"published_at": "2026-01-29T10:00:00Z",
}
with patch.object(UpdateManager, "_download_file") as mock_download:
mock_download.return_value = True
release = Release(**release_data)
result = await update_manager.download_update(release, tmp_path)
assert result is not None
assert result.name == "WebDropBridge-1.0.0-Setup.msi"
@pytest.mark.asyncio
@patch.object(UpdateManager, "_download_file")
async def test_download_update_no_installer(
self, mock_download, update_manager
):
"""Test download fails when no installer in release."""
release_data = {
"tag_name": "v0.0.2",
"name": "Test",
"version": "0.0.2",
"body": "",
"assets": [
{
"name": "README.txt",
"browser_download_url": "https://example.com/README.txt",
}
],
"published_at": "2026-01-29T10:00:00Z",
}
release = Release(**release_data)
result = await update_manager.download_update(release)
assert result is None
class TestChecksumVerification:
"""Test checksum verification."""
@pytest.mark.asyncio
@patch.object(UpdateManager, "_download_checksum")
async def test_verify_checksum_success(
self, mock_download_checksum, update_manager, sample_release, tmp_path
):
"""Test successful checksum verification."""
# Create test file
test_file = tmp_path / "test.exe"
test_file.write_bytes(b"test content")
# Calculate actual checksum
import hashlib
sha256 = hashlib.sha256(b"test content").hexdigest()
mock_download_checksum.return_value = sha256
release = Release(**sample_release)
result = await update_manager.verify_checksum(test_file, release)
assert result is True
@pytest.mark.asyncio
@patch.object(UpdateManager, "_download_checksum")
async def test_verify_checksum_mismatch(
self, mock_download_checksum, update_manager, sample_release, tmp_path
):
"""Test checksum verification fails on mismatch."""
test_file = tmp_path / "test.exe"
test_file.write_bytes(b"test content")
# Return wrong checksum
mock_download_checksum.return_value = "0" * 64
release = Release(**sample_release)
result = await update_manager.verify_checksum(test_file, release)
assert result is False
@pytest.mark.asyncio
async def test_verify_checksum_no_checksum_file(
self, update_manager, tmp_path
):
"""Test verification skipped when no checksum file in release."""
test_file = tmp_path / "test.exe"
test_file.write_bytes(b"test content")
release_data = {
"tag_name": "v0.0.2",
"name": "Test",
"version": "0.0.2",
"body": "",
"assets": [
{
"name": "WebDropBridge.exe",
"browser_download_url": "https://example.com/WebDropBridge.exe",
}
],
"published_at": "2026-01-29T10:00:00Z",
}
release = Release(**release_data)
result = await update_manager.verify_checksum(test_file, release)
# Should return True (skip verification)
assert result is True
class TestInstallation:
"""Test update installation."""
@patch("subprocess.Popen")
@patch("platform.system")
def test_install_update_windows(
self, mock_platform, mock_popen, update_manager, tmp_path
):
"""Test installation on Windows."""
mock_platform.return_value = "Windows"
installer = tmp_path / "WebDropBridge.msi"
installer.touch()
result = update_manager.install_update(installer)
assert result is True
mock_popen.assert_called_once()
@patch("subprocess.Popen")
@patch("platform.system")
def test_install_update_macos(
self, mock_platform, mock_popen, update_manager, tmp_path
):
"""Test installation on macOS."""
mock_platform.return_value = "Darwin"
installer = tmp_path / "WebDropBridge.dmg"
installer.touch()
result = update_manager.install_update(installer)
assert result is True
mock_popen.assert_called_once_with(["open", str(installer)])
def test_install_update_file_not_found(self, update_manager):
"""Test installation fails when file not found."""
result = update_manager.install_update(Path("/nonexistent/file.msi"))
assert result is False