"""Скрипт переключения живой установки: только сухой прогон, отказы preflight и чистые функции.""" import importlib.util import json import os import plistlib import sys import subprocess from pathlib import Path import pytest SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "cutover.py" _spec = importlib.util.spec_from_file_location("cutover", SCRIPT) cutover = importlib.util.module_from_spec(_spec) sys.modules["cutover"] = cutover _spec.loader.exec_module(cutover) GIT_ENV = { "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", } def git(*args, cwd): subprocess.run(["git", *args], cwd=str(cwd), check=True, capture_output=True, env={**os.environ, **GIT_ENV}) def snapshot(root: Path): out = {} for p in sorted(root.rglob("*")): st = p.stat() out[str(p)] = (st.st_mtime_ns, st.st_size) return out @pytest.fixture def env(tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() monkeypatch.setenv("HOME", str(home)) bridge = tmp_path / "bridge" bare = tmp_path / "remote.git" bare.mkdir() git("init", "--bare", "-b", "main", cwd=bare) old = bridge / "jira-hub" old.mkdir(parents=True) git("init", "-b", "main", cwd=old) (old / "a.txt").write_text("x") git("add", ".", cwd=old) git("commit", "-m", "init", cwd=old) git("remote", "add", "origin", str(bare), cwd=old) git("push", "-u", "origin", "main", cwd=old) data = tmp_path / "share" (data / "jira-hub").mkdir(parents=True) (data / "jira-hub" / "hub.sqlite").write_text("db") proj = tmp_path / "proj" proj.mkdir() (proj / ".mcp.json").write_text(json.dumps({"mcpServers": {"jira-hub": {"command": "uv", "args": []}}})) for k, v in { "CUTOVER_BRIDGE": bridge, "CUTOVER_DATA_ROOT": data, "CUTOVER_LAUNCH_AGENTS": tmp_path / "la", "CUTOVER_LOGS": tmp_path / "logs", "CUTOVER_REMOTE": bare, "CUTOVER_PROJECTS": proj, }.items(): monkeypatch.setenv(k, str(v)) monkeypatch.setattr(cutover.shutil, "which", lambda name: "/usr/bin/" + name) return {"tmp": tmp_path, "bridge": bridge, "old": old, "data": data, "bare": bare, "proj": proj} def test_rewrite_mcp_json(tmp_path): p = tmp_path / ".mcp.json" old = "/old/jira-hub" p.write_text(json.dumps({"mcpServers": { "jira-hub": {"command": "uv", "args": ["run", "--project", old, "jira-hub", "mcp", "--project", "AWPCA"]}, "jira-hub-wiki": {"command": "uv", "args": ["run", "--project", old, "jira-hub", "mcp", "wiki", "--project", "AWPCA"]}, "other": {"command": "x", "args": ["1"]}, }})) co = Path("/new/sdlc-hub") assert cutover.rewrite_mcp_json(p, co) is True servers = json.loads(p.read_text())["mcpServers"] assert list(servers) == ["sdlc-jira", "sdlc-wiki", "other"] assert servers["sdlc-jira"]["args"] == ["run", "--project", str(co), "sdlc-hub", "mcp", "jira", "--project", "AWPCA"] assert servers["sdlc-wiki"]["args"] == ["run", "--project", str(co), "sdlc-hub", "mcp", "wiki", "--project", "AWPCA"] assert servers["other"] == {"command": "x", "args": ["1"]} assert cutover.rewrite_mcp_json(p, co) is False def test_rewrite_text_settings(tmp_path): p = tmp_path / "settings.json" p.write_text(json.dumps({"enabledMcpjsonServers": ["jira-hub", "jira-hub-wiki"], "permissions": {"allow": ["mcp__jira-hub__issue", "mcp__jira-hub-wiki__page"]}})) assert cutover.rewrite_text(p) == [] d = json.loads(p.read_text()) assert d["enabledMcpjsonServers"] == ["sdlc-jira", "sdlc-wiki"] assert d["permissions"]["allow"] == ["mcp__sdlc-jira__issue", "mcp__sdlc-wiki__page"] def test_rewrite_text_reports_leftovers(tmp_path): p = tmp_path / "CLAUDE.md" p.write_text("Мост Jira (aep/jira-hub, aep/jira-agent) работает через MCP `jira-hub` (user scope)\nкаталог jira-hub\n") left = cutover.rewrite_text(p) text = p.read_text() assert "aep/sdlc-hub" in text and "MCP `sdlc-jira`" in text assert left == ["каталог jira-hub"] def test_backup(tmp_path): p = tmp_path / "f.json" p.write_text("a") b = cutover.backup(p) assert b == tmp_path / "f.json.pre-sdlc" and b.read_text() == "a" def test_plist_text(tmp_path): log = tmp_path / "sdlc-hub.log" d = plistlib.loads(cutover.plist_text(tmp_path / "sdlc-hub", log, "/opt/homebrew/bin/uv").encode()) assert d["Label"] == "ru.aepif.sdlc-hub" assert d["ProgramArguments"][-2:] == ["sdlc-hub", "serve"] assert d["ProgramArguments"][0] == "/opt/homebrew/bin/uv" assert d["StandardOutPath"] == d["StandardErrorPath"] == str(log) assert d["KeepAlive"] is True def test_dry_run_changes_nothing(env, capsys): before = snapshot(env["tmp"]) assert cutover.main([]) == 0 assert snapshot(env["tmp"]) == before assert "2." in capsys.readouterr().out def test_apply_refuses_without_remote(env, capsys): os.environ["CUTOVER_REMOTE"] = str(env["tmp"] / "nope.git") before = snapshot(env["tmp"]) assert cutover.main(["--apply"]) == 1 assert "aep/sdlc-hub" in capsys.readouterr().out assert snapshot(env["tmp"]) == before def test_apply_refuses_when_target_exists(env, capsys): (env["data"] / "sdlc-hub").mkdir() before = snapshot(env["tmp"]) assert cutover.main(["--apply"]) == 1 assert "уже существует" in capsys.readouterr().out assert snapshot(env["tmp"]) == before def test_dirty_checkout_refused(env): (env["old"] / "dirty.txt").write_text("x") probs = cutover.preflight(cutover.Config.from_env()) assert any("не чист" in s for s in probs) def test_preflight_clean(env): assert cutover.preflight(cutover.Config.from_env()) == [] def test_preflight_reports_missing_tool(env, monkeypatch): monkeypatch.setattr(cutover.shutil, "which", lambda name: None if name in ("claude", "make") else "/x/" + name) probs = cutover.preflight(cutover.Config.from_env()) assert any("claude" in s for s in probs) and any("make" in s for s in probs) assert not any("uv " in s for s in probs) class _Fake: def __init__(self, rc_by_cmd=None): self.calls = [] self.rc_by_cmd = rc_by_cmd or {} def __call__(self, cmd, **kw): self.calls.append((list(cmd), kw)) rc = self.rc_by_cmd.get(cmd[2] if len(cmd) > 2 else "", 0) r = subprocess.CompletedProcess(cmd, rc) if kw.get("check") and rc: raise subprocess.CalledProcessError(rc, cmd) return r def test_step7_add_before_remove_and_remove_not_fatal(env, monkeypatch, capsys): fake = _Fake({"remove": 1}) monkeypatch.setattr(cutover.subprocess, "run", fake) cfg = cutover.Config.from_env() desc, fn = cutover.steps(cfg)[-1] fn() cmds = [c for c, _ in fake.calls] assert cmds[0][:3] == ["claude", "mcp", "add"] and "sdlc-jira" in cmds[0] assert cmds[1][:3] == ["claude", "mcp", "remove"] assert fake.calls[1][1].get("check") is False assert "ПРЕДУПРЕЖДЕНИЕ" in capsys.readouterr().out def test_step2_fails_if_port_still_open(env, monkeypatch, capsys): monkeypatch.setattr(cutover.subprocess, "run", _Fake()) monkeypatch.setattr(cutover, "_port_open", lambda port: True) t = [0.0] monkeypatch.setattr(cutover, "_clock", lambda: t[0]) monkeypatch.setattr(cutover, "_sleep", lambda d: t.__setitem__(0, t[0] + d)) monkeypatch.setattr(cutover, "preflight", lambda cfg: []) assert cutover.main(["--apply"]) == 1 out = capsys.readouterr().out assert "порт" in out and "остановлено на шаге 2" in out assert "мог быть выгружен" in out and "plist удалён" in out assert "ничего не изменено" not in out assert "launchctl bootstrap gui/%d %s" % (os.getuid(), env["tmp"] / "la" / "ru.aepif.jira-hub.plist") in out assert t[0] >= 10 def test_step2_waits_for_port_to_close(env, monkeypatch): monkeypatch.setattr(cutover.subprocess, "run", _Fake()) answers = iter([True, True, True, False]) monkeypatch.setattr(cutover, "_port_open", lambda port: next(answers)) t = [0.0] monkeypatch.setattr(cutover, "_clock", lambda: t[0]) monkeypatch.setattr(cutover, "_sleep", lambda d: t.__setitem__(0, t[0] + d)) cutover.steps(cutover.Config.from_env())[0][1]() assert 0 < t[0] < 10 def test_failure_prints_state_and_rollback(env, monkeypatch, capsys): def boom(): raise RuntimeError("сломалось") plan = [("2. a", lambda: None), ("3. b", lambda: None), ("4. c", boom)] monkeypatch.setattr(cutover, "preflight", lambda cfg: []) monkeypatch.setattr(cutover, "steps", lambda cfg, leftovers=None, backups=None: plan) assert cutover.main(["--apply"]) == 1 out = capsys.readouterr().out assert "остановлено на шаге 4: сломалось" in out assert "2. a" in out and "3. b" in out assert "хаб ru.aepif.jira-hub остановлен" in out and "каталог данных перенесён" in out assert "Откат" in out and "README" in out