"""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