Обновление перемещало папку app целиком, а код выполняется именно из неё. Windows не даёт двигать такую папку: перемещение не прошло, откат тоже, и app исчезла - сервис переставал запускаться. Теперь папка остаётся на месте: новая версия проверяется во временном каталоге ДО установки, затем файлы перезаписываются по одному с копией для отката. Устаревшие модули удаляются, __pycache__ не трогается. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
126 lines
5.6 KiB
Python
126 lines
5.6 KiB
Python
"""Тесты установки обновления.
|
|
|
|
Первая версия перемещала папку 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 == []
|