Files
talkscore-asr/tests/test_updater_install.py
T
Vladimir BryzgalovandClaude Opus 5 59857fff0f Исправлено разрушение папки app при обновлении на Windows
Обновление перемещало папку app целиком, а код выполняется именно из неё.
Windows не даёт двигать такую папку: перемещение не прошло, откат тоже,
и app исчезла - сервис переставал запускаться.

Теперь папка остаётся на месте: новая версия проверяется во временном
каталоге ДО установки, затем файлы перезаписываются по одному с копией
для отката. Устаревшие модули удаляются, __pycache__ не трогается.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:00:03 +05:00

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 == []