sdlc-hub/tests/test_mcp.py
2026-10-15 13:04:55 +04:00

261 lines
14 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.

import asyncio
import json
import re
import threading
from types import SimpleNamespace
import pytest
from jira_hub import api, mcp_server, settings
from jira_hub.client import HubClient
@pytest.fixture
def live(tmp_path, monkeypatch):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
h.claim_wait = 0
conn = h.conn()
tok = settings.tokens(conn)
conn.close()
srv = api.make_server(h, "127.0.0.1", 0)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_port}"
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"]))
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05)
yield {"agent": HubClient(base, tok["agent"])}
srv.shutdown()
srv.server_close()
def test_offline_message(monkeypatch):
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert mcp_server.issues() == mcp_server.OFFLINE
assert mcp_server.agent_status() == mcp_server.OFFLINE
def test_tools_roundtrip(live):
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема раз", "status": "Готово", "duedate": "2026-10-23",
"assignee": "ivanov", "sprint": "Спринт 2", "labels": ["AWPCA"]}], "links": []})
out = mcp_server.issues(sprint="Спринт 2")
assert "A-1" in out and "Тема раз" in out and "ivanov" in out
assert "В работе" in mcp_server.notes_set("A-1", fact_status="В работе")
detail = mcp_server.issue("A-1")
assert "fact_status: В работе" in detail and "Готово" in detail
created = mcp_server.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}])
batch_id = int(created.split("#")[1].split()[0])
assert "2 команд" in created and "мин" in created
bad = mcp_server.batch_create("x", [{"type": "ensure_fields", "params": {"key": "A-1"}}])
assert bad.startswith("ошибка хаба 422")
cmd = live["agent"].post("/agent/claim", {})["command"]
live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 400 поле"})
tail = live["agent"].post("/agent/claim", {})["command"]
live["agent"].post(f"/agent/commands/{tail['id']}/result", {"status": "done"})
waited = asyncio.run(mcp_server.batch_wait(batch_id, timeout_minutes=1))
assert "done" in waited and "failed" in waited and "HTTP 400" in waited
assert f"#{batch_id}" in mcp_server.batches(limit=5)
assert "Повтор команды" in mcp_server.command_retry(cmd["id"])
assert "cancelled" in mcp_server.batch_cancel(batch_id + 1)
r = asyncio.run(mcp_server.refresh(full=True))
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", r)
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(mcp_server.refresh(keys=["A-1"], wait=False)))
assert "онлайн" in mcp_server.agent_status()
assert "120" in mcp_server.settings_get("pacing")
assert "settings_version" in mcp_server.settings_set("pacing", json.dumps({"refresh": 1}))
assert mcp_server.settings_set("tokens", '"x"').startswith("ошибка хаба 422")
def test_formatters():
assert mcp_server.fmt_issues([]) == "задач нет"
text = mcp_server.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None,
"sprint": None, "fact_status": None}])
assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text
assert "очередь" in mcp_server.fmt_agent({"online": False, "agent_version": None, "current_command_id": None,
"rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None,
"settings_version": None})
text = mcp_server.fmt_agent({"online": True, "agent_version": "2", "current_command_id": 40, "rate_limited_24h": 0,
"redelivered": [{"id": 40, "type": "ensure_fields", "claimed_at": "2026-10-13T10:00:00+00:00",
"deliveries": 2}],
"warning": None, "last_seen_at": "2026-10-13T10:00:00+00:00", "settings_version": 3})
assert "онлайн" in text and "#40" in text and "выдач 2" in text
def test_settings_set_bad_json(live):
assert mcp_server.settings_set("pacing", "{не json").startswith("значение не JSON")
def _batch(status):
return {"id": 7, "title": "т", "author": "claude", "created_at": "2026-10-11", "status": status,
"remaining_seconds": 0, "counts": {status: 1}, "commands": []}
class FakeClient:
def __init__(self, statuses, runs=None):
self.statuses, self.calls, self.runs = list(statuses), 0, runs
def get(self, path, **params):
if path == "/sync-runs":
return {"runs": self.runs}
self.calls += 1
return _batch(self.statuses.pop(0) if len(self.statuses) > 1 else self.statuses[0])
def _fake_time(monkeypatch, step=1.0):
clock = {"now": 0.0, "sleeps": 0, "deadlines": []}
def monotonic():
clock["now"] += step
return clock["now"]
async def sleep(_):
clock["sleeps"] += 1
monkeypatch.setattr(mcp_server, "time", SimpleNamespace(monotonic=monotonic))
monkeypatch.setattr(mcp_server, "asyncio", SimpleNamespace(sleep=sleep, to_thread=asyncio.to_thread))
return clock
def test_batch_wait_polls_until_done(monkeypatch):
fake = FakeClient(["running", "running", "done"])
monkeypatch.setattr(mcp_server, "_client", lambda: fake)
clock = _fake_time(monkeypatch)
out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=10))
assert out == mcp_server.fmt_batch(_batch("done")) and "таймаут" not in out
assert fake.calls == 3 and clock["sleeps"] == 2
def test_batch_wait_timeout(monkeypatch):
fake = FakeClient(["running"])
monkeypatch.setattr(mcp_server, "_client", lambda: fake)
clock = _fake_time(monkeypatch, step=30.0) # 1 минута = 2 шага монотонных часов
out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=1))
assert "таймаут ожидания" in out
assert 1 < fake.calls < 10 and clock["sleeps"] == fake.calls - 1
@pytest.mark.parametrize("asked,minutes", [(0, 1), (1000, 60)])
def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes):
fake = FakeClient(["running"])
monkeypatch.setattr(mcp_server, "_client", lambda: fake)
clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута
asyncio.run(mcp_server.batch_wait(7, timeout_minutes=asked))
# первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна
assert fake.calls == minutes
def test_client_created_once_per_wait(monkeypatch):
fake, made = FakeClient(["running", "running", "done"]), []
monkeypatch.setattr(mcp_server, "_client", lambda: made.append(1) or fake)
_fake_time(monkeypatch)
asyncio.run(mcp_server.batch_wait(7))
assert len(made) == 1
def test_sync_log_content(monkeypatch):
runs = [{"id": 3, "kind": "full", "query": "project = AWPCA", "started_at": "2026-10-11T10:00", "finished_at": None,
"issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}]
monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs))
assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1"
def test_async_tools_are_coroutines_and_registered():
assert asyncio.iscoroutinefunction(mcp_server.batch_wait) and asyncio.iscoroutinefunction(mcp_server.refresh)
tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())}
assert {"batch_wait", "refresh"} <= set(tools)
assert tools["refresh"].inputSchema["properties"]["wait"]["default"] is False
def test_async_guard_offline(monkeypatch):
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert asyncio.run(mcp_server.batch_wait(1)) == mcp_server.OFFLINE
assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE
def test_batch_wait_does_not_block_event_loop(monkeypatch):
"""Пока идёт ожидание, другая задача event loop продолжает выполняться."""
fake = FakeClient(["running", "running", "done"])
monkeypatch.setattr(mcp_server, "_client", lambda: fake)
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.01)
async def scenario():
ticks = []
async def ticker():
for _ in range(5):
ticks.append(1)
await asyncio.sleep(0.005)
out, _ = await asyncio.gather(mcp_server.batch_wait(7), ticker())
return out, ticks
out, ticks = asyncio.run(scenario())
assert "done" in out and len(ticks) == 5
def test_fmt_batch_warns_about_claimed():
b = {"id": 9, "title": "x", "author": "claude", "created_at": "2026-10-14T10:00:00+00:00", "status": "running",
"remaining_seconds": 120, "counts": {"claimed": 1, "cancelled": 1}, "commands": [],
"still_claimed": [{"id": 30, "type": "ensure_fields"}]}
text = mcp_server.fmt_batch(b)
assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text
def test_batch_create_mentions_labels():
doc = mcp_server.batch_create.__doc__ or ""
assert "labels" in doc and "AWPCA" in doc
def test_fmt_agent_update_lines():
base = {"online": True, "agent_version": "6", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [],
"warning": None, "last_seen_at": "2026-10-14T20:00:00+00:00", "settings_version": 3}
text = mcp_server.fmt_agent({**base, "release": {"version": 7, "sha256": "s", "published_at": "2026-10-14T19:00:00+00:00"},
"update_state": {"state": "rolled_back", "from_version": 6, "to_version": 7},
"launcher_version": 1, "update_hint": "откат с v7 на v6: код 1 через 3 с"})
assert "публикация: v7 от 2026-10-14T19:00:00+00:00" in text
assert "обновление: rolled_back v6 → v7" in text
assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text
text = mcp_server.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None})
assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text
def test_fmt_agent_launcher_unknown_after_restart():
"""M-2 #25, решение 8: agent_reported = false — «неизвестно», а не «без запускателя»; поля нет — прежний текст."""
base = {"online": True, "agent_version": "9", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [],
"warning": None, "last_seen_at": "2026-10-15T10:00:00+00:00", "settings_version": 3,
"release": {"version": 10, "sha256": "s", "published_at": "2026-10-15T09:00:00+00:00"},
"update_state": None, "launcher_version": None, "update_hint": None}
text = mcp_server.fmt_agent({**base, "agent_reported": False})
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text
assert "без запускателя" not in text and "агент запущен без run.py" not in text
assert "без запускателя" in mcp_server.fmt_agent({**base, "agent_reported": True})
assert "без запускателя" in mcp_server.fmt_agent(base) # старый хаб: поля agent_reported нет
def test_agent_status_right_after_hub_start(live):
"""Спека mcp «Статус агента сразу после перезапуска хаба»."""
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in mcp_server.agent_status()
live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
text = mcp_server.agent_status()
assert "запускатель: v2" in text and "неизвестно" not in text
def _detail(**over):
d = {"key": "A-1", "summary": "Т", "labels": [], "links_out": [], "links_in": [], "note": None, "commands": [],
"comments": [], "description": None}
d.update(over)
return d
def test_fmt_issue_comments():
assert "комментариев нет" in mcp_server.fmt_issue(_detail())
comments = [{"comment_id": str(n), "author": f"Автор {n}", "created": f"2026-10-1{n}T12:00:00.000+0300",
"updated": None, "body": ("д" * 500 if n == 7 else f"текст {n}\nстрока")} for n in range(7, 0, -1)]
text = mcp_server.fmt_issue(_detail(comments=comments))
assert "комментарии (7):" in text
assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300
assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3
assert "ещё 2" in text
assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:")
no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None,
"updated": None, "body": ""}]))
assert "комментарии (1):" in no_author and "ещё" not in no_author