sdlc-hub/tests/test_wiki_mcp.py
2026-10-24 11:45:40 +04:00

316 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.

"""MCP вики (wiki-module, spec mcp «MCP вики»): клиент хаба подменяется, живой хаб не нужен."""
import asyncio
import pytest
from sdlc_hub.core import mcp as core_mcp
from sdlc_hub.core import registry
from sdlc_hub.core.client import HubError
from sdlc_hub.modules.wiki import mcp as wmcp
PROJECTS = {"projects": [
{"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1", "wiki_root_title": "Корень", "wiki_pages": 3,
"wiki_synced_at": "2026-10-24T10:00:00+00:00"},
{"label": "sfm_kedr", "name": "СФМ", "wiki_root": None, "wiki_pages": None, "wiki_synced_at": None},
{"label": "fresh", "name": "Новый", "wiki_root": "DOC-9", "wiki_pages": 0, "wiki_synced_at": None}]}
TREE = {"pages": [
{"code": "DOC-1", "title": "Корень", "depth": 0, "parent_code": None, "size": 100},
{"code": "DOC-2", "title": "Статусы", "depth": 1, "parent_code": "DOC-1", "size": 200},
{"code": "DOC-3", "title": "Статистика", "depth": 1, "parent_code": "DOC-1", "size": 300},
{"code": "DOC-4", "title": "Глубокая", "depth": 2, "parent_code": "DOC-3", "size": 50},
{"code": "DOC-5", "title": "Ещё глубже", "depth": 3, "parent_code": "DOC-4", "size": 10}]}
class Fake:
"""Клиент хаба: ответы по пути; callable получает (method, params/body)."""
def __init__(self, routes):
self.routes, self.calls = routes, []
def _answer(self, method, path, arg):
self.calls.append((method, path, arg))
r = self.routes[(method, path)] if (method, path) in self.routes else self.routes[path]
if isinstance(r, list):
r = r.pop(0) if len(r) > 1 else r[0]
if isinstance(r, Exception):
raise r
return r(arg) if callable(r) else r
def get(self, path, **params):
return self._answer("GET", path, params)
def post(self, path, body=None):
return self._answer("POST", path, body)
def patch(self, path, body):
return self._answer("PATCH", path, body)
def get_bytes(self, path):
return self._answer("GET", path, None)
@pytest.fixture
def hub(monkeypatch, tmp_path):
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home"))
monkeypatch.setattr(wmcp, "POLL_SECONDS", 0.01)
monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA")
def make(routes, project="AWPCA"):
monkeypatch.setattr(core_mcp, "PROJECT", project)
fake = Fake({"/projects": PROJECTS, **routes})
monkeypatch.setattr(core_mcp, "client", lambda: fake)
return fake
return make
def paths_of(fake):
return [p for _, p, _ in fake.calls]
def test_unbound_tools_refuse(monkeypatch):
monkeypatch.setattr(core_mcp, "PROJECT", None)
fake = Fake({})
monkeypatch.setattr(core_mcp, "client", lambda: fake)
assert wmcp.tree() == core_mcp.NOT_BOUND
assert wmcp.search("x") == core_mcp.NOT_BOUND
assert wmcp.status() == core_mcp.NOT_BOUND
assert wmcp.root_set("DOC-1") == core_mcp.NOT_BOUND
assert asyncio.run(wmcp.sync()) == core_mcp.NOT_BOUND
assert fake.calls == []
def test_no_root_message(hub):
fake = hub({}, project="sfm_kedr")
msg = "у проекта sfm_kedr нет корня вики: root_set DOC-…"
assert wmcp.tree() == msg
assert wmcp.page(code="DOC-1") == msg
assert wmcp.search("x") == msg
assert asyncio.run(wmcp.sync()) == msg
assert asyncio.run(wmcp.attachment(1)) == msg
assert not any(p.startswith("/wiki/") for p in paths_of(fake))
def test_not_synced_message(hub):
fake = hub({}, project="fresh")
msg = "вики проекта ещё не синхронизирована: sync"
assert wmcp.tree() == msg and wmcp.search("x") == msg and wmcp.page(code="DOC-1") == msg
assert not any(p.startswith("/wiki/") for p in paths_of(fake))
def test_page_chunks(hub):
text = "я" * 45000
page = {"code": "DOC-3", "title": "Статистика", "path": "корень/стат", "url": "https://eva/doc3",
"synced_at": "2026-10-24T10:00:00+00:00", "markdown": text,
"attachments": [{"id": 7, "name": "m.xlsx", "state": "none", "size": 5}]}
hub({"/wiki/pages/DOC-3": page})
first = wmcp.page(code="DOC-3")
assert first.endswith("\nпродолжение: offset=20000")
assert "я" * 20000 in first and "я" * 20001 not in first
assert "DOC-3" in first and "корень/стат" in first and "https://eva/doc3" in first and "#7 m.xlsx" in first
last = wmcp.page(code="DOC-3", offset=40000)
assert "продолжение" not in last and last.endswith("я" * 5000)
mid = wmcp.page(code="DOC-3", offset=20000)
assert mid.endswith("продолжение: offset=40000")
def test_page_offset_past_end(hub):
hub({"/wiki/pages/DOC-3": {"code": "DOC-3", "title": "T", "path": "p", "url": "u", "synced_at": "s",
"markdown": "abc", "attachments": []}})
out = wmcp.page(code="DOC-3", offset=10)
assert "за концом" in out and "длина 3" in out and "продолжение" not in out
assert "за концом" in wmcp.page(code="DOC-3", offset=3) and "длина 3" in out
def test_page_by_title_candidates(hub):
fake = hub({"/wiki/tree": TREE})
out = wmcp.page(title="Стат")
assert "DOC-2 Статусы" in out and "DOC-3 Статистика" in out and "DOC-1" not in out
assert not any(p.startswith("/wiki/pages") for p in paths_of(fake))
def test_page_by_title_exact_case_insensitive(hub):
page = {"code": "DOC-3", "title": "Статистика", "path": "p", "url": "u", "synced_at": "s", "markdown": "тело",
"attachments": []}
fake = hub({"/wiki/tree": TREE, "/wiki/pages/DOC-3": page})
out = wmcp.page(title="статистика")
assert "тело" in out and "/wiki/pages/DOC-3" in paths_of(fake)
def test_page_by_title_ambiguous_and_missing(hub):
tree = {"pages": TREE["pages"] + [{"code": "DOC-8", "title": "статистика", "depth": 1, "parent_code": "DOC-1", "size": 1}]}
hub({"/wiki/tree": tree})
out = wmcp.page(title="Статистика")
assert "DOC-3 Статистика" in out and "DOC-8 статистика" in out and "тело" not in out
assert "нет страницы" in wmcp.page(title="Нет такой")
assert "code или title" in wmcp.page()
def test_page_candidates_capped(hub):
tree = {"pages": [{"code": f"DOC-{i}", "title": f"Раздел {i}", "depth": 1, "parent_code": None, "size": 1} for i in range(30)]}
hub({"/wiki/tree": tree})
out = wmcp.page(title="Раздел")
assert len([l for l in out.splitlines() if l.startswith("DOC-")]) == 10
def test_tree_depth_and_subtree(hub):
fake = hub({"/wiki/tree": TREE})
out = wmcp.tree()
lines = out.splitlines()
assert lines[0].startswith("DOC-1 Корень") and lines[1].startswith(" DOC-2 Статусы")
assert "DOC-4" in out and "DOC-5" not in out # depth=2 по умолчанию: корень + два уровня
sub = wmcp.tree(code="DOC-3", depth=1)
assert sub.splitlines()[0].startswith("DOC-3") and "DOC-4" in sub and "DOC-5" not in sub and "DOC-2" not in sub
assert "нет страницы DOC-9" in wmcp.tree(code="DOC-9")
assert fake.calls[-1][2] == {"project": "AWPCA"}
def test_search(hub):
fake = hub({"/wiki/search": {"results": [{"code": "DOC-2", "title": "Статусы", "path": "к/с", "snippet": "…согласован…"}]}})
out = wmcp.search("статус согласован", limit=5)
assert "DOC-2" in out and "Статусы" in out and "к/с" in out and "…согласован…" in out
assert fake.calls[-1] == ("GET", "/wiki/search", {"project": "AWPCA", "q": "статус согласован", "limit": 5})
hub({"/wiki/search": {"results": []}})
assert wmcp.search("zzz") == "ничего не найдено"
def test_root_set(hub):
fake = hub({("PATCH", "/projects/sfm_kedr"): {"project": {"label": "sfm_kedr", "wiki_root": "DOC-42"}, "batch_ids": [77]}},
project="sfm_kedr")
out = wmcp.root_set("https://eva.example/docs/doc-42")
assert "DOC-42" in out and "#77" in out
assert fake.calls[-1] == ("PATCH", "/projects/sfm_kedr", {"wiki_root": "DOC-42", "author": "claude"})
def test_root_set_invalid_refused_without_request(hub):
fake = hub({}, project="sfm_kedr")
for bad in ("", " ", "страница", "DOCX-1", 5):
out = wmcp.root_set(bad)
assert "DOC-" in out and "ошибка хаба" not in out, bad
assert not any(m == "PATCH" for m, _, _ in fake.calls)
def test_root_set_same_root_no_batch(hub):
hub({("PATCH", "/projects/AWPCA"): {"project": {}, "batch_ids": []}})
out = wmcp.root_set("DOC-1")
assert "DOC-1" in out and "пакета синхронизации не поставлено" in out
def agent_ok():
return {"online": True, "agent_version": "1", "settings_version": 2, "last_seen_at": "t", "rate_limited_24h": 0,
"release": None, "update_state": None, "agent_reported": True, "launcher_version": 1}
def test_status(hub):
fake = hub({"/wiki/status": {"root": "DOC-1", "root_title": "Корень", "synced_at": "2026-10-24T10:00:00+00:00", "pages": 3,
"error": "сбой чтения", "batch": {"id": 12, "status": "queued"}},
"/agent": agent_ok()})
out = wmcp.status()
assert "DOC-1" in out and "Корень" in out and "сбой чтения" in out and "#12" in out
assert "агент онлайн" in out and "запускатель: v1" in out
assert ("GET", "/agent", {"module": "wiki"}) in fake.calls
def test_status_no_root_still_shows_agent(hub):
fake = hub({"/agent": agent_ok()}, project="sfm_kedr")
out = wmcp.status()
assert "у проекта sfm_kedr нет корня вики" in out and "агент онлайн" in out
assert not any(p.startswith("/wiki/") for p in paths_of(fake))
def test_sync_nowait_and_in_progress(hub):
fake = hub({("POST", "/wiki/sync"): [{"batch_id": 5}, HubError(409, "sync_in_progress", {"batch_id": 5})]})
assert "#5" in asyncio.run(wmcp.sync())
assert fake.calls[-1] == ("POST", "/wiki/sync", {"project": "AWPCA", "author": "claude"})
out = asyncio.run(wmcp.sync())
assert "уже идёт" in out and "#5" in out
def test_sync_wait(hub):
done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "done", "error": None}]}
hub({("POST", "/wiki/sync"): {"batch_id": 5},
"/batches/5": [{"id": 5, "status": "running", "commands": []}, done],
"/wiki/status": {"root": "DOC-1", "root_title": "К", "synced_at": "s", "pages": 3, "error": None, "batch": None}})
out = asyncio.run(wmcp.sync(wait=True))
assert "#5" in out and "done" in out and "страниц: 3" in out
def test_sync_wait_timeout(hub, monkeypatch):
monkeypatch.setattr(wmcp, "SYNC_WAIT", 0.05)
hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": {"id": 5, "status": "running", "commands": []}})
assert "ещё выполняется" in asyncio.run(wmcp.sync(wait=True))
ATT_NAME = "../../etc/pass:wd.xlsx"
def att(state, **kw):
return {"id": 7, "name": ATT_NAME, "page_code": "DOC-3", "url": "u", "state": state, "size": 3, "error": None, **kw}
def test_attachment_ready_copy(hub, tmp_path):
fake = hub({("POST", "/wiki/attachments/7/fetch"): att("fetching"),
"/wiki/attachments/7": [att("fetching"), att("ready")],
"/wiki/attachments/7/content": b"xyz"})
out = asyncio.run(wmcp.attachment(7))
target = tmp_path / "home" / "downloads" / "wiki-7"
files = list(target.iterdir())
assert len(files) == 1 and files[0].read_bytes() == b"xyz"
assert files[0].name == "pass_wd.xlsx" and str(files[0]) in out
assert target.stat().st_mode & 0o777 == 0o700
assert ("POST", "/wiki/attachments/7/fetch", {"author": "claude"}) in fake.calls
@pytest.mark.parametrize("name,expected", [("..", "attachment-7"), ("a/b/c.txt", "c.txt"), ("C:\\x\\y.doc", "y.doc"),
("", "attachment-7"), (None, "attachment-7")])
def test_attachment_name_basename(hub, tmp_path, name, expected):
hub({("POST", "/wiki/attachments/7/fetch"): att("ready", name=name), "/wiki/attachments/7/content": b"x"})
asyncio.run(wmcp.attachment(7))
assert [p.name for p in (tmp_path / "home" / "downloads" / "wiki-7").iterdir()] == [expected]
def test_attachment_timeout_and_failed(hub, monkeypatch):
monkeypatch.setattr(wmcp, "ATTACHMENT_WAIT", 0.05)
hub({("POST", "/wiki/attachments/7/fetch"): att("fetching"), "/wiki/attachments/7": att("fetching")})
out = asyncio.run(wmcp.attachment(7))
assert "ещё идёт" in out and "ещё раз" in out
hub({("POST", "/wiki/attachments/7/fetch"): att("failed", error="HTTP 404")})
assert "не удалось скачать" in asyncio.run(wmcp.attachment(7)) and "HTTP 404" in asyncio.run(wmcp.attachment(7))
def test_attachment_unknown(hub):
hub({("POST", "/wiki/attachments/99/fetch"): HubError(404, "not_found", "вложение 99")})
assert asyncio.run(wmcp.attachment(99)).startswith("ошибка хаба 404")
def test_jira_server_unchanged():
from test_baseline import test_mcp_tools
test_mcp_tools()
def test_run_wiki_module():
m = registry.module("wiki")
assert m.mcp_server().name == "sdlc-wiki"
def test_wiki_tool_names_order():
names = [t.name for t in asyncio.run(wmcp.mcp.list_tools())]
assert names == ["tree", "page", "search", "attachment", "sync", "status", "root_set", "projects", "project_create"]
def test_projects_table_and_create(hub):
fake = hub({"/projects": PROJECTS, ("POST", "/projects"): {"project": {"label": "n", "name": "Н"}, "batch_id": 3}})
out = asyncio.run(wmcp.mcp.call_tool("projects", {}))
text = str(out)
assert "AWPCA" in text and "DOC-1" in text and "(текущий)" in text
created = wmcp.project_create("n", "Н", "DOC-5")
assert "sdlc-wiki" in created
assert fake.calls[-1][2]["wiki_root"] == "DOC-5"
def test_root_set_quotes_label(hub):
"""M-4: метка со спецсимволами кодируется в адресе PATCH."""
label = "A#B?c%d/e"
fake = hub({("PATCH", "/projects/A%23B%3Fc%25d%2Fe"): {"project": {}, "batch_ids": [5]}}, project=label)
fake.routes["/projects"] = {"projects": [{"label": label, "name": "x", "wiki_root": None}]}
assert "#5" in wmcp.root_set("DOC-2")
assert fake.calls[-1][1] == "/projects/A%23B%3Fc%25d%2Fe"