"""Tests for services.installer — no GUI/network (download is mocked).""" import hashlib import shutil import sys import tempfile import unittest import zipfile from pathlib import Path from unittest.mock import patch sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from services import installer from services.update_service import UpdateInfo def _make_release_zip(dst_zip, version, exe_body, wrap=False): tmp = Path(tempfile.mkdtemp()) try: base = tmp / ("CMBot-" + version) if wrap else tmp base.mkdir(parents=True, exist_ok=True) (base / "CMBot.exe").write_text(exe_body, encoding="ascii") (base / "version.txt").write_text(version, encoding="ascii") with zipfile.ZipFile(str(dst_zip), "w", zipfile.ZIP_DEFLATED) as z: for p in base.rglob("*"): z.write(str(p), str(p.relative_to(tmp))) finally: shutil.rmtree(str(tmp), ignore_errors=True) return hashlib.sha256(dst_zip.read_bytes()).hexdigest() class _Base(unittest.TestCase): def setUp(self): self.tmp = Path(tempfile.mkdtemp()) self.root = self.tmp / "install" self.app = self.root / "app" self.app.mkdir(parents=True) (self.app / "CMBot.exe").write_text("OLD-1.0.0", encoding="ascii") (self.app / "version.txt").write_text("1.0.0", encoding="ascii") def tearDown(self): shutil.rmtree(str(self.tmp), ignore_errors=True) def _info(self, version, zip_path): return UpdateInfo(version=version, source="http://x/{}.zip".format(version), sha256=hashlib.sha256(zip_path.read_bytes()).hexdigest(), size=zip_path.stat().st_size) class TestDownloadAndStage(_Base): def _copy_download(self, src_zip): def fake(url, dest, user="", password="", timeout=120): shutil.copy(str(src_zip), str(dest)) return fake def test_stage_flat_zip(self): z = self.tmp / "n.zip" _make_release_zip(z, "1.1.0", "NEW") with patch("services.installer.download", side_effect=self._copy_download(z)): ver = installer.download_and_stage(self.root, self._info("1.1.0", z)) self.assertEqual(ver, "1.1.0") self.assertEqual(installer.staged_version(self.root), "1.1.0") self.assertTrue((installer.staged_app(self.root) / "CMBot.exe").exists()) # app untouched until apply self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0") def test_stage_wrapped_zip(self): z = self.tmp / "n.zip" _make_release_zip(z, "1.1.0", "NEW", wrap=True) with patch("services.installer.download", side_effect=self._copy_download(z)): ver = installer.download_and_stage(self.root, self._info("1.1.0", z)) self.assertEqual(ver, "1.1.0") self.assertEqual(installer.staged_version(self.root), "1.1.0") def test_sha_mismatch_raises(self): z = self.tmp / "n.zip" _make_release_zip(z, "1.1.0", "NEW") info = self._info("1.1.0", z) info.sha256 = "deadbeef" with patch("services.installer.download", side_effect=self._copy_download(z)): with self.assertRaises(ValueError): installer.download_and_stage(self.root, info) self.assertEqual(installer.staged_version(self.root), "") def test_version_mismatch_raises(self): z = self.tmp / "n.zip" _make_release_zip(z, "1.1.0", "NEW") info = self._info("9.9.9", z) # manifest claims a different version with patch("services.installer.download", side_effect=self._copy_download(z)): with self.assertRaises(ValueError): installer.download_and_stage(self.root, info) class TestApplyStaged(_Base): def _stage(self, version, body): s = installer.staged_app(self.root) s.mkdir(parents=True, exist_ok=True) (s / "CMBot.exe").write_text(body, encoding="ascii") (s / "version.txt").write_text(version, encoding="ascii") def test_apply_swaps(self): self._stage("1.1.0", "NEW-1.1.0") applied = installer.apply_staged(self.root) self.assertEqual(applied, "1.1.0") self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.1.0") self.assertEqual((self.app / "CMBot.exe").read_text(encoding="ascii"), "NEW-1.1.0") self.assertEqual((installer.old_dir(self.root) / "version.txt").read_text(encoding="ascii"), "1.0.0") self.assertEqual(installer.staged_version(self.root), "") def test_nothing_staged_returns_empty(self): self.assertEqual(installer.apply_staged(self.root), "") self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0") def test_not_newer_staged_is_discarded(self): self._stage("0.9.0", "OLDER") self.assertEqual(installer.apply_staged(self.root), "") self.assertEqual((self.app / "version.txt").read_text(encoding="ascii"), "1.0.0") self.assertFalse(installer.staged_app(self.root).exists()) class TestHelpers(_Base): def test_is_writable(self): self.assertTrue(installer.is_writable(self.root)) def test_install_root_for(self): self.assertEqual(installer.install_root_for(self.app), self.root.resolve()) if __name__ == "__main__": unittest.main()