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
This commit is contained in:
parent
79afa91490
commit
d408c3a2de
1 changed files with 370 additions and 0 deletions
370
tests/unit/test_updater.py
Normal file
370
tests/unit/test_updater.py
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
"""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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue