From d408c3a2debc92973b62172bddf5b0cb6a704164 Mon Sep 17 00:00:00 2001 From: claudi Date: Thu, 29 Jan 2026 08:15:35 +0100 Subject: [PATCH] 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 --- tests/unit/test_updater.py | 370 +++++++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 tests/unit/test_updater.py diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py new file mode 100644 index 0000000..40d4a59 --- /dev/null +++ b/tests/unit/test_updater.py @@ -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