"""Тесты установки обновления. Первая версия перемещала папку app целиком - и на Windows это сломало сервис: код выполнялся из этой самой папки, перемещение не прошло, откат тоже не смог её вернуть, и app просто исчезла. Отсюда требования ниже: не двигать и не удалять рабочую папку, проверять новый код до установки, уметь откатиться пофайлово. """ import io import zipfile import pytest from app.updater import UpdateError, install_update def make_zip(files: dict[str, str]) -> bytes: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as z: for name, text in files.items(): z.writestr(name, text) return buf.getvalue() @pytest.fixture def base(tmp_path): app = tmp_path / "app" app.mkdir() (app / "main.py").write_text("старый main", encoding="utf-8") (app / "version.py").write_text('__version__ = "0.1.0"', encoding="utf-8") return tmp_path class TestNoDestructiveMoves: def test_app_directory_is_never_removed(self, base, monkeypatch): """rmtree по рабочей папке - прямой путь к потере сервиса.""" import shutil removed = [] monkeypatch.setattr(shutil, "rmtree", lambda p, **kw: removed.append(str(p))) install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True) assert not any(str(p).endswith("app") for p in removed) def test_app_directory_is_never_moved(self, base, monkeypatch): import shutil moved = [] real_move = shutil.move monkeypatch.setattr(shutil, "move", lambda s, d, **kw: (moved.append(str(s)), real_move(s, d))[1]) install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True) assert not any(str(p).rstrip("/\\").endswith("app") for p in moved) def test_directory_keeps_same_inode(self, base): """Папка должна остаться той же самой, а не быть пересозданной.""" before = (base / "app").stat().st_ino install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True) assert (base / "app").stat().st_ino == before class TestVerifyBeforeInstall: def test_broken_version_never_touches_working_files(self, base): """Проверять надо до установки, иначе сломанный код уже стоит.""" with pytest.raises(UpdateError): install_update(make_zip({"app/main.py": "сломанный"}), base, verify=lambda _: False) assert (base / "app" / "main.py").read_text(encoding="utf-8") == "старый main" def test_verify_receives_staging_not_live_dir(self, base): seen = {} def verify(path): seen["path"] = path # На момент проверки рабочий файл ещё не тронут seen["live_intact"] = (base / "app" / "main.py").read_text(encoding="utf-8") return True install_update(make_zip({"app/main.py": "новый"}), base, verify=verify) assert seen["live_intact"] == "старый main" assert seen["path"] != base class TestInstallResult: def test_files_are_updated(self, base): install_update(make_zip({"app/main.py": "новый", "app/version.py": '__version__ = "0.2.0"'}), base, verify=lambda _: True) assert (base / "app" / "main.py").read_text(encoding="utf-8") == "новый" assert '0.2.0' in (base / "app" / "version.py").read_text(encoding="utf-8") def test_new_files_appear(self, base): install_update(make_zip({"app/main.py": "новый", "app/extra.py": "доп"}), base, verify=lambda _: True) assert (base / "app" / "extra.py").is_file() def test_leftover_files_from_old_version_are_removed(self, base): """Файл, которого больше нет в релизе, не должен оставаться и импортироваться.""" (base / "app" / "obsolete.py").write_text("устаревший", encoding="utf-8") install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True) assert not (base / "app" / "obsolete.py").exists() def test_rollback_restores_files_on_copy_failure(self, base, monkeypatch): """Если копирование упало на середине, возвращаем прежние файлы.""" import shutil calls = {"n": 0} real_copy = shutil.copy2 def flaky_copy(src, dst, **kw): calls["n"] += 1 if calls["n"] == 2: raise OSError("файл занят") return real_copy(src, dst) monkeypatch.setattr(shutil, "copy2", flaky_copy) with pytest.raises(OSError): install_update(make_zip({"app/main.py": "новый", "app/a.py": "a", "app/b.py": "b"}), base, verify=lambda _: True) assert (base / "app" / "main.py").read_text(encoding="utf-8") == "старый main" def test_no_temp_dirs_left_behind(self, base): install_update(make_zip({"app/main.py": "новый"}), base, verify=lambda _: True) leftovers = [p.name for p in base.iterdir() if p.name.startswith(".update")] assert leftovers == []