Описание MCP-инструмента batch_create собирается из SPEC (commands.describe): обязательные и необязательные параметры каждого типа с подсказками формата (hints) и условием на набор (note). Ошибки validate «нет …» и «лишние параметры …» называют ожидаемый набор параметров типа. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
15 KiB
Python
278 lines
15 KiB
Python
import asyncio
|
||
import json
|
||
import re
|
||
import threading
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from jira_hub import api, commands, 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_batch_create_description_lists_params_from_spec():
|
||
"""Клиент MCP не видит specs/commands: описание инструмента перечисляет параметры каждого типа из SPEC (#44)."""
|
||
tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())}
|
||
desc = tools["batch_create"].description
|
||
for ctype, spec in commands.SPEC.items():
|
||
line = next(ln for ln in desc.splitlines() if ln.startswith(f"- {ctype}:"))
|
||
required, _, optional = line.removeprefix(f"- {ctype}:").partition("; [")
|
||
for name in spec["required"]:
|
||
assert re.search(rf"\b{name}\b", required), (ctype, name)
|
||
for name in spec["optional"]:
|
||
assert re.search(rf"\b{name}\b", optional), (ctype, name)
|
||
for name, hint in spec.get("hints", {}).items():
|
||
assert f"{name} ({hint})" in line, (ctype, name)
|
||
assert "target (статус или список-путь)" in desc and "sprint (имя спринта или backlog)" in desc
|
||
assert "labels" in desc and "AWPCA" in desc
|
||
|
||
|
||
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
|