diff --git a/synodic_client/application/update_controller.py b/synodic_client/application/update_controller.py index 0204841..e1cf84a 100644 --- a/synodic_client/application/update_controller.py +++ b/synodic_client/application/update_controller.py @@ -440,8 +440,8 @@ def _apply_update(self, *, silent: bool = False) -> None: # the next launch. sync_startup(sys.executable, auto_start=self._store.config.auto_start) - self._pending_version = None self._client.apply_update_on_exit(restart=True, silent=silent) + self._pending_version = None logger.info('Update scheduled — restarting application') self._app.quit() except Exception as e: diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 61761d3..34c8d2a 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -184,7 +184,12 @@ def check_for_update(self) -> UpdateInfo: latest_version=latest, _velopack_info=velopack_info, ) - self._state = UpdateState.UPDATE_AVAILABLE + # Only advance to UPDATE_AVAILABLE if we haven't already + # moved past it. A periodic re-check that discovers the + # same release must not regress DOWNLOADED → UPDATE_AVAILABLE, + # which would cause apply_update_on_exit() to reject the update. + if self._state not in (UpdateState.DOWNLOADED, UpdateState.APPLYING, UpdateState.APPLIED): + self._state = UpdateState.UPDATE_AVAILABLE logger.info('Update available: %s -> %s', self._current_version, latest) else: self._update_info = UpdateInfo( diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 887a0cb..ac932c2 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -227,6 +227,38 @@ def test_check_non_404_http_error_is_failed(updater: Updater) -> None: assert info.available is False assert updater.state == UpdateState.FAILED + @staticmethod + def test_check_preserves_downloaded_state(updater: Updater) -> None: + """Re-checking after download must not regress DOWNLOADED → UPDATE_AVAILABLE. + + Regression test: when the periodic auto-check timer fires between + download completion and the user clicking "Restart Now", the state + was incorrectly reset to UPDATE_AVAILABLE, causing apply_update_on_exit + to reject the update with "No downloaded update to apply". + """ + mock_target = MagicMock(spec=velopack.VelopackAsset) + mock_target.Version = '2.0.0' + mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) + mock_velopack_info.TargetFullRelease = mock_target + + mock_manager = MagicMock(spec=velopack.UpdateManager) + mock_manager.check_for_updates.return_value = mock_velopack_info + + # Simulate: download already completed + updater._state = UpdateState.DOWNLOADED + updater._update_info = UpdateInfo( + available=True, + current_version=Version('1.0.0'), + latest_version=Version('2.0.0'), + _velopack_info=mock_velopack_info, + ) + + with patch.object(updater, '_get_velopack_manager', return_value=mock_manager): + info = updater.check_for_update() + + assert info.available is True + assert updater.state == UpdateState.DOWNLOADED + class TestUpdaterDownloadUpdate: """Tests for download_update method."""