sdlc-hub/tests/test_cutover.py
2026-10-24 12:09:16 +04:00

209 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Скрипт переключения живой установки: только сухой прогон, отказы 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):
monkeypatch.setattr(cutover.subprocess, "run", _Fake())
monkeypatch.setattr(cutover, "_port_open", lambda port: True)
cfg = cutover.Config.from_env()
with pytest.raises(RuntimeError, match="порт"):
cutover.steps(cfg)[0][1]()
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