sdlc-hub/tests/test_mcp.py
Andrey Epifancev c8af45ced8 Вики: безопасность и чистка (M-4, M-5, M-6, M-8, M-9, M-10, M-14, M-15 хаб)
- root_set кодирует метку; parse_root вынесен в wiki/roots.py; PATCH wiki_root "" -> 422
- суррогаты в пачке страниц заменяются; dir_name экранирует ведущую точку; error у вложений
- beautifulsoup4 и markdownify<2 объявлены; убраны неиспользуемые импорты и дубль _has_table

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:01:54 +04:00

849 lines
46 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 hashlib
import json
import os
import re
import threading
from pathlib import Path
from types import SimpleNamespace
import pytest
from jira_hub.core import api, files, settings
from jira_hub.core import mcp as core_mcp
from jira_hub.modules.jira import mcp as jmcp
from jira_hub.modules.jira import commands
from jira_hub.core.client import HubClient
@pytest.fixture
def live(tmp_path, monkeypatch, add_project):
h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
h.claim_wait = 0
conn = h.conn()
add_project(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(core_mcp, "client", lambda: HubClient(base, tok["client"]))
monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05)
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
yield {"agent": HubClient(base, tok["agents"]["jira"]), "client": HubClient(base, tok["client"])}
srv.shutdown()
srv.server_close()
def test_offline_message(monkeypatch):
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert jmcp.issues() == core_mcp.OFFLINE
assert jmcp.agent_status() == core_mcp.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 = jmcp.issues(sprint="Спринт 2")
assert "A-1" in out and "Тема раз" in out and "ivanov" in out
assert "В работе" in jmcp.notes_set("A-1", fact_status="В работе")
detail = jmcp.issue("A-1")
assert "fact_status: В работе" in detail and "Готово" in detail
created = jmcp.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 = jmcp.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(jmcp.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 jmcp.batches(limit=5)
assert "Повтор команды" in jmcp.command_retry(cmd["id"])
assert "cancelled" in jmcp.batch_cancel(batch_id + 1)
r = asyncio.run(jmcp.refresh(full=True))
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", r)
assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(jmcp.refresh(keys=["A-1"], wait=False)))
assert "онлайн" in jmcp.agent_status()
assert "120" in jmcp.settings_get("pacing")
assert "settings_version" in jmcp.settings_set("pacing", json.dumps({"refresh": 1}))
assert jmcp.settings_set("tokens", '"x"').startswith("ошибка хаба 422")
def test_formatters():
assert jmcp.fmt_issues([]) == "задач нет"
text = jmcp.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 jmcp.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 = jmcp.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 jmcp.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(jmcp, "time", SimpleNamespace(monotonic=monotonic))
monkeypatch.setattr(jmcp, "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(core_mcp, "client", lambda: fake)
clock = _fake_time(monkeypatch)
out = asyncio.run(jmcp.batch_wait(7, timeout_minutes=10))
assert out == jmcp.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(core_mcp, "client", lambda: fake)
clock = _fake_time(monkeypatch, step=30.0) # 1 минута = 2 шага монотонных часов
out = asyncio.run(jmcp.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(core_mcp, "client", lambda: fake)
_fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута
asyncio.run(jmcp.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(core_mcp, "client", lambda: made.append(1) or fake)
_fake_time(monkeypatch)
asyncio.run(jmcp.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(core_mcp, "client", lambda: FakeClient(["done"], runs))
assert jmcp.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(jmcp.batch_wait) and asyncio.iscoroutinefunction(jmcp.refresh)
tools = {t.name: t for t in asyncio.run(jmcp.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(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert asyncio.run(jmcp.batch_wait(1)) == core_mcp.OFFLINE
assert asyncio.run(jmcp.refresh(full=True)) == core_mcp.OFFLINE
def test_batch_wait_does_not_block_event_loop(monkeypatch):
"""Пока идёт ожидание, другая задача event loop продолжает выполняться."""
fake = FakeClient(["running", "running", "done"])
monkeypatch.setattr(core_mcp, "client", lambda: fake)
monkeypatch.setattr(jmcp, "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(jmcp.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 = jmcp.fmt_batch(b)
assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text
def test_batch_create_mentions_labels():
doc = jmcp.batch_create.__doc__ or ""
assert "метку проекта сессии хаб дописывает в ensure_issue сам" in doc
assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" in doc
assert "AWPCA" not 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(jmcp.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 "метку проекта сессии хаб дописывает в ensure_issue сам" in desc and "AWPCA" not 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 = jmcp.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 = jmcp.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 = jmcp.fmt_agent({**base, "agent_reported": False})
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text
assert "без запускателя" not in text and "агент запущен без run.py" not in text
assert "без запускателя" in jmcp.fmt_agent({**base, "agent_reported": True})
assert "без запускателя" in jmcp.fmt_agent(base) # старый хаб: поля agent_reported нет
def test_agent_status_right_after_hub_start(live):
"""Спека mcp «Статус агента сразу после перезапуска хаба»."""
assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in jmcp.agent_status()
live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
text = jmcp.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 jmcp.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 = jmcp.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 " #7 Автор 7 2026-10-17T12:00:00.000+0300: ддд" in text and " #3 Автор 3 " in text # номер — для comment (#80)
assert "ещё 2" in text
assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:")
no_author = jmcp.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None,
"updated": None, "body": ""}]))
assert "комментарии (1):" in no_author and "ещё" not in no_author
def test_fmt_comment():
"""Один комментарий целиком (#80): переносы строк сохраняются, «изменён» — только если правили."""
body = "первая строка\n" + "т" * 1000
text = jmcp.fmt_comment("A-1", {"comment_id": "12", "author": "golov", "created": "2026-10-20T10:00:00.000+0300",
"updated": "2026-10-21T09:00:00.000+0300", "body": body})
assert text == ("A-1 #12 golov, создан 2026-10-20T10:00:00.000+0300, изменён 2026-10-21T09:00:00.000+0300\n" + body)
same = jmcp.fmt_comment("A-1", {"comment_id": "12", "author": None, "created": "2026-10-20T10:00:00.000+0300",
"updated": "2026-10-20T10:00:00.000+0300", "body": ""})
assert same == "A-1 #12 —, создан 2026-10-20T10:00:00.000+0300\n—"
def test_comment_tool(live, monkeypatch):
long = "абв " * 200 + "\nконец"
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "Готово", "labels": ["AWPCA"],
"comments": [{"id": "101", "author": "golov", "body": long,
"created": "2026-10-20T10:00:00.000+0300"},
{"id": "102", "author": "ivanov", "body": "коротко",
"created": "2026-10-21T10:00:00.000+0300"}]}],
"links": []})
assert "#101 golov" in jmcp.issue("A-1") and long not in jmcp.issue("A-1")
out = jmcp.comment("A-1", "101")
assert out.startswith("A-1 #101 golov, создан 2026-10-20T10:00:00.000+0300\n") and out.endswith(long)
assert jmcp.comment("A-1", "#102").endswith("\nкоротко") # номер можно передать как в issue
assert jmcp.comment("A-1", "999") == "у A-1 нет комментария #999"
assert jmcp.comment("A-404", "1").startswith("ошибка хаба 404")
monkeypatch.setattr(core_mcp, "PROJECT", None)
assert jmcp.comment("A-1", "101") == core_mcp.NOT_BOUND
BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана",
"labels": ["AWPCA"]}}
def _batch_commands(out):
batch_id = int(out.split("#")[1].split()[0])
return core_mcp.client().get(f"/batches/{batch_id}")["commands"]
def _write(path, data):
path.write_bytes(data)
return str(path)
def test_batch_create_bug_with_local_file(live, tmp_path):
"""Спека mcp «Ошибка с файлом из чата»: файл загружен в хаб, path заменён на file и filename."""
jmcp.settings_set("issue_types", json.dumps({"task": {"name": "Задача", "id": "10100"},
"bug": {"name": "Ошибка", "id": None}}))
f = tmp_path / "ОСВ сч. 60 за 2 кв. 2026.xlsx"
f.write_bytes(b"PK\x03\x04 xlsx")
out = jmcp.batch_create("Ошибка ОСВ 60", [BUG, {"type": "ensure_attachment",
"params": {"key": "new:1", "path": str(f)}}])
assert "3 команд" in out
cmds = _batch_commands(out)
sha = hashlib.sha256(f.read_bytes()).hexdigest()
assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_attachment", "refresh"]
assert cmds[1]["params"] == {"key": "new:1", "file": sha, "filename": "ОСВ сч. 60 за 2 кв. 2026.xlsx"}
assert (tmp_path / "files" / sha).read_bytes() == f.read_bytes()
def test_batch_create_explicit_filename_and_tilde(live, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []})
(tmp_path / "скрин.png").write_bytes(b"png")
out = jmcp.batch_create("скрин", [{"type": "ensure_attachment", "params": {
"key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}])
assert _batch_commands(out)[0]["params"] == {"key": "A-1", "file": hashlib.sha256(b"png").hexdigest(),
"filename": "Экран 1.png"}
@pytest.mark.parametrize("make, expected", [
(lambda d: str(d / "нет.xlsx"), "файл не найден: {p}"),
(lambda d: str(d), "не файл: {p}"),
(lambda d: "отчёт.xlsx", "нужен абсолютный путь: {p}"),
(lambda d: _write(d / "пусто.xlsx", b""), "пустой файл: {p}"),
(lambda d: _write(d / "большой.bin", b"\0" * (files.MAX_SIZE + 1)),
"файл больше 10 МБ (" + str(files.MAX_SIZE + 1) + " байт): {p}"),
])
def test_batch_create_bad_path(live, tmp_path, make, expected):
"""Спека mcp «Файла нет» и соседние причины: текст с путём, пакет не создан, в хаб ничего не загружено."""
src = tmp_path / "src"
src.mkdir()
p = make(src)
out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": p}}])
assert out == expected.format(p=p)
assert core_mcp.client().get("/batches")["batches"] == []
assert list((tmp_path / "files").iterdir()) == []
def test_batch_create_unreadable_file(live, tmp_path):
f = tmp_path / "закрыт.xlsx"
f.write_bytes(b"x")
f.chmod(0)
try:
if os.access(f, os.R_OK):
pytest.skip("файл читается и без прав (root)")
out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}])
assert out == f"нет прав на чтение: {f}"
finally:
f.chmod(0o600)
def test_batch_create_path_and_file(live, tmp_path):
f = tmp_path / "a.xlsx"
f.write_bytes(b"a")
out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {
"key": "A-1", "path": str(f), "file": "0" * 64}}])
assert out == "у ensure_attachment либо path, либо file"
assert list((tmp_path / "files").iterdir()) == []
def test_batch_create_second_file_missing_uploads_nothing(live, tmp_path):
good = tmp_path / "a.xlsx"
good.write_bytes(b"a")
missing = tmp_path / "нет.xlsx"
out = jmcp.batch_create("x", [
{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(good)}},
{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(missing)}}])
assert out == f"файл не найден: {missing}"
assert list((tmp_path / "files").iterdir()) == []
def test_batch_create_hub_422_after_upload(live, tmp_path):
"""Отказ хаба по другой команде — текст ошибки; загруженный файл остаётся до уборки (design, «MCP»)."""
f = tmp_path / "a.xlsx"
f.write_bytes(b"a")
out = jmcp.batch_create("x", [BUG, {"type": "ensure_attachment", "params": {"key": "new:1", "path": str(f)}}])
assert out == "ошибка хаба 422 bad_batch: команда 1: нет настройки issue_types.bug"
assert core_mcp.client().get("/batches")["batches"] == []
def test_batch_create_description_mentions_path():
desc = {t.name: t for t in asyncio.run(jmcp.mcp.list_tools())}["batch_create"].description
assert "file (sha256 из POST /files; в MCP — path к локальному файлу)" in desc
assert "issue_type (task|epic|bug)" in desc
assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc
def _projects():
"""Инструмент projects сервера jira: его строит register_core_tools, в модуле функции нет."""
return jmcp.mcp._tool_manager.get_tool("projects").fn()
def test_unbound_session(live, monkeypatch): # сценарий «Сессия без проекта»
monkeypatch.setattr(core_mcp, "PROJECT", None)
calls = []
real = HubClient.call
monkeypatch.setattr(HubClient, "call", lambda self, m, p, *a, **k: calls.append(p) or real(self, m, p, *a, **k))
for out in (jmcp.issues(), jmcp.issue("A-1"), jmcp.notes_set("A-1", work_kind="x"),
jmcp.batch_create("x", []), jmcp.command_retry(1), asyncio.run(jmcp.refresh(full=True))):
assert out == core_mcp.NOT_BOUND
assert calls == []
for out in (jmcp.batches(), jmcp.sync_log(), jmcp.settings_get(), asyncio.run(jmcp.batch_wait(1))):
assert out != core_mcp.NOT_BOUND # не зависящие от проекта — работают без привязки
assert "AWPCA" in _projects()
assert jmcp.agent_status().startswith("агент ") # служебные инструменты работают без привязки
def test_unknown_bound_project(live, monkeypatch): # сценарий «Опечатка в проекте»
monkeypatch.setattr(core_mcp, "PROJECT", "STOKS")
assert jmcp.issues() == "проект STOKS не заведён в хабе: project_create или исправьте --project в .mcp.json"
assert jmcp.agent_status().startswith("проект STOKS не заведён")
assert asyncio.run(jmcp.batch_wait(1)).startswith("проект STOKS не заведён")
assert "AWPCA" in _projects()
def test_bound_offline(monkeypatch): # Review Focus 4: хаб выключен — не «проект не заведён»
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1))
assert jmcp.issues() == core_mcp.OFFLINE
assert jmcp.agent_status() == core_mcp.OFFLINE
assert asyncio.run(jmcp.refresh(full=True)) == core_mcp.OFFLINE
assert _projects() == core_mcp.OFFLINE
def test_bound_issues_and_create(live): # «Задачи своего проекта», «Создание задачи без метки»
live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"})
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "своя АРМ", "labels": ["AWPCA"]},
{"key": "S-1", "summary": "своя запасы", "labels": ["STOCKS_DRRK"]}], "links": []})
assert "A-1" in jmcp.issues() and "S-1" not in jmcp.issues()
out = jmcp.batch_create("новая", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "т"}}])
b = live["client"].get(f"/batches/{int(out.split('#')[1].split()[0])}")
assert b["project"] == "AWPCA" and b["commands"][0]["params"]["labels"] == ["AWPCA"]
assert jmcp.notes_set("S-1", work_kind="x") == "S-1 не в проекте AWPCA"
assert "work_kind: x" in jmcp.notes_set("new:3", work_kind="x") # временный ключ — можно, без GET /issues
assert "S-1" in jmcp.issue("S-1") # явный ключ — любая задача
assert jmcp.batch_create("чужая", [{"type": "ensure_fields", "params": {"key": "S-1", "duedate": "2026-10-23"}}]).startswith("ошибка хаба 422")
def test_bound_batches_and_retry(live, monkeypatch):
live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"})
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []})
mine = jmcp.batch_create("моя", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}])
mine_id = int(mine.split("#")[1].split()[0])
monkeypatch.setattr(core_mcp, "PROJECT", "STOCKS_DRRK")
other = jmcp.batch_create("чужого проекта", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "с"}}])
other_id = int(other.split("#")[1].split()[0])
out = jmcp.batches(limit=50)
assert f"пакет #{other_id} " in out and f"пакет #{mine_id} " not in out
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
assert f"пакет #{mine_id} " in jmcp.batches(limit=50) and f"пакет #{other_id} " not in jmcp.batches(limit=50)
assert f"#{other_id}" in jmcp.batch_status(other_id) # по id — без ограничения
other_cmd = live["client"].get(f"/batches/{other_id}")["commands"][0]["id"]
out = jmcp.command_retry(other_cmd)
assert out.startswith("ошибка хаба 422") and "не из проекта AWPCA" in out
monkeypatch.setattr(core_mcp, "PROJECT", None) # без привязки batches — все
assert f"пакет #{mine_id} " in jmcp.batches(limit=50) and f"пакет #{other_id} " in jmcp.batches(limit=50)
def test_project_create(live, monkeypatch): # сценарий «Проект из чата»
monkeypatch.setattr(core_mcp, "PROJECT", None)
out = jmcp.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120)
assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out
frag = json.loads(out[out.index("{"):])
assert frag == core_mcp.mcp_json("STOCKS_DRRK")
assert frag["mcpServers"]["jira-hub"]["args"][-2:] == ["--project", "STOCKS_DRRK"]
assert live["client"].get("/batches")["batches"][0]["author"] == "claude"
assert jmcp.project_create("awpca", "x").startswith("ошибка хаба 409")
assert jmcp.project_create("BAD LABEL", "x").startswith("ошибка хаба 422")
def test_hub_root_is_repo():
assert (core_mcp.HUB_ROOT / "pyproject.toml").is_file()
assert core_mcp.mcp_json("X")["mcpServers"]["jira-hub"]["args"][:3] == ["run", "--project", str(core_mcp.HUB_ROOT)]
def test_fmt_projects():
items = [{"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": "scrum", "issues_count": 190},
{"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": None, "board_type": None, "issues_count": 0}]
out = core_mcp.fmt_projects(items, "AWPCA", jmcp.PROJECT_COLUMNS).splitlines()
assert out[0] == "метка | название | доска | тип доски | задач"
assert out[1] == "AWPCA | АРМ КА | 4216 | scrum | 190 (текущий)"
assert out[2] == "STOCKS_DRRK | Запасы ДРРК | — | неизвестен | 0"
assert core_mcp.fmt_projects([], None, jmcp.PROJECT_COLUMNS) == "проектов нет"
assert "(текущий)" not in core_mcp.fmt_projects(items, None, jmcp.PROJECT_COLUMNS)
def test_run_binds_project(monkeypatch):
monkeypatch.setattr(core_mcp, "PROJECT", None)
monkeypatch.setattr(jmcp.mcp, "run", lambda transport: None)
assert core_mcp.run("jira", "AWPCA") == 0
assert core_mcp.PROJECT == "AWPCA"
# --- лента изменений (#70, Task 5) ------------------------------------------------------------------------------
from datetime import datetime, timedelta, timezone # noqa: E402
def _local(at: str) -> datetime:
return datetime.fromisoformat(at).astimezone()
def _ev(**kw):
base = {"at": "2026-10-22T08:15:00+00:00", "key": "X", "summary": "Тема", "status": "Готово", "kind": "status",
"field": "status", "author": "Петров Пётр", "from": "В работе", "to": "Готово", "from_value": None,
"to_value": None}
return {**base, **kw}
def test_fmt_event_status():
e = _ev()
hm = _local(e["at"]).strftime("%H:%M")
assert jmcp.fmt_event(e, _local(e["at"]).date()) == f"{hm} Петров Пётр: статус «В работе» → «Готово»"
def test_fmt_event_other_day_has_date():
e = _ev()
d = _local(e["at"])
assert jmcp.fmt_event(e, d.date() + timedelta(days=1)).startswith(d.strftime("%d.%m %H:%M "))
assert jmcp.fmt_event(e, None).startswith(d.strftime("%d.%m %H:%M "))
def test_fmt_event_dates_by_value():
e = _ev(kind="dates", field="duedate", **{"from": "25/окт/26", "to": None}, from_value="2026-10-25", to_value=None)
assert jmcp.fmt_event(e, None).endswith(": срок 25.10 → —")
e = _ev(kind="dates", field="planned_end", **{"from": "сырое", "to": "30/окт/26"}, from_value=None, to_value="2026-10-30 00:00:00.0")
assert jmcp.fmt_event(e, None).endswith(": плановое окончание сырое → 30.10")
def test_fmt_event_kinds():
assert jmcp.fmt_event(_ev(kind="description", field="description"), None).endswith(": описание изменено")
long = "к" * 250
out = jmcp.fmt_event(_ev(kind="comment", field="comment", **{"from": None, "to": long}), None)
assert out.endswith(": комментарий: " + "к" * 199 + "…")
out = jmcp.fmt_event(_ev(kind="other", field="assignee", **{"from": None, "to": "ivanov"}), None)
assert out.endswith(": assignee: «—» → «ivanov»")
def test_fmt_changes_grouped():
now = datetime(2026, 10, 22, 14, 5, tzinfo=timezone.utc).astimezone()
data = {"since": "2026-10-21T21:00:00+00:00", "until": "2026-10-22T11:05:00+00:00", "truncated": True, "changes": [
_ev(at="2026-10-22T09:00:00+00:00", key="Y", summary="Вторая", status="В работе"),
_ev(at="2026-10-22T08:15:00+00:00"),
_ev(at="2026-10-22T08:00:00+00:00", key="Y", summary="Вторая", status="В работе", kind="description",
field="description")]}
lines = jmcp.fmt_changes(data, now).split("\n")
assert lines[0].startswith("изменения с ")
assert lines[1] == "Y Вторая — В работе" and lines[2].startswith(" ") and lines[3].endswith("описание изменено")
assert lines[4] == "X Тема — Готово"
assert lines[-1] == "показаны последние 3 событий, сузьте период"
def test_fmt_changes_empty():
out = jmcp.fmt_changes({"since": "2026-10-22T10:00:00+00:00", "until": "2026-10-22T11:00:00+00:00",
"truncated": False, "changes": []}, datetime.now().astimezone())
assert out.split("\n")[1] == "за период изменений нет"
def _history(live, key, n, created="2026-10-22T08:00:00Z", **kw):
items = [{"history_id": f"{key}{i}", "seq": 0, "key": key, "created": created, "author": "Петров",
"field_id": "status", "field_name": "status", "from": None, "from_string": "a", "to": None,
"to_string": f"s{i}", **kw} for i in range(n)]
live["agent"].post("/agent/history", {"changes": items})
def test_changes_today(live): # «Что закрылось сегодня» / «За два часа»
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "status": "Готово",
"labels": ["AWPCA"]}], "links": []})
now = datetime.now(timezone.utc).replace(microsecond=0)
_history(live, "A-1", 1, created=(now - timedelta(minutes=30)).isoformat())
_history(live, "B-1", 1, created=(now - timedelta(minutes=30)).isoformat()) # не в проекте
out = jmcp.changes(kinds="status")
assert "A-1 Тема — Готово" in out and "статус «a» → «s0»" in out and "B-1" not in out
assert "A-1" in jmcp.changes(hours=2)
assert "B-1" in jmcp.changes(key="B-1")
def test_changes_hours_and_since(live):
assert jmcp.changes(hours=2, since="2026-10-22T10:00:00Z") == "либо hours, либо since"
assert jmcp.changes(hours=0) == "hours — число больше 0"
def test_changes_bad_since(live):
assert jmcp.changes(since="вчера").startswith("ошибка хаба 422")
def test_changes_naive_since_is_local(live): # M-2: время без смещения — местное
out = jmcp.changes(since="2026-10-22T09:00", until="2026-10-22T10:00")
assert out.split("\n")[0] == "изменения с 22.10 09:00 по 22.10 10:00"
def test_changes_empty(live):
assert jmcp.changes(hours=1).split("\n")[1] == "за период изменений нет"
def test_changes_unbound(live, monkeypatch):
monkeypatch.setattr(core_mcp, "PROJECT", None)
assert jmcp.changes() == core_mcp.NOT_BOUND
assert jmcp.changes(key="A-1").split("\n")[1] == "за период изменений нет"
def test_issue_shows_changes(live): # «История в карточке»
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "labels": ["AWPCA"]}], "links": []})
assert "изменения (" not in jmcp.issue("A-1")
_history(live, "A-1", 7)
out = jmcp.issue("A-1")
assert "изменения (7):" in out and " ещё 2" in out and out.count("статус «a»") == 5
def test_sync_log_history(live):
asyncio.run(jmcp.refresh(full=True))
cmd = live["agent"].post("/agent/claim", {})["command"]
assert "журнал: нет" in jmcp.sync_log()
live["agent"].post(f"/agent/commands/{cmd['id']}/result",
{"status": "done", "result": {"history": {"issues": 0, "changes": 0, "incomplete": []}}})
assert re.search(r"журнал: принят \d\d\.\d\d \d\d:\d\d", jmcp.sync_log())
# --- вложения (#83) -----------------------------------------------------------------
ATT_KEY = "AURORA-1650"
def _att(aid, name, size, state="none", **over):
d = {"attachment_id": aid, "filename": name, "size": size, "mime_type": "image/png", "author": "golov",
"created": "2026-10-20T10:00:00.000+0300", "state": state, "error": None}
d.update(over)
return d
class FakeHub:
"""Фейковый HubClient: журнал вызовов и сценарий ответов по маршрутам."""
def __init__(self, issue, fetch=None, polls=None, data=b"x" * 10, online=True):
self.issue, self.fetch, self.polls, self.data, self.online = issue, fetch, polls or [], data, online
self.calls = []
def get(self, path, **params):
self.calls.append(("GET", path))
if path == f"/issues/{ATT_KEY}":
return self.issue
if path == "/agent":
return {"online": self.online}
if path.startswith("/attachments/"):
return self.polls.pop(0) if len(self.polls) > 1 else self.polls[0]
raise AssertionError(path)
def post(self, path, body=None):
self.calls.append(("POST", path, body))
if isinstance(self.fetch, Exception):
raise self.fetch
return self.fetch
def get_bytes(self, path):
self.calls.append(("BYTES", path))
return self.data
@pytest.fixture
def downloads(tmp_path, monkeypatch):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05)
return tmp_path / "home" / "downloads"
def _use(monkeypatch, hub):
monkeypatch.setattr(core_mcp, "client", lambda: hub)
monkeypatch.setattr(core_mcp, "unbound_or_unknown", lambda required: None)
def _issue_with_att():
return _detail(key=ATT_KEY, description="см. !скрин 1.png!", description_attachment_ids=["31337"],
comments=[{"comment_id": "101", "author": "golov", "created": "2026-10-20T10:00:00.000+0300",
"updated": None, "body": "!скрин 1.png!", "attachment_ids": ["31337"]}],
attachments=[_att("31338", "запись.mp4", 26214400, mime_type="video/mp4"),
_att("31337", "скрин 1.png", 239821, state="ready")])
def test_issue_attachments_block():
text = jmcp.fmt_issue(_issue_with_att())
assert "вложения (2):" in text and text.index("комментарии") < text.index("вложения (2):")
lines = {l.split()[0]: l for l in text.splitlines() if l.strip().startswith("#3133")}
assert "больше 10 МБ — только в Jira" in lines["#31338"] and "25,0 МБ" in lines["#31338"]
assert "(234,2 КБ)" in lines["#31337"] and "в описании, в #101, скачано" in lines["#31337"]
assert "файлы: #31337 скрин 1.png" in text
assert "вложений нет" in jmcp.fmt_issue(_detail(attachments=[]))
assert "вложений нет" in jmcp.fmt_issue(_detail())
def test_comment_attachments():
d = _issue_with_att()
text = jmcp.fmt_comment(ATT_KEY, d["comments"][0], d["attachments"])
assert text.endswith("вложения:\n #31337 скрин 1.png (234,2 КБ)")
assert "вложения" not in jmcp.fmt_comment(ATT_KEY, {**d["comments"][0], "attachment_ids": []}, d["attachments"])
def test_safe_name_limits_255_bytes():
"""M-7: имя ≤ 255 байт UTF-8, расширение сохраняется, многобайтовый символ не режется."""
for stem in ("я" * 200, "ы" * 127 + "a"):
name = jmcp.safe_name(stem + ".xlsx", "7")
assert len(name.encode()) <= 255 and name.endswith(".xlsx")
name.encode("utf-8")
assert name.startswith("я") or name.startswith("ы")
assert len(jmcp.safe_name("ф" * 300, "7").encode()) <= 255
assert jmcp.safe_name("короткое.png", "7") == "короткое.png"
def test_attachment_long_cyrillic_name_written(downloads, monkeypatch):
name = "я" * 200 + ".png"
att = _att("31337", name, 3, state="ready")
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "ready", "command_id": 5}, polls=[att], data=b"abc")
_use(monkeypatch, hub)
out = asyncio.run(jmcp.attachment(ATT_KEY, "31337"))
path = Path(out.splitlines()[0])
assert path.is_file() and path.read_bytes() == b"abc" and len(path.name.encode()) <= 255
def test_safe_name():
assert jmcp.safe_name("a/b\\c:d\x01.png", "7") == "a_b_c_d_.png"
assert jmcp.safe_name(" ", "7") == "attachment-7"
assert jmcp.safe_name("..", "7") == "attachment-7"
assert jmcp.safe_name("a\x7f", "7") == "a_"
def _ready_hub(size=239821, data=b"PNG" * 3):
att = _att("31337", "скрин 1.png", size, state="ready")
return FakeHub(_detail(key=ATT_KEY, attachments=[att]),
fetch={"state": "ready", "command_id": 5}, polls=[att], data=data)
def test_attachment_by_id_ready(downloads, monkeypatch):
hub = _ready_hub()
_use(monkeypatch, hub)
out = asyncio.run(jmcp.attachment(ATT_KEY, "#31337"))
path = downloads / "31337" / "скрин 1.png"
lines = out.splitlines()
assert lines[0] == str(path) and lines[1] == "скрин 1.png (234,2 КБ), image/png, задача AURORA-1650"
assert path.read_bytes() == b"PNG" * 3
assert (downloads.stat().st_mode & 0o777) == 0o700 and ((downloads / "31337").stat().st_mode & 0o777) == 0o700
assert ("POST", "/attachments/31337/fetch", {"author": "claude"}) in hub.calls
def test_attachment_by_name_ambiguous(downloads, monkeypatch):
a20, a10 = _att("20", "скрин.png", 3), _att("10", "скрин.png", 3) # новые сверху, как отдаёт хаб
hub = FakeHub(_detail(key=ATT_KEY, attachments=[a20, a10]), fetch={"state": "ready", "command_id": 1},
polls=[{**a20, "state": "ready"}], data=b"abc")
_use(monkeypatch, hub)
out = asyncio.run(jmcp.attachment(ATT_KEY, "скрин.png"))
assert "у AURORA-1650 2 вложений «скрин.png», взято самое новое #20" in out
assert str(downloads / "20" / "скрин.png") in out
def test_attachment_not_found(downloads, monkeypatch):
hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("1", "a.png", 1)]))
_use(monkeypatch, hub)
out = asyncio.run(jmcp.attachment(ATT_KEY, "нет.png"))
assert out == "у AURORA-1650 нет вложения «нет.png»; список — issue AURORA-1650"
assert all(c[1] == f"/issues/{ATT_KEY}" for c in hub.calls)
def test_attachment_wait_timeout(downloads, monkeypatch):
att = _att("1", "a.png", 5)
fetching = {**att, "state": "fetching", "command_id": 9}
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=fetching, polls=[fetching])
_use(monkeypatch, hub)
out = asyncio.run(jmcp.attachment(ATT_KEY, "1", wait_seconds=1))
assert out == "скачивание ещё идёт (команда #9, агент онлайн); вызовите attachment ещё раз — вторая команда не поставится"
def test_attachment_failed(downloads, monkeypatch):
att = _att("1", "a.png", 5)
failed = {**att, "state": "failed", "error": "HTTP 404", "command_id": 3}
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "fetching", "command_id": 3}, polls=[failed])
_use(monkeypatch, hub)
assert asyncio.run(jmcp.attachment(ATT_KEY, "1")) == "не удалось скачать: HTTP 404"
def test_attachment_413(downloads, monkeypatch):
from jira_hub.core.client import HubError
att = _att("5", "запись.mp4", 26214400)
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой"))
_use(monkeypatch, hub)
assert asyncio.run(jmcp.attachment(ATT_KEY, "#5")) == \
"файл больше 10 МБ (25,0 МБ) — скачайте в Jira: задача AURORA-1650, вложение «запись.mp4»"
def test_attachment_409_passes_hub_text(downloads, monkeypatch):
from jira_hub.core.client import HubError
hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]),
fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14"))
_use(monkeypatch, hub)
assert "агент v9 не умеет" in asyncio.run(jmcp.attachment(ATT_KEY, "5"))
def test_attachment_existing_copy_same_size(downloads, monkeypatch):
hub = _ready_hub(data=b"PNG" * 3)
_use(monkeypatch, hub)
path = downloads / "31337" / "скрин 1.png"
path.parent.mkdir(parents=True)
path.write_bytes(b"OLD" * 3)
os.utime(path, (1_000_000, 1_000_000))
os.utime(path.parent, (1_000_000, 1_000_000))
asyncio.run(jmcp.attachment(ATT_KEY, "31337"))
assert path.read_bytes() == b"OLD" * 3
assert path.parent.stat().st_mtime > 1_000_000 + 100 # M-6: папка «освежена» — уборка её не заденет