"""Integration tests for OTA update flow (mocked network).""" from __future__ import annotations import json import os import shutil import tempfile import unittest import zipfile from pathlib import Path from unittest.mock import MagicMock, patch from update import AutoUpdater class UpdateFlowTests(unittest.TestCase): def setUp(self) -> None: self._tmp = tempfile.mkdtemp(prefix="wesp-flow-") self.root = Path(self._tmp) data = self.root / "data" data.mkdir(parents=True) (data / "config.json").write_text(json.dumps({"version": "1.0.0"}), encoding="utf-8") (self.root / "requirements.txt").write_text("Flask==2.3.3\n", encoding="utf-8") wheels = self.root / "vendor" / "wheels" wheels.mkdir(parents=True) (wheels / "flask-2.3.3-py3-none-any.whl").write_bytes(b"x") (self.root / "wsgi.py").write_text("app = object()\n", encoding="utf-8") def tearDown(self) -> None: shutil.rmtree(self._tmp, ignore_errors=True) def _minimal_updater(self) -> AutoUpdater: with patch.object(AutoUpdater, "__init__", lambda self: None): u = AutoUpdater() u.base_dir = str(self.root) u.config_file = str(self.root / "data" / "config.json") u.enabled = True u.current_version = "1.0.0" u.is_updating = False u.pending_release = None u.update_progress = None u._state_lock = __import__("threading").Lock() u.restart_cmd = "true" u.protected_files = [ "data/recipes.db", "data/update_state.json", "data/.pending_restart.json", ] u.protected_folders = ["backups", "temp_updates", ".secret"] return u def _make_zip(self, path: Path) -> None: staging = self.root / "zip_staging" / "wesp" staging.mkdir(parents=True) shutil.copy(self.root / "requirements.txt", staging / "requirements.txt") shutil.copytree(self.root / "vendor", staging / "vendor") (staging / "run.py").write_text("# stub\n", encoding="utf-8") with zipfile.ZipFile(path, "w") as zf: for f in staging.rglob("*"): if f.is_file(): zf.write(f, f.relative_to(staging.parent)) @patch("update.AutoUpdater._run_restart_cmd") @patch("app.services.update_verify.run_smoke_test", return_value=(True, "OK")) @patch("app.services.update_verify.install_requirements_from_package") @patch("update.AutoUpdater.download_update") @patch("update.AutoUpdater.create_backup") def test_full_success( self, mock_backup, mock_download, mock_install, mock_smoke, mock_restart, ) -> None: backup_dir = self.root / "backups" / "b1" backup_dir.mkdir(parents=True) mock_backup.return_value = str(backup_dir) zip_path = self.root / "temp_updates" / "u.zip" zip_path.parent.mkdir(parents=True) self._make_zip(zip_path) mock_download.return_value = str(zip_path) mock_smoke.return_value = (True, "OK") u = self._minimal_updater() release = {"version": "2.0.0", "assets": []} self.assertTrue(u.update(release)) mock_install.assert_called_once() mock_restart.assert_called_once() @patch("update.AutoUpdater.create_backup") @patch("update.AutoUpdater.download_update") @patch("update.AutoUpdater.apply_update", return_value=True) @patch("app.services.update_verify.wheelhouse_covers_requirements", return_value=(False, ["flask"])) def test_deps_missing_rollback(self, _wh, _apply, mock_dl, mock_bk) -> None: bk = self.root / "backups" / "b2" bk.mkdir(parents=True) (bk / "run.py").write_text("old\n", encoding="utf-8") mock_bk.return_value = str(bk) mock_dl.return_value = str(self.root / "z.zip") u = self._minimal_updater() with patch.object(u, "_restore_from_backup") as mock_restore: self.assertFalse(u.update({"version": "2.0.0"})) mock_restore.assert_called() if __name__ == "__main__": unittest.main()