From eb7ffe99698091564641324feda02eefb090f031 Mon Sep 17 00:00:00 2001 From: claudi Date: Thu, 29 Jan 2026 09:01:58 +0100 Subject: [PATCH] feat: Update DEVELOPMENT_PLAN with deliverables and acceptance criteria; add integration tests for update flow --- DEVELOPMENT_PLAN.md | 50 +++---- tests/integration/test_update_flow.py | 203 ++++++++++++++++++++++++++ 2 files changed, 228 insertions(+), 25 deletions(-) create mode 100644 tests/integration/test_update_flow.py diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md index 7acc85c..ddcd6a3 100644 --- a/DEVELOPMENT_PLAN.md +++ b/DEVELOPMENT_PLAN.md @@ -784,20 +784,20 @@ AUTO_UPDATE_NOTIFY=true - Security: HTTPS-only, checksum verification **Deliverables:** -- [ ] `src/webdrop_bridge/core/updater.py` - Update manager -- [ ] Menu item for manual update check -- [ ] Update notification dialog -- [ ] Unit tests for update checking and downloading -- [ ] Integration with Forgejo API +- [x] `src/webdrop_bridge/core/updater.py` - Update manager (COMPLETE) +- [x] Unit tests for update checking and downloading (20 tests passing) +- [x] Integration with Forgejo API (async queries working) +- [ ] Menu item for manual update check (TODO: Priority 2) +- [ ] Update notification dialog (TODO: Priority 2) **Acceptance Criteria:** -- Can query Forgejo releases API -- Detects new versions correctly -- Downloads and verifies checksums -- Prompts user for restart -- Manual check works from menu -- Gracefully handles network errors -- Version comparison uses semantic versioning +- [x] Can query Forgejo releases API +- [x] Detects new versions correctly +- [x] Downloads and verifies checksums +- [x] Gracefully handles network errors +- [x] Version comparison uses semantic versioning +- [ ] Manual check works from menu (TODO: Priority 2) +- [ ] Prompts user for restart (TODO: Priority 2) --- @@ -872,21 +872,21 @@ Help Menu - Cancel-safe download handling **Deliverables:** -- [ ] `src/webdrop_bridge/ui/update_manager_ui.py` - UI dialogs -- [ ] Update menu item integration -- [ ] Status bar update indicator -- [ ] All dialogs with error handling -- [ ] Tests for UI interactions +- [x] `src/webdrop_bridge/ui/update_manager_ui.py` - UI dialogs (skeleton complete) +- [x] Status bar update indicator (COMPLETE - emoji + status text) +- [ ] Update menu item integration (TODO: Priority 2) +- [ ] All dialogs with signal hookups (TODO: Priority 2) +- [ ] Tests for UI interactions (TODO: Priority 2) **Acceptance Criteria:** -- Menu item works and triggers check -- All dialogs display correctly -- Progress shown during download -- Restart options work -- Network errors handled gracefully -- Cancel operations work safely -- Status bar updates in real-time -- No blocking operations on main thread +- [x] Status bar updates in real-time (DONE) +- [x] No blocking operations on main thread (async/await) +- [x] Network errors handled gracefully (try/except with logging) +- [ ] Menu item works and triggers check (TODO: Priority 2) +- [ ] All dialogs display correctly (TODO: Priority 2) +- [ ] Progress shown during download (TODO: Priority 2) +- [ ] Restart options work (TODO: Priority 2) +- [ ] Cancel operations work safely (TODO: Priority 2) --- diff --git a/tests/integration/test_update_flow.py b/tests/integration/test_update_flow.py new file mode 100644 index 0000000..5dc4ae6 --- /dev/null +++ b/tests/integration/test_update_flow.py @@ -0,0 +1,203 @@ +"""Integration tests for the complete update flow.""" + +import asyncio +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from webdrop_bridge.config import Config +from webdrop_bridge.core.updater import Release, UpdateManager + + +@pytest.fixture +def config(tmp_path): + """Create test config.""" + return Config( + app_name="Test WebDrop", + app_version="0.0.1", + log_level="INFO", + log_file=None, + allowed_roots=[tmp_path], + allowed_urls=[], + webapp_url="file:///./webapp/index.html", + window_width=800, + window_height=600, + enable_logging=False, + ) + + +@pytest.fixture +def mock_forgejo_response(): + """Mock Forgejo API response - formatted as returned by _fetch_release.""" + return { + "tag_name": "v0.0.2", + "name": "WebDropBridge v0.0.2", + "version": "0.0.2", # _fetch_release adds this + "body": "## Bug Fixes\n- Fixed drag and drop on macOS", + "assets": [ + { + "name": "WebDropBridge.exe", + "browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.0.2/WebDropBridge.exe", + }, + { + "name": "WebDropBridge.exe.sha256", + "browser_download_url": "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.0.2/WebDropBridge.exe.sha256", + }, + ], + "published_at": "2026-01-29T10:00:00Z", + } + + +class TestUpdateFlowIntegration: + """Integration tests for the complete update check flow.""" + + @pytest.mark.asyncio + async def test_full_update_check_flow(self, config, mock_forgejo_response, tmp_path): + """Test complete flow: API query -> version check -> signal.""" + manager = UpdateManager( + current_version=config.app_version, + config_dir=tmp_path + ) + + # Mock the API fetch + with patch.object(manager, "_fetch_release") as mock_fetch: + mock_fetch.return_value = mock_forgejo_response + + # Run check + release = await manager.check_for_updates() + + # Verify API was called + mock_fetch.assert_called_once() + + # Verify we got a release + assert release is not None + assert release.version == "0.0.2" + assert release.tag_name == "v0.0.2" + assert len(release.assets) == 2 + + @pytest.mark.asyncio + async def test_update_check_with_cache(self, config, mock_forgejo_response, tmp_path): + """Test that cache is used on second call.""" + manager = UpdateManager( + current_version=config.app_version, + config_dir=tmp_path + ) + + with patch.object(manager, "_fetch_release") as mock_fetch: + mock_fetch.return_value = mock_forgejo_response + + # First call - should fetch from API + release1 = await manager.check_for_updates() + assert mock_fetch.call_count == 1 + + # Second call - should use cache + release2 = await manager.check_for_updates() + assert mock_fetch.call_count == 1 # Still 1, cache used + + # Verify both got same result + assert release1.version == release2.version + + @pytest.mark.asyncio + async def test_update_check_no_newer_version(self, config, tmp_path): + """Test that no update available when latest is same version.""" + manager = UpdateManager( + current_version="0.0.2", + config_dir=tmp_path + ) + + response = { + "tag_name": "v0.0.2", + "name": "WebDropBridge v0.0.2", + "body": "", + "assets": [], + "published_at": "2026-01-29T10:00:00Z", + } + + with patch.object(manager, "_fetch_release") as mock_fetch: + mock_fetch.return_value = response + + release = await manager.check_for_updates() + + # Should return None since version is not newer + assert release is None + + @pytest.mark.asyncio + async def test_update_check_network_error(self, config, tmp_path): + """Test graceful handling of network errors.""" + manager = UpdateManager( + current_version=config.app_version, + config_dir=tmp_path + ) + + # Mock network error + with patch.object(manager, "_fetch_release") as mock_fetch: + mock_fetch.side_effect = Exception("Connection timeout") + + release = await manager.check_for_updates() + + # Should return None on error + assert release is None + + @pytest.mark.asyncio + async def test_version_parsing_in_api_response(self, config, tmp_path): + """Test that version is correctly extracted from tag_name.""" + manager = UpdateManager( + current_version=config.app_version, + config_dir=tmp_path + ) + + # API returns version with 'v' prefix - but _fetch_release processes it + response = { + "tag_name": "v1.2.3", + "name": "Release", + "version": "1.2.3", # _fetch_release adds this + "body": "", + "assets": [], + "published_at": "2026-01-29T10:00:00Z", + } + + with patch.object(manager, "_fetch_release") as mock_fetch: + mock_fetch.return_value = response + + release = await manager.check_for_updates() + + # Version should be extracted correctly (without 'v') + assert release.version == "1.2.3" + + @pytest.mark.asyncio + async def test_asset_parsing_in_release(self, config, mock_forgejo_response, tmp_path): + """Test that release assets are correctly parsed.""" + manager = UpdateManager( + current_version=config.app_version, + config_dir=tmp_path + ) + + with patch.object(manager, "_fetch_release") as mock_fetch: + mock_fetch.return_value = mock_forgejo_response + + release = await manager.check_for_updates() + + # Should have both exe and checksum + assert len(release.assets) == 2 + asset_names = [a["name"] for a in release.assets] + assert "WebDropBridge.exe" in asset_names + assert "WebDropBridge.exe.sha256" in asset_names + + @pytest.mark.asyncio + async def test_changelog_preserved(self, config, mock_forgejo_response, tmp_path): + """Test that release notes/changelog are preserved.""" + manager = UpdateManager( + current_version=config.app_version, + config_dir=tmp_path + ) + + with patch.object(manager, "_fetch_release") as mock_fetch: + mock_fetch.return_value = mock_forgejo_response + + release = await manager.check_for_updates() + + # Changelog should be available + assert release.body == mock_forgejo_response["body"] + assert "Bug Fixes" in release.body